Skip to content
File

Blob: src/client/components/workspace/archive.tsx

typescript164 lines
1import { useEffect, useState } from "react";
2import { Link, useNavigate, useParams } from "@tanstack/react-router";
3import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4import { ArrowLeft, Archive, Loader2, RefreshCw, RotateCcw } from "lucide-react";
5import { Button } from "@/client/components/ui/button";
6import { useCurrentWorkspace, useWorkspaceRole } from "./use-workspace-view";
7import { replicaCommands } from "@/client/stores/db/workspace-replica";
8import { useDocumentTitle } from "@/client/hooks/use-document-title";
9import { api, toApiError } from "@/client/lib/api";
10import { archivedPagesQueryKey, archivedPagesQueryOptions } from "@/client/lib/queries/archived-pages";
11import { formatAbsoluteDate, formatRelativeDate } from "@/client/lib/format-date";
12import { DEFAULT_PAGE_TITLE } from "@/shared/constants";
13import type { ArchivedPage, Workspace } from "@/shared/types";
14 
15export function WorkspaceArchive() {
16 const params = useParams({ strict: false }) as { workspaceSlug?: string };
17 const navigate = useNavigate();
18 const currentWorkspace = useCurrentWorkspace();
19 const role = useWorkspaceRole();
20 useDocumentTitle(currentWorkspace ? `Archive — ${currentWorkspace.name}` : "Archive");
21 
22 // The layout's member-only route gating already keeps non-members off this
23 // route, but guests are members and reach it like Settings does. Guests
24 // cannot manage the archive, so funnel them back to the workspace. Key on
25 // the concrete "guest" value — role is null while the replica resolves, and
26 // redirecting on null would bounce a legitimate member mid-load.
27 const isGuest = role === "guest";
28 useEffect(() => {
29 if (isGuest && params.workspaceSlug) {
30 navigate({ to: "/$workspaceSlug", params: { workspaceSlug: params.workspaceSlug }, replace: true });
31 }
32 }, [isGuest, params.workspaceSlug, navigate]);
33 
34 if (!currentWorkspace || isGuest) return null;
35 
36 return (
37 <div className="mx-auto max-w-2xl px-8 py-10">
38 <Link
39 to="/$workspaceSlug"
40 params={{ workspaceSlug: params.workspaceSlug ?? "" }}
41 className="mb-6 flex items-center gap-2 text-sm text-zinc-400 transition-colors hover:text-zinc-200"
42 >
43 <ArrowLeft className="h-4 w-4" />
44 Back to {currentWorkspace.name}
45 </Link>
46
47 <div className="mb-8 flex items-center gap-3">
48 <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-zinc-800/50">
49 <Archive className="h-5 w-5 text-zinc-400" />
50 </div>
51 <h1 className="text-xl font-semibold text-zinc-100">Archive</h1>
52 </div>
53
54 <ArchiveList workspace={currentWorkspace} />
55 </div>
56 );
57}
58 
59function ArchiveList({ workspace }: { workspace: Workspace }) {
60 const queryClient = useQueryClient();
61 const archivedQuery = useQuery(archivedPagesQueryOptions(workspace.id));
62 const [restoreError, setRestoreError] = useState<string | null>(null);
63 const archivedPages = archivedQuery.data ?? [];
64 
65 const restoreMutation = useMutation({
66 mutationFn: (pageId: string) => api.pages.restore(workspace.id, pageId),
67 onSuccess: async (restoredPages, pageId) => {
68 await replicaCommands.upsertPages(workspace.id, restoredPages);
69 queryClient.setQueryData<ArchivedPage[]>(archivedPagesQueryKey(workspace.id), (prev) =>
70 prev ? prev.filter((page) => page.id !== pageId) : prev,
71 );
72 setRestoreError(null);
73 },
74 onError: (err) => {
75 setRestoreError(toApiError(err).message);
76 },
77 });
78 
79 return (
80 <section className="mb-8">
81 <div className="mb-4 flex items-center justify-between gap-3">
82 <p className="text-sm text-zinc-400">
83 {archivedQuery.isSuccess ? `${archivedPages.length} ${archivedPages.length === 1 ? "page" : "pages"}` : ""}
84 </p>
85 <Button
86 variant="ghost"
87 size="sm"
88 iconOnly
89 onClick={() => void archivedQuery.refetch()}
90 disabled={archivedQuery.isFetching}
91 aria-label="Refresh"
92 title="Refresh"
93 icon={
94 archivedQuery.isFetching ? (
95 <Loader2 className="h-3.5 w-3.5 animate-spin" />
96 ) : (
97 <RefreshCw className="h-3.5 w-3.5" />
98 )
99 }
100 />
101 </div>
102 <div className="overflow-hidden rounded-lg border border-zinc-800">
103 {restoreError && <p className="border-b border-zinc-800 px-4 py-3 text-sm text-red-400">{restoreError}</p>}
104 {archivedQuery.isLoading ? (
105 <div className="flex items-center gap-2 px-4 py-4 text-sm text-zinc-400">
106 <Loader2 className="h-4 w-4 animate-spin" />
107 Loading archived pages
108 </div>
109 ) : archivedQuery.isError ? (
110 <div className="px-4 py-4 text-sm text-red-400">{toApiError(archivedQuery.error).message}</div>
111 ) : archivedPages.length === 0 ? (
112 <div className="px-4 py-4 text-sm text-zinc-400">No archived pages.</div>
113 ) : (
114 <div className="divide-y divide-zinc-800">
115 {archivedPages.map((page) => (
116 <ArchivePageRow
117 key={page.id}
118 page={page}
119 restoring={restoreMutation.isPending && restoreMutation.variables === page.id}
120 onRestore={() => restoreMutation.mutate(page.id)}
121 />
122 ))}
123 </div>
124 )}
125 </div>
126 </section>
127 );
128}
129 
130function ArchivePageRow({
131 page,
132 restoring,
133 onRestore,
134}: {
135 page: ArchivedPage;
136 restoring: boolean;
137 onRestore: () => void;
138}) {
139 const descendantText =
140 page.archived_descendant_count === 1 ? "1 subpage" : `${page.archived_descendant_count} subpages`;
141 const archivedAt = page.archived_at;
142 const archivedLabel = archivedAt ? `Archived ${formatRelativeDate(archivedAt)}` : "Archived";
143 
144 return (
145 <div className="flex items-center justify-between gap-3 px-4 py-3">
146 <div className="min-w-0">
147 <p className="truncate text-sm font-medium text-zinc-200">{page.title || DEFAULT_PAGE_TITLE}</p>
148 <p className="truncate text-xs text-zinc-400" title={archivedAt ? formatAbsoluteDate(archivedAt) : undefined}>
149 {descendantText} · {archivedLabel}
150 </p>
151 </div>
152 <Button
153 variant="secondary"
154 size="sm"
155 onClick={onRestore}
156 disabled={restoring}
157 icon={restoring ? <Loader2 className="h-4 w-4 animate-spin" /> : <RotateCcw className="h-4 w-4" />}
158 >
159 {restoring ? "Restoring..." : "Restore"}
160 </Button>
161 </div>
162 );
163}