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.

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