Skip to content
File

Blob: src/client/components/workspace/page-view.tsx

typescript329 lines
1import { useState, type ReactNode } from "react";
2import type YProvider from "y-partyserver/provider";
3import { useNavigate, useParams } from "@tanstack/react-router";
4import { PanelRightOpen } from "lucide-react";
5import { useCanonicalPageContext } from "@/client/components/workspace/use-canonical-page-context";
6import { useAuthStore } from "@/client/stores/auth-store";
7import { deriveWorkspacePageAffordance, type WorkspacePageAffordance } from "@/client/lib/affordance/workspace-page";
8import { isActionEnabled, isActionVisible } from "@/client/lib/affordance/action-state";
9import type { ActivePageSnapshot } from "@/client/lib/active-page-model";
10import { DocumentPage } from "@/client/components/editor/document-page";
11import { CanvasPage } from "@/client/components/canvas/canvas-page";
12import { PageBreadcrumbs } from "@/client/components/ui/page-breadcrumbs";
13import { PageByline } from "@/client/components/ui/page-byline";
14import { PageCover } from "@/client/components/ui/page-cover";
15import { PageErrorState } from "@/client/components/ui/page-error-state";
16import { PageLoadingSkeleton } from "@/client/components/ui/page-loading-skeleton";
17import { type CanvasStageLayout } from "@/client/components/ui/page-layout";
18import { PageChrome, PageEmojiIcon } from "@/client/components/ui/page-chrome";
19import { AvatarStack } from "@/client/components/presence/avatar-stack";
20import { SyncStatusDot } from "@/client/components/presence/sync-status";
21import { IconPicker } from "@/client/components/icon-picker";
22import { CoverPicker } from "@/client/components/cover-picker";
23import { ShareDialog } from "@/client/components/workspace/share-dialog";
24import { SummarizeSheet } from "@/client/components/workspace/summarize-sheet";
25import { useSyncStatus, type SyncStatus } from "@/client/hooks/use-sync";
26import { useOnline } from "@/client/hooks/use-online";
27import { DEFAULT_PAGE_TITLE } from "@/shared/constants";
28import { useDocumentTitle } from "@/client/hooks/use-document-title";
29import { CanonicalActivePageBoundary } from "@/client/components/active-page/canonical";
30import {
31 useActivePageActions,
32 useActivePageState,
33 useActivePageSync,
34} from "@/client/components/active-page/use-active-page";
35import { useCanonicalPageActions } from "@/client/components/workspace/use-canonical-page-actions";
36import { useWorkspaceLayoutMode } from "@/client/components/workspace/layout-mode";
37import type { Page, PageAncestor, Workspace } from "@/shared/types";
38 
39export function PageView() {
40 const { pageId } = useParams({ strict: false }) as { pageId: string };
41 return (
42 <CanonicalActivePageBoundary key={pageId}>
43 <PageViewContent />
44 </CanonicalActivePageBoundary>
45 );
46}
47 
48function PageViewContent() {
49 const params = useParams({ strict: false }) as {
50 workspaceSlug: string;
51 pageId: string;
52 };
53 const navigate = useNavigate();
54 const activePageState = useActivePageState();
55 const { syncProvider, setSyncProvider } = useActivePageSync();
56 const { patchPage } = useActivePageActions();
57 const {
58 workspaceId: effectiveWorkspaceId,
59 workspace,
60 currentPageMeta,
61 pages,
62 members,
63 accessMode,
64 workspaceRole,
65 } = useCanonicalPageContext();
66 const currentUser = useAuthStore((s) => s.user);
67 const online = useOnline();
68 const isSharedMode = accessMode === "shared";
69 const [summarizeOpen, setSummarizeOpen] = useState(false);
70 const { status } = useSyncStatus(syncProvider, online);
71 const { expanded } = useWorkspaceLayoutMode();
72 const canvasLayout: CanvasStageLayout = expanded ? "stage" : "centered";
73 
74 const page = activePageState.kind === "ready" ? activePageState.snapshot : null;
75 const ancestors = activePageState.kind === "ready" ? activePageState.ancestors : [];
76 const creator =
77 currentPageMeta && currentPageMeta.created_by
78 ? (members.find((member) => member.user_id === currentPageMeta.created_by)?.user ?? null)
79 : null;
80 const docFooterLeading =
81 creator && currentPageMeta ? <PageByline creator={creator} createdAt={currentPageMeta.created_at} /> : undefined;
82 
83 useDocumentTitle(page?.title || DEFAULT_PAGE_TITLE);
84 
85 const { handleTitleChange, handleIconChange, handleCoverChange } = useCanonicalPageActions({
86 workspace,
87 page,
88 syncProvider,
89 patchPage,
90 });
91 
92 if (activePageState.kind === "loading") {
93 return (
94 <PageLoadingSkeleton
95 canvasLayout={canvasLayout}
96 kind={currentPageMeta?.kind ?? "unknown"}
97 documentLayout={expanded ? "rail" : "inline"}
98 />
99 );
100 }
101 
102 if (activePageState.kind === "unavailable" || !page || !effectiveWorkspaceId) {
103 return (
104 <PageErrorState
105 message={activePageState.kind === "unavailable" ? activePageState.message : "Page not found."}
106 className="h-full"
107 action={{
108 label: "Go back",
109 onClick: () => {
110 if (window.history.length > 1) {
111 window.history.back();
112 } else {
113 navigate({
114 to: "/$workspaceSlug",
115 params: { workspaceSlug: params.workspaceSlug },
116 });
117 }
118 },
119 }}
120 />
121 );
122 }
123 
124 const pageAffordance = deriveWorkspacePageAffordance({
125 accessMode,
126 workspaceRole: workspaceRole ?? "none",
127 pageKind: page.kind,
128 pageAccess: activePageState.access.mode,
129 ownsPage: currentUser?.id === currentPageMeta?.created_by,
130 workspaceId: effectiveWorkspaceId,
131 online,
132 });
133 
134 const headerActions = (
135 <>
136 {pageAffordance.kind === "doc" && pageAffordance.editor.canSummarizePage && (
137 <button
138 type="button"
139 aria-label="Summarize page"
140 title="Summarize page"
141 aria-expanded={summarizeOpen}
142 className="flex items-center gap-1.5 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-800 hover:text-zinc-100 focus-visible:border-accent-500/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/30"
143 onClick={() => setSummarizeOpen((value) => !value)}
144 >
145 <PanelRightOpen size={14} />
146 <span className="hidden md:inline">Summarize</span>
147 </button>
148 )}
149 {!isSharedMode && workspace && isActionVisible(pageAffordance.shareDialog) && (
150 <ShareDialog
151 pageId={page.id}
152 workspaceId={effectiveWorkspaceId}
153 pageKind={page.kind}
154 disabled={!isActionEnabled(pageAffordance.shareDialog)}
155 title={pageAffordance.shareDialog.kind === "disabled" ? pageAffordance.shareDialog.reason : undefined}
156 />
157 )}
158 </>
159 );
160 const workspaceChrome = (
161 <WorkspacePageChrome
162 page={page}
163 currentPageMeta={currentPageMeta}
164 workspace={workspace}
165 pages={pages}
166 ancestors={ancestors}
167 pageAffordance={pageAffordance}
168 workspaceSlug={params.workspaceSlug}
169 syncProvider={syncProvider}
170 status={status}
171 onIconChange={handleIconChange}
172 onCoverChange={handleCoverChange}
173 headerActions={headerActions}
174 />
175 );
176 
177 return (
178 <>
179 {pageAffordance.kind === "doc" ? (
180 <DocumentPage
181 key={page.id}
182 pageId={page.id}
183 initialTitle={page.title}
184 onTitleChange={handleTitleChange}
185 onProvider={setSyncProvider}
186 workspaceId={effectiveWorkspaceId}
187 affordance={pageAffordance.editor}
188 outlineMode={expanded ? "rail" : "inline"}
189 chrome={workspaceChrome}
190 docFooterLeading={docFooterLeading}
191 />
192 ) : (
193 <CanvasPage
194 pageId={page.id}
195 initialTitle={page.title}
196 onTitleChange={handleTitleChange}
197 onProvider={setSyncProvider}
198 workspaceId={effectiveWorkspaceId}
199 affordance={pageAffordance.canvas}
200 layout={canvasLayout}
201 chrome={workspaceChrome}
202 />
203 )}
204
205 {pageAffordance.kind === "doc" &&
206 (pageAffordance.editor.canSummarizePage || pageAffordance.editor.canAskPage) && (
207 <SummarizeSheet
208 open={summarizeOpen}
209 onClose={() => setSummarizeOpen(false)}
210 workspaceId={effectiveWorkspaceId}
211 pageId={page.id}
212 canSummarize={pageAffordance.editor.canSummarizePage}
213 canAsk={pageAffordance.editor.canAskPage}
214 />
215 )}
216 </>
217 );
218}
219 
220interface WorkspacePageChromeProps {
221 page: ActivePageSnapshot;
222 currentPageMeta: Page | null;
223 workspace: Workspace | null;
224 pages: Page[];
225 ancestors: PageAncestor[];
226 pageAffordance: WorkspacePageAffordance;
227 workspaceSlug: string;
228 syncProvider: YProvider | null;
229 status: SyncStatus;
230 onIconChange: (icon: string | null) => void;
231 onCoverChange: (cover: string | null) => void;
232 headerActions?: ReactNode;
233}
234 
235function WorkspacePageChrome({
236 page,
237 currentPageMeta,
238 workspace,
239 pages,
240 ancestors,
241 pageAffordance,
242 workspaceSlug,
243 syncProvider,
244 status,
245 onIconChange,
246 onCoverChange,
247 headerActions,
248}: WorkspacePageChromeProps) {
249 const canEditMetadata = !!workspace && isActionVisible(pageAffordance.editPageMetadata);
250 const editDisabled = !isActionEnabled(pageAffordance.editPageMetadata);
251 const editDisabledTitle =
252 pageAffordance.editPageMetadata.kind === "disabled" ? pageAffordance.editPageMetadata.reason : undefined;
253 
254 const breadcrumb =
255 pageAffordance.breadcrumbMode === "restricted" ? (
256 <PageBreadcrumbs
257 mode="shared-in-workspace"
258 currentTitle={page.title}
259 currentIcon={page.icon}
260 workspaceSlug={workspaceSlug}
261 workspaceName={workspace?.name}
262 ancestors={ancestors}
263 />
264 ) : (
265 <PageBreadcrumbs
266 mode="workspace"
267 currentTitle={page.title}
268 currentIcon={page.icon}
269 currentParentId={currentPageMeta?.parent_id ?? null}
270 workspaceSlug={workspaceSlug}
271 workspaceName={workspace?.name}
272 pages={pages}
273 />
274 );
275 
276 return (
277 <PageChrome
278 cover={page.coverUrl ? <PageCover coverUrl={page.coverUrl} /> : null}
279 coverOverlay={
280 canEditMetadata && workspace && page.coverUrl ? (
281 <CoverPicker
282 currentCover={page.coverUrl}
283 onSelect={onCoverChange}
284 workspaceId={workspace.id}
285 pageId={page.id}
286 disabled={editDisabled}
287 title={editDisabledTitle}
288 />
289 ) : null
290 }
291 breadcrumb={breadcrumb}
292 breadcrumbActions={
293 <>
294 {headerActions}
295 <AvatarStack
296 awareness={syncProvider?.awareness ?? null}
297 localClientId={syncProvider?.awareness.clientID ?? null}
298 />
299 <SyncStatusDot status={status} />
300 </>
301 }
302 icon={
303 canEditMetadata && workspace ? (
304 <>
305 <IconPicker
306 currentIcon={page.icon}
307 onSelect={onIconChange}
308 disabled={editDisabled}
309 title={editDisabledTitle}
310 />
311 {!page.coverUrl ? (
312 <CoverPicker
313 currentCover={null}
314 onSelect={onCoverChange}
315 workspaceId={workspace.id}
316 pageId={page.id}
317 disabled={editDisabled}
318 title={editDisabledTitle}
319 />
320 ) : null}
321 </>
322 ) : page.icon ? (
323 <PageEmojiIcon emoji={page.icon} />
324 ) : null
325 }
326 />
327 );
328}