File
Blob: src/client/components/workspace/index.tsx
| 1 | import { useEffect, useMemo } from "react"; |
| 2 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 3 | import { Plus, Loader2 } from "lucide-react"; |
| 4 | import { Button } from "@/client/components/ui/button"; |
| 5 | import { useCurrentWorkspace, useWorkspacePages, useWorkspaceRole } from "./use-workspace-view"; |
| 6 | import { useCreatePage } from "@/client/hooks/use-create-page"; |
| 7 | import { useDocumentTitle } from "@/client/hooks/use-document-title"; |
| 8 | import { useLastVisitedPageId } from "@/client/stores/workspace-navigation"; |
| 9 | import { useOnline } from "@/client/hooks/use-online"; |
| 10 | import { deriveSidebarBaseAffordance } from "@/client/lib/affordance/sidebar"; |
| 11 | import { isActionEnabled, isActionVisible } from "@/client/lib/affordance/action-state"; |
| 12 | |
| 13 | export 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 | } |