File
Blob: src/client/components/sidebar/page-tree-item.tsx
| 1 | import { lazy, Suspense, useCallback, useRef, useState } from "react"; |
| 2 | import { Link, useNavigate, useParams } from "@tanstack/react-router"; |
| 3 | import { useQueryClient } from "@tanstack/react-query"; |
| 4 | |
| 5 | import { |
| 6 | ArrowDown, |
| 7 | ArrowUp, |
| 8 | ChevronRight, |
| 9 | FileText, |
| 10 | ListIndentDecrease, |
| 11 | ListIndentIncrease, |
| 12 | MoreHorizontal, |
| 13 | Move, |
| 14 | PenTool, |
| 15 | Plus, |
| 16 | Trash2, |
| 17 | } from "lucide-react"; |
| 18 | import type { Page } from "@/shared/types"; |
| 19 | import { DEFAULT_PAGE_TITLE } from "@/shared/constants"; |
| 20 | import { api, toApiError } from "@/client/lib/api"; |
| 21 | import { useCurrentWorkspace } from "@/client/components/workspace/use-workspace-view"; |
| 22 | import { replicaCommands } from "@/client/stores/db/workspace-replica"; |
| 23 | import { useAuthStore } from "@/client/stores/auth-store"; |
| 24 | import { |
| 25 | getActiveDescendantIds, |
| 26 | getArchivePageConfirmMessage, |
| 27 | shouldNavigateAwayAfterArchive, |
| 28 | } from "@/client/lib/page-archive"; |
| 29 | import { useCreatePage } from "@/client/hooks/use-create-page"; |
| 30 | import { EmojiIcon } from "@/client/components/ui/emoji-icon"; |
| 31 | import { confirm } from "@/client/components/confirm-store"; |
| 32 | import { DropdownPortal } from "@/client/components/ui/dropdown-portal"; |
| 33 | import { deriveSidebarRowAffordance } from "@/client/lib/affordance/sidebar"; |
| 34 | import { isActionEnabled, isActionVisible } from "@/client/lib/affordance/action-state"; |
| 35 | import { |
| 36 | resolveIndent, |
| 37 | resolveMoveDown, |
| 38 | resolveMoveUp, |
| 39 | resolveOutdent, |
| 40 | type MoveResolution, |
| 41 | type MoveResult, |
| 42 | type PageTreeIndex, |
| 43 | } from "@/client/lib/page-tree-model"; |
| 44 | import { toast } from "@/client/components/toast-store"; |
| 45 | import { archivedPagesQueryKey } from "@/client/lib/queries/archived-pages"; |
| 46 | const SidebarMoveDialog = lazy(() => |
| 47 | import("./sidebar-move-dialog").then((mod) => ({ default: mod.SidebarMoveDialog })), |
| 48 | ); |
| 49 | import { getSidebarTreeChevronLeft, getSidebarTreeContentPaddingLeft } from "./tree-metrics"; |
| 50 | |
| 51 | interface PageTreeItemProps { |
| 52 | page: Page; |
| 53 | depth: number; |
| 54 | index: PageTreeIndex; |
| 55 | allPages: Page[]; |
| 56 | alwaysShowActions: boolean; |
| 57 | activeAncestorIds: Set<string>; |
| 58 | menuZIndex?: number; |
| 59 | workspaceRole: "owner" | "admin" | "member" | "guest" | "none"; |
| 60 | online: boolean; |
| 61 | } |
| 62 | |
| 63 | const EMPTY_CHILDREN: readonly never[] = []; |
| 64 | |
| 65 | const MENU_ITEM_CLASS = |
| 66 | "group flex min-h-8 w-full items-center gap-2 rounded px-2 py-1.5 text-[13px] text-left transition-[background-color,color] focus-visible:outline-none disabled:opacity-50 disabled:hover:bg-transparent"; |
| 67 | const MENU_NEUTRAL_ITEM_CLASS = |
| 68 | "text-zinc-300 hover:bg-zinc-700 hover:text-zinc-100 focus-visible:bg-zinc-700 focus-visible:text-zinc-100 disabled:hover:text-zinc-300"; |
| 69 | const MENU_DANGER_ITEM_CLASS = |
| 70 | "text-red-400 hover:bg-red-500/10 hover:text-red-300 focus-visible:bg-red-500/10 focus-visible:text-red-300 disabled:hover:text-red-400"; |
| 71 | const MENU_ICON_CLASS = |
| 72 | "flex w-4 shrink-0 items-center justify-center text-zinc-400 transition-colors group-hover:text-current group-focus-visible:text-current"; |
| 73 | const MENU_DANGER_ICON_CLASS = MENU_ICON_CLASS; |
| 74 | const MENU_LABEL_CLASS = "flex-1"; |
| 75 | const MENU_SEPARATOR_CLASS = "my-1 h-px bg-zinc-800"; |
| 76 | |
| 77 | export function PageTreeItem({ |
| 78 | page, |
| 79 | depth, |
| 80 | index, |
| 81 | allPages, |
| 82 | alwaysShowActions, |
| 83 | activeAncestorIds, |
| 84 | menuZIndex, |
| 85 | workspaceRole, |
| 86 | online, |
| 87 | }: PageTreeItemProps) { |
| 88 | const childPages = index.childrenByParent.get(page.id) ?? EMPTY_CHILDREN; |
| 89 | const params = useParams({ strict: false }) as { |
| 90 | workspaceSlug?: string; |
| 91 | pageId?: string; |
| 92 | }; |
| 93 | const navigate = useNavigate(); |
| 94 | const queryClient = useQueryClient(); |
| 95 | const currentWorkspace = useCurrentWorkspace(); |
| 96 | const currentUser = useAuthStore((s) => s.user); |
| 97 | const rowAffordance = deriveSidebarRowAffordance({ |
| 98 | workspaceRole, |
| 99 | ownsPage: currentUser?.id === page.created_by, |
| 100 | online, |
| 101 | }); |
| 102 | const { createPage, isCreating: creating } = useCreatePage(); |
| 103 | const isActive = params.pageId === page.id; |
| 104 | const shouldExpand = activeAncestorIds.has(page.id) || isActive; |
| 105 | const [userExpanded, setUserExpanded] = useState(shouldExpand); |
| 106 | const [prevShouldExpand, setPrevShouldExpand] = useState(shouldExpand); |
| 107 | // Auto-expand when becoming active or hosting an active descendant; user |
| 108 | // toggles still win until shouldExpand transitions again. Pattern from |
| 109 | // https://react.dev/reference/react/useState#storing-information-from-previous-renders |
| 110 | if (shouldExpand !== prevShouldExpand) { |
| 111 | setPrevShouldExpand(shouldExpand); |
| 112 | if (shouldExpand) setUserExpanded(true); |
| 113 | } |
| 114 | const isExpanded = userExpanded; |
| 115 | const [menuOpen, setMenuOpen] = useState(false); |
| 116 | const [moveDialogOpen, setMoveDialogOpen] = useState(false); |
| 117 | const [archiving, setArchiving] = useState(false); |
| 118 | const [moving, setMoving] = useState(false); |
| 119 | const menuRef = useRef<HTMLDivElement>(null); |
| 120 | const hasChildren = childPages.length > 0; |
| 121 | const moreVisibility = |
| 122 | alwaysShowActions || menuOpen || isActive ? "opacity-100" : "opacity-40 group-hover:opacity-100"; |
| 123 | const addVisibility = alwaysShowActions || menuOpen || isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100"; |
| 124 | |
| 125 | const toggleExpand = useCallback((e: React.MouseEvent) => { |
| 126 | e.preventDefault(); |
| 127 | e.stopPropagation(); |
| 128 | setUserExpanded((v) => !v); |
| 129 | }, []); |
| 130 | |
| 131 | const executeMove = useCallback( |
| 132 | async (resolution: MoveResolution) => { |
| 133 | if (!currentWorkspace || moving) return; |
| 134 | setMoving(true); |
| 135 | setMenuOpen(false); |
| 136 | const previousParentId = page.parent_id; |
| 137 | const previousPosition = page.position; |
| 138 | await replicaCommands.patchPage(currentWorkspace.id, page.id, { |
| 139 | parent_id: resolution.proposal.parentId, |
| 140 | position: resolution.proposal.position, |
| 141 | }); |
| 142 | try { |
| 143 | const updated = await api.pages.update(currentWorkspace.id, page.id, { |
| 144 | parent_id: resolution.proposal.parentId, |
| 145 | position: resolution.proposal.position, |
| 146 | }); |
| 147 | await replicaCommands.upsertPage(currentWorkspace.id, updated); |
| 148 | } catch (err) { |
| 149 | await replicaCommands.patchPage(currentWorkspace.id, page.id, { |
| 150 | parent_id: previousParentId, |
| 151 | position: previousPosition, |
| 152 | }); |
| 153 | toast.error(toApiError(err).message || "Failed to move page"); |
| 154 | throw err; |
| 155 | } finally { |
| 156 | setMoving(false); |
| 157 | } |
| 158 | }, |
| 159 | [currentWorkspace, moving, page.id, page.parent_id, page.position], |
| 160 | ); |
| 161 | |
| 162 | const handleArchive = useCallback( |
| 163 | async (e: React.MouseEvent) => { |
| 164 | e.preventDefault(); |
| 165 | e.stopPropagation(); |
| 166 | if (!currentWorkspace || archiving) return; |
| 167 | setMenuOpen(false); |
| 168 | const activeDescendantIds = getActiveDescendantIds(index, page.id); |
| 169 | const ok = await confirm({ |
| 170 | title: "Archive page", |
| 171 | message: getArchivePageConfirmMessage(page.title, activeDescendantIds.length), |
| 172 | }); |
| 173 | if (!ok) return; |
| 174 | setArchiving(true); |
| 175 | try { |
| 176 | const archived = await api.pages.delete(currentWorkspace.id, page.id); |
| 177 | await replicaCommands.removePages(currentWorkspace.id, archived.archived_page_ids); |
| 178 | await queryClient.invalidateQueries({ queryKey: archivedPagesQueryKey(currentWorkspace.id) }); |
| 179 | if (shouldNavigateAwayAfterArchive(params.pageId, archived.archived_page_ids)) { |
| 180 | navigate({ to: "/$workspaceSlug", params: { workspaceSlug: params.workspaceSlug || currentWorkspace.slug } }); |
| 181 | } |
| 182 | } catch (err) { |
| 183 | toast.error(toApiError(err).message || "Failed to archive page"); |
| 184 | } finally { |
| 185 | setArchiving(false); |
| 186 | setMenuOpen(false); |
| 187 | } |
| 188 | }, |
| 189 | [ |
| 190 | currentWorkspace, |
| 191 | archiving, |
| 192 | page.id, |
| 193 | page.title, |
| 194 | index, |
| 195 | params.pageId, |
| 196 | params.workspaceSlug, |
| 197 | navigate, |
| 198 | queryClient, |
| 199 | ], |
| 200 | ); |
| 201 | |
| 202 | const handleCreateSubpage = useCallback( |
| 203 | (e: React.MouseEvent) => { |
| 204 | e.preventDefault(); |
| 205 | e.stopPropagation(); |
| 206 | createPage({ parentId: page.id, onCreated: () => setUserExpanded(true) }); |
| 207 | }, |
| 208 | [createPage, page.id], |
| 209 | ); |
| 210 | |
| 211 | const openMoveDialog = useCallback( |
| 212 | (e: React.MouseEvent) => { |
| 213 | e.preventDefault(); |
| 214 | e.stopPropagation(); |
| 215 | if (!isActionEnabled(rowAffordance.movePage)) return; |
| 216 | setMenuOpen(false); |
| 217 | setMoveDialogOpen(true); |
| 218 | }, |
| 219 | [rowAffordance.movePage], |
| 220 | ); |
| 221 | |
| 222 | const runQuickMove = useCallback( |
| 223 | (e: React.MouseEvent, result: MoveResult) => { |
| 224 | e.preventDefault(); |
| 225 | e.stopPropagation(); |
| 226 | if (!isActionEnabled(rowAffordance.movePage) || !result.ok) return; |
| 227 | void executeMove(result); |
| 228 | }, |
| 229 | [executeMove, rowAffordance.movePage], |
| 230 | ); |
| 231 | |
| 232 | const rowPaddingLeft = getSidebarTreeContentPaddingLeft(depth); |
| 233 | const chevronLeft = getSidebarTreeChevronLeft(depth); |
| 234 | |
| 235 | const renderActionsMenu = () => { |
| 236 | const moveUp = resolveMoveUp(allPages, page, index); |
| 237 | const moveDown = resolveMoveDown(allPages, page, index); |
| 238 | const indent = resolveIndent(allPages, page, index); |
| 239 | const outdent = resolveOutdent(allPages, page, index); |
| 240 | const moveTitle = rowAffordance.movePage.kind === "disabled" ? rowAffordance.movePage.reason : undefined; |
| 241 | const moveDisabledReason = (result: MoveResult) => |
| 242 | rowAffordance.movePage.kind === "disabled" |
| 243 | ? rowAffordance.movePage.reason |
| 244 | : result.ok |
| 245 | ? undefined |
| 246 | : result.message; |
| 247 | return ( |
| 248 | <DropdownPortal |
| 249 | triggerRef={menuRef} |
| 250 | zIndex={menuZIndex} |
| 251 | width={208} |
| 252 | className="p-1 shadow-[0_8px_24px_rgba(0,0,0,0.45)]" |
| 253 | onClose={() => setMenuOpen(false)} |
| 254 | > |
| 255 | <div role="menu" aria-label="Page actions"> |
| 256 | {isActionVisible(rowAffordance.movePage) && ( |
| 257 | <> |
| 258 | <button |
| 259 | role="menuitem" |
| 260 | onMouseDown={(e) => e.preventDefault()} |
| 261 | onClick={openMoveDialog} |
| 262 | disabled={!isActionEnabled(rowAffordance.movePage)} |
| 263 | className={`${MENU_ITEM_CLASS} ${MENU_NEUTRAL_ITEM_CLASS}`} |
| 264 | title={moveTitle} |
| 265 | > |
| 266 | <span className={MENU_ICON_CLASS}> |
| 267 | <Move className="h-3.5 w-3.5" /> |
| 268 | </span> |
| 269 | <span className={MENU_LABEL_CLASS}>Move…</span> |
| 270 | </button> |
| 271 | |
| 272 | <div className={MENU_SEPARATOR_CLASS} role="separator" /> |
| 273 | |
| 274 | <button |
| 275 | role="menuitem" |
| 276 | onMouseDown={(e) => e.preventDefault()} |
| 277 | onClick={(e) => runQuickMove(e, moveUp)} |
| 278 | disabled={!isActionEnabled(rowAffordance.movePage) || !moveUp.ok} |
| 279 | className={`${MENU_ITEM_CLASS} ${MENU_NEUTRAL_ITEM_CLASS}`} |
| 280 | title={moveDisabledReason(moveUp)} |
| 281 | > |
| 282 | <span className={MENU_ICON_CLASS}> |
| 283 | <ArrowUp className="h-3.5 w-3.5" /> |
| 284 | </span> |
| 285 | <span className={MENU_LABEL_CLASS}>Move up</span> |
| 286 | </button> |
| 287 | <button |
| 288 | role="menuitem" |
| 289 | onMouseDown={(e) => e.preventDefault()} |
| 290 | onClick={(e) => runQuickMove(e, moveDown)} |
| 291 | disabled={!isActionEnabled(rowAffordance.movePage) || !moveDown.ok} |
| 292 | className={`${MENU_ITEM_CLASS} ${MENU_NEUTRAL_ITEM_CLASS}`} |
| 293 | title={moveDisabledReason(moveDown)} |
| 294 | > |
| 295 | <span className={MENU_ICON_CLASS}> |
| 296 | <ArrowDown className="h-3.5 w-3.5" /> |
| 297 | </span> |
| 298 | <span className={MENU_LABEL_CLASS}>Move down</span> |
| 299 | </button> |
| 300 | |
| 301 | <div className={MENU_SEPARATOR_CLASS} role="separator" /> |
| 302 | |
| 303 | <button |
| 304 | role="menuitem" |
| 305 | onMouseDown={(e) => e.preventDefault()} |
| 306 | onClick={(e) => runQuickMove(e, indent)} |
| 307 | disabled={!isActionEnabled(rowAffordance.movePage) || !indent.ok} |
| 308 | className={`${MENU_ITEM_CLASS} ${MENU_NEUTRAL_ITEM_CLASS}`} |
| 309 | title={moveDisabledReason(indent)} |
| 310 | > |
| 311 | <span className={MENU_ICON_CLASS}> |
| 312 | <ListIndentIncrease className="h-3.5 w-3.5" /> |
| 313 | </span> |
| 314 | <span className={MENU_LABEL_CLASS}>Indent</span> |
| 315 | </button> |
| 316 | <button |
| 317 | role="menuitem" |
| 318 | onMouseDown={(e) => e.preventDefault()} |
| 319 | onClick={(e) => runQuickMove(e, outdent)} |
| 320 | disabled={!isActionEnabled(rowAffordance.movePage) || !outdent.ok} |
| 321 | className={`${MENU_ITEM_CLASS} ${MENU_NEUTRAL_ITEM_CLASS}`} |
| 322 | title={moveDisabledReason(outdent)} |
| 323 | > |
| 324 | <span className={MENU_ICON_CLASS}> |
| 325 | <ListIndentDecrease className="h-3.5 w-3.5" /> |
| 326 | </span> |
| 327 | <span className={MENU_LABEL_CLASS}>Outdent</span> |
| 328 | </button> |
| 329 | </> |
| 330 | )} |
| 331 | |
| 332 | {isActionVisible(rowAffordance.movePage) && isActionVisible(rowAffordance.archivePage) && ( |
| 333 | <div className={MENU_SEPARATOR_CLASS} role="separator" /> |
| 334 | )} |
| 335 | |
| 336 | {isActionVisible(rowAffordance.archivePage) && ( |
| 337 | <button |
| 338 | role="menuitem" |
| 339 | onMouseDown={(e) => e.preventDefault()} |
| 340 | onClick={handleArchive} |
| 341 | disabled={archiving || !isActionEnabled(rowAffordance.archivePage)} |
| 342 | className={`${MENU_ITEM_CLASS} ${MENU_DANGER_ITEM_CLASS}`} |
| 343 | title={rowAffordance.archivePage.kind === "disabled" ? rowAffordance.archivePage.reason : undefined} |
| 344 | > |
| 345 | <span className={MENU_DANGER_ICON_CLASS}> |
| 346 | <Trash2 className="h-3.5 w-3.5" /> |
| 347 | </span> |
| 348 | <span className={MENU_LABEL_CLASS}>{archiving ? "Archiving..." : "Archive"}</span> |
| 349 | </button> |
| 350 | )} |
| 351 | </div> |
| 352 | </DropdownPortal> |
| 353 | ); |
| 354 | }; |
| 355 | |
| 356 | return ( |
| 357 | <div> |
| 358 | <Link |
| 359 | to="/$workspaceSlug/$pageId" |
| 360 | params={{ workspaceSlug: params.workspaceSlug || currentWorkspace?.slug || "", pageId: page.id }} |
| 361 | data-page-row |
| 362 | data-page-id={page.id} |
| 363 | data-depth={depth} |
| 364 | className={`group relative flex h-8 items-center gap-1 rounded-md px-2 text-sm transition-colors ${ |
| 365 | isActive ? "bg-accent-500/10 text-accent-400" : "text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-200" |
| 366 | }`} |
| 367 | style={{ paddingLeft: rowPaddingLeft }} |
| 368 | > |
| 369 | {hasChildren && ( |
| 370 | <button |
| 371 | onClick={toggleExpand} |
| 372 | className="absolute top-1/2 flex h-5 w-4 -translate-y-1/2 items-center justify-center rounded text-zinc-500 transition-colors hover:bg-zinc-700 hover:text-zinc-300" |
| 373 | style={{ left: chevronLeft }} |
| 374 | tabIndex={-1} |
| 375 | aria-label={isExpanded ? "Collapse" : "Expand"} |
| 376 | > |
| 377 | <ChevronRight className={`h-3 w-3 transition-transform ${isExpanded ? "rotate-90" : ""}`} /> |
| 378 | </button> |
| 379 | )} |
| 380 | |
| 381 | <span className="flex h-5 w-5 shrink-0 items-center justify-center text-xs"> |
| 382 | {page.icon ? ( |
| 383 | <EmojiIcon emoji={page.icon} size={14} /> |
| 384 | ) : page.kind === "canvas" ? ( |
| 385 | <PenTool className="h-3.5 w-3.5 text-zinc-500" /> |
| 386 | ) : ( |
| 387 | <FileText className="h-3.5 w-3.5 text-zinc-500" /> |
| 388 | )} |
| 389 | </span> |
| 390 | |
| 391 | <span className="truncate" title={page.title || DEFAULT_PAGE_TITLE}> |
| 392 | {page.title || DEFAULT_PAGE_TITLE} |
| 393 | </span> |
| 394 | |
| 395 | {isActionVisible(rowAffordance.createSubpage) && ( |
| 396 | <button |
| 397 | onClick={handleCreateSubpage} |
| 398 | disabled={creating || !isActionEnabled(rowAffordance.createSubpage)} |
| 399 | className={`ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded hover:bg-zinc-700 ${addVisibility} disabled:opacity-50`} |
| 400 | tabIndex={-1} |
| 401 | aria-label="Create subpage" |
| 402 | title={rowAffordance.createSubpage.kind === "disabled" ? rowAffordance.createSubpage.reason : undefined} |
| 403 | > |
| 404 | <Plus className="h-3.5 w-3.5" /> |
| 405 | </button> |
| 406 | )} |
| 407 | |
| 408 | {(isActionVisible(rowAffordance.movePage) || isActionVisible(rowAffordance.archivePage)) && ( |
| 409 | <div ref={menuRef} className="shrink-0"> |
| 410 | <button |
| 411 | onClick={(e) => { |
| 412 | e.preventDefault(); |
| 413 | e.stopPropagation(); |
| 414 | setMenuOpen((v) => !v); |
| 415 | }} |
| 416 | className={`flex h-6 w-6 items-center justify-center rounded hover:bg-zinc-700 ${moreVisibility}`} |
| 417 | tabIndex={-1} |
| 418 | aria-label="Page options" |
| 419 | > |
| 420 | <MoreHorizontal className="h-3.5 w-3.5" /> |
| 421 | </button> |
| 422 | {menuOpen && renderActionsMenu()} |
| 423 | </div> |
| 424 | )} |
| 425 | </Link> |
| 426 | |
| 427 | {moveDialogOpen && ( |
| 428 | <Suspense fallback={null}> |
| 429 | <SidebarMoveDialog |
| 430 | open |
| 431 | page={page} |
| 432 | allPages={allPages} |
| 433 | index={index} |
| 434 | onClose={() => setMoveDialogOpen(false)} |
| 435 | onConfirm={executeMove} |
| 436 | /> |
| 437 | </Suspense> |
| 438 | )} |
| 439 | |
| 440 | {isExpanded && hasChildren && ( |
| 441 | <div> |
| 442 | {childPages.map((child) => ( |
| 443 | <PageTreeItem |
| 444 | key={child.id} |
| 445 | page={child} |
| 446 | depth={depth + 1} |
| 447 | index={index} |
| 448 | allPages={allPages} |
| 449 | alwaysShowActions={alwaysShowActions} |
| 450 | activeAncestorIds={activeAncestorIds} |
| 451 | menuZIndex={menuZIndex} |
| 452 | workspaceRole={workspaceRole} |
| 453 | online={online} |
| 454 | /> |
| 455 | ))} |
| 456 | </div> |
| 457 | )} |
| 458 | </div> |
| 459 | ); |
| 460 | } |