SPB Git
6.5 KB · 64 lines tsx
1"use client";2import Link from "next/link";3import { useEffect, useState } from "react";4import { useQuery, useQueryClient } from "@tanstack/react-query";5import { useRouter } from "next/navigation";6import { Trash2, RotateCcw, AlertTriangle, Clock } from "lucide-react";7import { api } from "@/lib/client/api";8import { fmtDate, timeAgo } from "@/lib/utils";9import { Button } from "@/components/ui/button";10import { Badge, Dialog, DialogContent, Empty, Spinner } from "@/components/ui/primitives";11import { toast } from "@/components/ui/toast";1213interface TrashedPage { id: string; title: string; icon: string | null; section: string; type: string; deletedAt: string; wordCount: number; updatedAt: string }14const AUTO_PURGE_DAYS = 30;1516export function TrashView() {17  const qc = useQueryClient(); const r = useRouter();18  const [purged, setPurged] = useState<number | null>(null);19  const { data: items = [], isLoading, refetch } = useQuery({ queryKey: ["trash"], queryFn: () => api<TrashedPage[]>("/api/pages?view=trash&limit=100"), enabled: purged !== null, staleTime: 5_000 });20  useEffect(() => { api("/api/trash", { method: "POST", json: { action: "purge-old", days: AUTO_PURGE_DAYS } }).then((res) => setPurged(res.purged ?? 0)).catch(() => setPurged(0)); }, []);21  const [confirm, setConfirm] = useState<{ kind: "one"; page: TrashedPage } | { kind: "all" } | null>(null); const [busy, setBusy] = useState(false);22  const refresh = () => { qc.invalidateQueries({ queryKey: ["trash"] }); qc.invalidateQueries({ queryKey: ["tree"] }); qc.invalidateQueries({ queryKey: ["dashboard"] }); };23  async function restore(p: TrashedPage) { try { await api(`/api/pages/${p.id}?action=restore`, { method: "POST" }); toast("Restored", { description: p.title || "Untitled", tone: "success" }); refresh(); } catch (e: any) { toast("Restore failed", { description: e.message, tone: "danger" }); } }24  async function doConfirm() {25    if (!confirm) return; setBusy(true);26    try {27      if (confirm.kind === "one") { await api(`/api/pages/${confirm.page.id}?purge=1`, { method: "DELETE" }); toast("Deleted forever", { description: confirm.page.title || "Untitled" }); }28      else { const res = await api("/api/trash", { method: "DELETE" }); toast("Trash emptied", { description: `${res.purged} page${res.purged === 1 ? "" : "s"} deleted forever` }); }29      setConfirm(null); refresh();30    } catch (e: any) { toast("Delete failed", { description: e.message, tone: "danger" }); } finally { setBusy(false); }31  }32  const daysLeft = (iso: string) => Math.max(0, AUTO_PURGE_DAYS - Math.floor((Date.now() - new Date(iso).getTime()) / 864e5));33  return (34    <div className="mx-auto w-full max-w-4xl px-4 py-6 sm:px-6 sm:py-8">35      <div className="mb-4 flex flex-wrap items-end gap-3">36        <div className="min-w-0 flex-1"><h1 className="text-2xl font-semibold tracking-tight">Trash</h1><p className="mt-0.5 text-[13px] text-fg-muted">Pages are deleted forever after {AUTO_PURGE_DAYS} days in the trash.{purged ? ` ${purged} old item${purged === 1 ? "" : "s"} just purged.` : ""}</p></div>37        <Button size="sm" variant="outline" className="text-danger hover:bg-danger/10" disabled={!items.length} onClick={() => setConfirm({ kind: "all" })}><Trash2 /> Empty trash</Button>38      </div>39      {isLoading || purged === null ? <div className="flex justify-center py-16"><Spinner /></div> : !items.length ? <Empty icon={<Trash2 />} title="Trash is empty" hint="Pages you move to the trash can be restored from here for 30 days." action={<Button size="sm" variant="outline" onClick={() => r.push("/")}>Back home</Button>} /> : (40        <div className="overflow-hidden rounded-xl border border-line bg-bg-elev shadow-card">41          <div className="hidden grid-cols-[1fr_110px_150px_80px_170px] gap-2 border-b border-line bg-bg-sunk/60 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-fg-faint md:grid"><span>Page</span><span>Section</span><span>Deleted</span><span className="text-right">Words</span><span /></div>42          {items.map((p) => (43            <div key={p.id} className="grid grid-cols-1 gap-2 border-b border-line px-3 py-2.5 last:border-b-0 md:grid-cols-[1fr_110px_150px_80px_170px] md:items-center">44              <Link href={`/p/${p.id}`} className="flex min-w-0 items-center gap-2 text-[13.5px] hover:text-accent"><span className="w-5 shrink-0 text-center">{p.icon ?? (p.type === "database" ? "🗂" : p.type === "research" ? "🔬" : "📄")}</span><span className="truncate">{p.title || "Untitled"}</span></Link>45              <div className="flex items-center gap-2 text-[12px] text-fg-muted md:contents">46                <span><Badge>{p.section}</Badge></span>47                <span className="flex items-center gap-1 text-[12px] text-fg-muted" title={fmtDate(p.deletedAt, { dateStyle: "medium", timeStyle: "short" })}><Clock className="size-3 md:hidden" />{timeAgo(p.deletedAt)}<span className="text-fg-faint"> · {daysLeft(p.deletedAt)} d left</span></span>48                <span className="text-[12px] tabular-nums text-fg-muted md:text-right">{p.wordCount} w</span>49              </div>50              <div className="flex items-center gap-1 md:justify-end"><Button size="sm" variant="ghost" onClick={() => restore(p)}><RotateCcw /> Restore</Button><Button size="sm" variant="ghost" className="text-danger hover:text-danger" onClick={() => setConfirm({ kind: "one", page: p })}><Trash2 /> Delete</Button></div>51            </div>52          ))}53        </div>54      )}55      <Dialog open={!!confirm} onOpenChange={(o) => !o && !busy && setConfirm(null)}>56        <DialogContent title={confirm?.kind === "all" ? "Empty the trash?" : "Delete forever?"} description={confirm?.kind === "all" ? `All ${items.length} page${items.length === 1 ? "" : "s"} in the trash, including their versions and attachments, will be permanently deleted.` : confirm ? `"${confirm.page.title || "Untitled"}" and its sub-pages will be permanently deleted. This cannot be undone.` : ""}>57          <div className="flex items-center gap-2 rounded-md bg-danger/5 px-3 py-2 text-[12px] text-danger"><AlertTriangle className="size-4 shrink-0" /> This action is irreversible.</div>58          <div className="mt-4 flex justify-end gap-2"><Button variant="ghost" onClick={() => setConfirm(null)} disabled={busy}>Cancel</Button><Button variant="danger" onClick={doConfirm} disabled={busy} autoFocus>{busy ? <Spinner className="size-3.5 border-white/40 border-t-white" /> : <Trash2 />} {confirm?.kind === "all" ? "Empty trash" : "Delete forever"}</Button></div>59        </DialogContent>60      </Dialog>61    </div>62  );63}64
Files · main
Menu

Theme