File
Blob: src/client/components/workspace/page-view.tsx
| 1 | import { useState, type ReactNode } from "react"; |
| 2 | import type YProvider from "y-partyserver/provider"; |
| 3 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 4 | import { PanelRightOpen } from "lucide-react"; |
| 5 | import { useCanonicalPageContext } from "@/client/components/workspace/use-canonical-page-context"; |
| 6 | import { useAuthStore } from "@/client/stores/auth-store"; |
| 7 | import { deriveWorkspacePageAffordance, type WorkspacePageAffordance } from "@/client/lib/affordance/workspace-page"; |
| 8 | import { isActionEnabled, isActionVisible } from "@/client/lib/affordance/action-state"; |
| 9 | import type { ActivePageSnapshot } from "@/client/lib/active-page-model"; |
| 10 | import { DocumentPage } from "@/client/components/editor/document-page"; |
| 11 | import { CanvasPage } from "@/client/components/canvas/canvas-page"; |
| 12 | import { PageBreadcrumbs } from "@/client/components/ui/page-breadcrumbs"; |
| 13 | import { PageByline } from "@/client/components/ui/page-byline"; |
| 14 | import { PageCover } from "@/client/components/ui/page-cover"; |
| 15 | import { PageErrorState } from "@/client/components/ui/page-error-state"; |
| 16 | import { PageLoadingSkeleton } from "@/client/components/ui/page-loading-skeleton"; |
| 17 | import { type CanvasStageLayout } from "@/client/components/ui/page-layout"; |
| 18 | import { PageChrome, PageEmojiIcon } from "@/client/components/ui/page-chrome"; |
| 19 | import { AvatarStack } from "@/client/components/presence/avatar-stack"; |
| 20 | import { SyncStatusDot } from "@/client/components/presence/sync-status"; |
| 21 | import { IconPicker } from "@/client/components/icon-picker"; |
| 22 | import { CoverPicker } from "@/client/components/cover-picker"; |
| 23 | import { ShareDialog } from "@/client/components/workspace/share-dialog"; |
| 24 | import { SummarizeSheet } from "@/client/components/workspace/summarize-sheet"; |
| 25 | import { useSyncStatus, type SyncStatus } from "@/client/hooks/use-sync"; |
| 26 | import { useOnline } from "@/client/hooks/use-online"; |
| 27 | import { DEFAULT_PAGE_TITLE } from "@/shared/constants"; |
| 28 | import { useDocumentTitle } from "@/client/hooks/use-document-title"; |
| 29 | import { CanonicalActivePageBoundary } from "@/client/components/active-page/canonical"; |
| 30 | import { |
| 31 | useActivePageActions, |
| 32 | useActivePageState, |
| 33 | useActivePageSync, |
| 34 | } from "@/client/components/active-page/use-active-page"; |
| 35 | import { useCanonicalPageActions } from "@/client/components/workspace/use-canonical-page-actions"; |
| 36 | import { useWorkspaceLayoutMode } from "@/client/components/workspace/layout-mode"; |
| 37 | import type { Page, PageAncestor, Workspace } from "@/shared/types"; |
| 38 | |
| 39 | export function PageView() { |
| 40 | const { pageId } = useParams({ strict: false }) as { pageId: string }; |
| 41 | return ( |
| 42 | <CanonicalActivePageBoundary key={pageId}> |
| 43 | <PageViewContent /> |
| 44 | </CanonicalActivePageBoundary> |
| 45 | ); |
| 46 | } |
| 47 | |
| 48 | function 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 | |
| 220 | interface 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 | |
| 235 | function 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 | } |