Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

137 linesCodeBlameRaw
1import type { Folio } from "@g1t/contracts";
2import { RotateCcw, Trash2 } from "lucide-react";
3import { useState } from "react";
4import { data } from "react-router";
5
6import type { Route } from "./+types/trash";
7import { useFoliosAction } from "../../../components/folios/actions";
8import { KindIcon } from "../../../components/folios/kinds";
9import { EmptyState, ErrorText, TimeAgo } from "../../../components/ui";
10import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "../../../components/ui/alert-dialog";
11import { Button } from "../../../components/ui/button";
12import { Card } from "../../../components/ui/card";
13import { canDo } from "../../../lib/folios";
14import { page } from "../../../lib/meta";
15import { folios } from "../../../lib/services.server";
16import { requireUser, roleIn } from "../../../lib/session.server";
17
18export function meta({ params, ...args }: Route.MetaArgs) {
19 return page(args, { title: `Trash · Artifacts · ${params.owner} · g1t` });
20}
21
22export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ items: Folio[] | null }> {
23 const viewer = requireUser(context, request);
24 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
25 const found = await folios.trashed(params.owner.toLowerCase(), viewer).catch(() => null);
26 return { items: found?.ok ? found.value : null };
27}
28
29const count = (n: number) => `${n} ${n === 1 ? "artifact" : "artifacts"}`;
30
31/**
32 * Artifacts moved to the trash that the viewer can change: restore them,
33 * delete one for good, or empty the trash (everything they have full
34 * access to goes). After 30 days they go on their own. Every change
35 * reloads the sidebar's count with the page.
36 */
37export default function FoliosTrash({ loaderData, params }: Route.ComponentProps) {
38 const slug = params.owner.toLowerCase();
39 const { send, busy, error } = useFoliosAction(slug);
40 const [forever, setForever] = useState<Folio | null>(null);
41 const [emptying, setEmptying] = useState(false);
42 const items = loaderData.items;
43 const deletable = (items ?? []).filter((f) => canDo(f.viewer_role, "manage"));
44 const kept = (items?.length ?? 0) - deletable.length;
45 return (
46 <div className="mx-auto max-w-3xl">
47 <div className="flex flex-wrap items-end justify-between gap-4">
48 <div>
49 <h1 className="text-xl font-semibold tracking-tight">Trash</h1>
50 <p className="mt-1 text-sm text-muted">Artifacts come back with what was inside them. After 30 days in the trash they are deleted for good.</p>
51 </div>
52 {deletable.length > 0 && (
53 <Button type="button" variant="destructive" disabled={busy} onClick={() => setEmptying(true)}>
54 <Trash2 size={14} /> Empty trash
55 </Button>
56 )}
57 </div>
58 {error && (
59 <div className="mt-3">
60 <ErrorText>{error}</ErrorText>
61 </div>
62 )}
63 <div className="mt-6">
64 {items === null ? (
65 <EmptyState title="Artifacts didn't answer">Try again in a moment.</EmptyState>
66 ) : items.length === 0 ? (
67 <EmptyState title="The trash is empty" />
68 ) : (
69 <Card asChild divided className="overflow-hidden">
70 <ul>
71 {items.map((f) => (
72 <li key={f.id} className="flex items-center gap-3 px-3 py-2.5 sm:px-4">
73 <KindIcon kind={f.kind} />
74 <span className="min-w-0 grow">
75 <span className="block truncate text-sm">{f.title || "Untitled"}</span>
76 <span className="block truncate text-xs text-faint">
77 {f.space?.name ?? "Private"} · trashed {f.trashed_at ? <TimeAgo at={f.trashed_at} /> : null}
78 </span>
79 </span>
80 <Button type="button" disabled={busy} onClick={() => send("restore", { folio_id: f.id })} variant="outline" size="sm" className="text-xs text-fg hover:bg-raised font-normal">
81 <RotateCcw size={13} /> Restore
82 </Button>
83 {canDo(f.viewer_role, "manage") && (
84 <Button type="button" disabled={busy} onClick={() => setForever(f)} variant="destructive" size="sm" className="text-xs font-normal">
85 <Trash2 size={13} /> <span className="max-sm:hidden">Delete</span>
86 </Button>
87 )}
88 </li>
89 ))}
90 </ul>
91 </Card>
92 )}
93 </div>
94 <AlertDialog open={!!forever} onOpenChange={(open) => !open && setForever(null)}>
95 <AlertDialogContent>
96 <AlertDialogHeader>
97 <AlertDialogTitle>Delete &ldquo;{forever?.title || "Untitled"}&rdquo; for good?</AlertDialogTitle>
98 <AlertDialogDescription>It goes with its history, comments and everything inside it. This can&apos;t be undone.</AlertDialogDescription>
99 </AlertDialogHeader>
100 <AlertDialogFooter>
101 <AlertDialogCancel>Keep it</AlertDialogCancel>
102 <AlertDialogAction
103 onClick={() => {
104 if (forever) void send("delete", { folio_id: forever.id });
105 setForever(null);
106 }}
107 >
108 Delete for good
109 </AlertDialogAction>
110 </AlertDialogFooter>
111 </AlertDialogContent>
112 </AlertDialog>
113 <AlertDialog open={emptying} onOpenChange={setEmptying}>
114 <AlertDialogContent>
115 <AlertDialogHeader>
116 <AlertDialogTitle>Empty the trash?</AlertDialogTitle>
117 <AlertDialogDescription>
118 {count(deletable.length)} you have full access to {deletable.length === 1 ? "goes" : "go"} for good, with {deletable.length === 1 ? "its" : "their"} history, comments and everything inside.
119 {kept > 0 ? ` The ${count(kept)} you can only edit ${kept === 1 ? "stays" : "stay"}.` : ""} This can&apos;t be undone.
120 </AlertDialogDescription>
121 </AlertDialogHeader>
122 <AlertDialogFooter>
123 <AlertDialogCancel>Keep everything</AlertDialogCancel>
124 <AlertDialogAction
125 onClick={() => {
126 void send("empty_trash");
127 setEmptying(false);
128 }}
129 >
130 Delete {count(deletable.length)} for good
131 </AlertDialogAction>
132 </AlertDialogFooter>
133 </AlertDialogContent>
134 </AlertDialog>
135 </div>
136 );
137}