Skip to content
File

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

typescript85 lines
1import { useEffect, useMemo } from "react";
2import { useNavigate, useParams } from "@tanstack/react-router";
3import { Plus, Loader2 } from "lucide-react";
4import { Button } from "@/client/components/ui/button";
5import { useCurrentWorkspace, useWorkspacePages, useWorkspaceRole } from "./use-workspace-view";
6import { useCreatePage } from "@/client/hooks/use-create-page";
7import { useDocumentTitle } from "@/client/hooks/use-document-title";
8import { useLastVisitedPageId } from "@/client/stores/workspace-navigation";
9import { useOnline } from "@/client/hooks/use-online";
10import { deriveSidebarBaseAffordance } from "@/client/lib/affordance/sidebar";
11import { isActionEnabled, isActionVisible } from "@/client/lib/affordance/action-state";
12 
13export function WorkspaceIndex() {
14 const currentWorkspace = useCurrentWorkspace();
15 const pages = useWorkspacePages();
16 const workspaceRole = useWorkspaceRole();
17 const online = useOnline();
18 const { createPage, isCreating } = useCreatePage();
19 const navigate = useNavigate();
20 const { workspaceSlug } = useParams({ strict: false }) as { workspaceSlug: string };
21 useDocumentTitle(currentWorkspace?.name);
22 
23 const activePages = useMemo(() => pages.filter((p) => !p.archived_at), [pages]);
24 const hasPages = activePages.length > 0;
25 
26 // Role-aware create affordance — mirrors the sidebar's own gating so guests
27 // and non-members see a read-only empty state instead of a CTA the worker
28 // will reject.
29 const { createPage: createPageAffordance } = deriveSidebarBaseAffordance({
30 workspaceRole: workspaceRole ?? "none",
31 online,
32 });
33 const canCreate = isActionVisible(createPageAffordance);
34 
35 const lastVisitedPageId = useLastVisitedPageId(currentWorkspace?.id ?? null);
36 
37 useEffect(() => {
38 if (!currentWorkspace || !lastVisitedPageId) return;
39 const stillAvailable = activePages.some((p) => p.id === lastVisitedPageId);
40 if (!stillAvailable) return;
41 navigate({
42 to: "/$workspaceSlug/$pageId",
43 params: { workspaceSlug, pageId: lastVisitedPageId },
44 replace: true,
45 });
46 }, [currentWorkspace, lastVisitedPageId, activePages, navigate, workspaceSlug]);
47 
48 return (
49 <div className="flex h-full items-center justify-center px-6">
50 <div className="animate-slide-up max-w-md text-center">
51 {hasPages ? (
52 <>
53 <h2 className="font-display text-4xl font-extrabold tracking-tight text-zinc-100">
54 {currentWorkspace?.name ?? "Workspace"}
55 </h2>
56 <p className="mt-3 text-sm text-zinc-400">Pick a page. Or don't. We're not your boss.</p>
57 </>
58 ) : canCreate ? (
59 <>
60 <h2 className="font-display text-4xl font-extrabold tracking-tight text-zinc-100">Blank slate.</h2>
61 <p className="mt-3 text-sm text-zinc-400">Your first page is one click away.</p>
62 <Button
63 variant="primary"
64 size="sm"
65 className="mt-6"
66 onClick={() => createPage()}
67 disabled={isCreating || !isActionEnabled(createPageAffordance)}
68 icon={isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
69 >
70 {isCreating ? "Creating..." : "Create first page"}
71 </Button>
72 </>
73 ) : (
74 <>
75 <h2 className="font-display text-4xl font-extrabold tracking-tight text-zinc-100">Nothing here yet.</h2>
76 <p className="mt-3 text-sm text-zinc-400">
77 When pages are shared with you, they'll show up in the sidebar.
78 </p>
79 </>
80 )}
81 </div>
82 </div>
83 );
84}