File
Blob: src/client/components/sidebar/sidebar-move-dialog.tsx
| 1 | import { useCallback, useMemo, useState, type ReactNode } from "react"; |
| 2 | import { ChevronRight, FileText, Search } from "lucide-react"; |
| 3 | import { Dialog } from "@/client/components/ui/dialog"; |
| 4 | import { Button } from "@/client/components/ui/button"; |
| 5 | import { EmojiIcon } from "@/client/components/ui/emoji-icon"; |
| 6 | import { |
| 7 | buildPageMap, |
| 8 | resolveMoveRelative, |
| 9 | resolveMoveToRoot, |
| 10 | type MoveRelation, |
| 11 | type MoveResult, |
| 12 | type PageTreeIndex, |
| 13 | } from "@/client/lib/page-tree-model"; |
| 14 | import { DEFAULT_PAGE_TITLE } from "@/shared/constants"; |
| 15 | import type { Page } from "@/shared/types"; |
| 16 | |
| 17 | interface SidebarMoveDialogProps { |
| 18 | open: boolean; |
| 19 | page: Page; |
| 20 | allPages: Page[]; |
| 21 | index?: PageTreeIndex; |
| 22 | onClose: () => void; |
| 23 | onConfirm: (result: Extract<MoveResult, { ok: true }>) => Promise<void>; |
| 24 | } |
| 25 | |
| 26 | const EMPTY_ANCESTORS: ReadonlySet<string> = new Set(); |
| 27 | |
| 28 | type TargetSelection = { kind: "root" } | { kind: "page"; pageId: string } | null; |
| 29 | |
| 30 | interface TreeNode { |
| 31 | page: Page; |
| 32 | children: TreeNode[]; |
| 33 | } |
| 34 | |
| 35 | const PAGE_RELATIONS = ["before", "inside", "after"] as const; |
| 36 | const ROOT_RELATIONS = ["root-top", "root-bottom"] as const; |
| 37 | |
| 38 | const RELATION_LABEL: Record<MoveRelation, string> = { |
| 39 | before: "Before", |
| 40 | inside: "Inside", |
| 41 | after: "After", |
| 42 | "root-top": "At top", |
| 43 | "root-bottom": "At bottom", |
| 44 | }; |
| 45 | |
| 46 | const INDENT_BASE_PX = 20; |
| 47 | const INDENT_PER_DEPTH_PX = 16; |
| 48 | const CHEVRON_COLUMN_PX = 20; |
| 49 | const CHIP_OFFSET_PX = 48; |
| 50 | |
| 51 | function buildTree(pages: Page[]): TreeNode[] { |
| 52 | const active = pages.filter((candidate) => !candidate.archived_at); |
| 53 | const byParent = new Map<string | null, Page[]>(); |
| 54 | for (const candidate of active) { |
| 55 | const arr = byParent.get(candidate.parent_id) ?? []; |
| 56 | arr.push(candidate); |
| 57 | byParent.set(candidate.parent_id, arr); |
| 58 | } |
| 59 | for (const arr of byParent.values()) arr.sort((a, b) => a.position - b.position); |
| 60 | const build = (parentId: string | null): TreeNode[] => |
| 61 | (byParent.get(parentId) ?? []).map((candidate) => ({ page: candidate, children: build(candidate.id) })); |
| 62 | return build(null); |
| 63 | } |
| 64 | |
| 65 | export function SidebarMoveDialog({ |
| 66 | open, |
| 67 | page, |
| 68 | allPages, |
| 69 | index: providedIndex, |
| 70 | onClose, |
| 71 | onConfirm, |
| 72 | }: SidebarMoveDialogProps) { |
| 73 | const [query, setQuery] = useState(""); |
| 74 | const [target, setTarget] = useState<TargetSelection>(null); |
| 75 | const [relation, setRelation] = useState<MoveRelation | null>(null); |
| 76 | const [submitting, setSubmitting] = useState(false); |
| 77 | const [manualExpand, setManualExpand] = useState<Record<string, boolean>>({}); |
| 78 | |
| 79 | const byId = useMemo(() => providedIndex?.byId ?? buildPageMap(allPages), [providedIndex, allPages]); |
| 80 | const tree = useMemo(() => buildTree(allPages), [allPages]); |
| 81 | |
| 82 | // Cache ancestor sets per page (amortized O(n) build, O(1) lookup), so the |
| 83 | // search/autoExpand passes don't re-walk parent chains for every node. |
| 84 | const ancestorsByPage = useMemo(() => { |
| 85 | const cache = new Map<string, Set<string>>(); |
| 86 | function compute(id: string): Set<string> { |
| 87 | const cached = cache.get(id); |
| 88 | if (cached) return cached; |
| 89 | const node = byId.get(id); |
| 90 | let result: Set<string>; |
| 91 | if (!node?.parent_id) { |
| 92 | result = new Set(); |
| 93 | } else { |
| 94 | result = new Set(compute(node.parent_id)); |
| 95 | result.add(node.parent_id); |
| 96 | } |
| 97 | cache.set(id, result); |
| 98 | return result; |
| 99 | } |
| 100 | for (const candidate of allPages) compute(candidate.id); |
| 101 | return cache; |
| 102 | }, [allPages, byId]); |
| 103 | |
| 104 | const ancestorsOf = useCallback( |
| 105 | (id: string | null): ReadonlySet<string> => (id ? (ancestorsByPage.get(id) ?? EMPTY_ANCESTORS) : EMPTY_ANCESTORS), |
| 106 | [ancestorsByPage], |
| 107 | ); |
| 108 | |
| 109 | const movingSubtreeIds = useMemo(() => { |
| 110 | const result = new Set<string>(); |
| 111 | for (const candidate of allPages) { |
| 112 | if (candidate.archived_at || candidate.id === page.id) continue; |
| 113 | if (ancestorsOf(candidate.id).has(page.id)) result.add(candidate.id); |
| 114 | } |
| 115 | return result; |
| 116 | }, [allPages, ancestorsOf, page.id]); |
| 117 | |
| 118 | const isSearchActive = query.trim().length > 0; |
| 119 | |
| 120 | const { matches, visibleIds } = useMemo(() => { |
| 121 | if (!isSearchActive) return { matches: null as Set<string> | null, visibleIds: null as Set<string> | null }; |
| 122 | const needle = query.trim().toLowerCase(); |
| 123 | const hits = new Set<string>(); |
| 124 | const visible = new Set<string>(); |
| 125 | for (const candidate of allPages) { |
| 126 | if (candidate.archived_at) continue; |
| 127 | if (candidate.id === page.id) continue; |
| 128 | if (movingSubtreeIds.has(candidate.id)) continue; |
| 129 | if (candidate.title.toLowerCase().includes(needle)) { |
| 130 | hits.add(candidate.id); |
| 131 | visible.add(candidate.id); |
| 132 | for (const id of ancestorsOf(candidate.id)) visible.add(id); |
| 133 | } |
| 134 | } |
| 135 | return { matches: hits, visibleIds: visible }; |
| 136 | }, [allPages, ancestorsOf, isSearchActive, movingSubtreeIds, page.id, query]); |
| 137 | |
| 138 | const effectiveTarget = useMemo<TargetSelection>(() => { |
| 139 | if (!target) return null; |
| 140 | if (target.kind === "root") return isSearchActive ? null : target; |
| 141 | if (visibleIds && !visibleIds.has(target.pageId)) return null; |
| 142 | return target; |
| 143 | }, [isSearchActive, target, visibleIds]); |
| 144 | |
| 145 | const autoExpandIds = useMemo(() => { |
| 146 | const result = new Set<string>(); |
| 147 | for (const id of ancestorsOf(page.id)) result.add(id); |
| 148 | if (effectiveTarget?.kind === "page") { |
| 149 | for (const id of ancestorsOf(effectiveTarget.pageId)) result.add(id); |
| 150 | } |
| 151 | if (matches) { |
| 152 | for (const id of matches) { |
| 153 | for (const anc of ancestorsOf(id)) result.add(anc); |
| 154 | } |
| 155 | } |
| 156 | return result; |
| 157 | }, [ancestorsOf, effectiveTarget, matches, page.id]); |
| 158 | |
| 159 | const isExpanded = useCallback( |
| 160 | (id: string): boolean => (id in manualExpand ? manualExpand[id] : autoExpandIds.has(id)), |
| 161 | [autoExpandIds, manualExpand], |
| 162 | ); |
| 163 | const toggleNode = useCallback( |
| 164 | (id: string) => |
| 165 | setManualExpand((prev) => { |
| 166 | const was = id in prev ? prev[id] : autoExpandIds.has(id); |
| 167 | return { ...prev, [id]: !was }; |
| 168 | }), |
| 169 | [autoExpandIds], |
| 170 | ); |
| 171 | |
| 172 | const selectedPage = effectiveTarget?.kind === "page" ? (byId.get(effectiveTarget.pageId) ?? null) : null; |
| 173 | const relationResults = useMemo(() => { |
| 174 | if (!effectiveTarget) return []; |
| 175 | if (effectiveTarget.kind === "root") { |
| 176 | return ROOT_RELATIONS.map((candidate) => ({ |
| 177 | relation: candidate as MoveRelation, |
| 178 | label: RELATION_LABEL[candidate], |
| 179 | result: resolveMoveToRoot(allPages, page, candidate === "root-top" ? "top" : "bottom", providedIndex), |
| 180 | })); |
| 181 | } |
| 182 | if (!selectedPage) return []; |
| 183 | return PAGE_RELATIONS.map((candidate) => ({ |
| 184 | relation: candidate as MoveRelation, |
| 185 | label: RELATION_LABEL[candidate], |
| 186 | result: resolveMoveRelative({ |
| 187 | allPages, |
| 188 | page, |
| 189 | targetPage: selectedPage, |
| 190 | relation: candidate, |
| 191 | index: providedIndex, |
| 192 | }), |
| 193 | })); |
| 194 | }, [allPages, effectiveTarget, page, providedIndex, selectedPage]); |
| 195 | |
| 196 | const selectedRelation = relation |
| 197 | ? (relationResults.find((candidate) => candidate.relation === relation) ?? null) |
| 198 | : null; |
| 199 | const selectedResolution = selectedRelation?.result ?? null; |
| 200 | |
| 201 | const selectTarget = useCallback((next: TargetSelection) => { |
| 202 | setTarget(next); |
| 203 | setRelation(null); |
| 204 | }, []); |
| 205 | |
| 206 | const handleConfirm = async () => { |
| 207 | if (!selectedResolution || !selectedResolution.ok || submitting) return; |
| 208 | setSubmitting(true); |
| 209 | try { |
| 210 | await onConfirm(selectedResolution); |
| 211 | onClose(); |
| 212 | } finally { |
| 213 | setSubmitting(false); |
| 214 | } |
| 215 | }; |
| 216 | |
| 217 | const footerStatus: ReactNode = (() => { |
| 218 | if (selectedResolution?.ok) { |
| 219 | return <span className="text-zinc-300">{selectedResolution.proposal.previewLabel}</span>; |
| 220 | } |
| 221 | if (selectedResolution && !selectedResolution.ok) { |
| 222 | return <span className="text-amber-400/90">{selectedResolution.message}</span>; |
| 223 | } |
| 224 | if (effectiveTarget) return <span>Pick where it goes.</span>; |
| 225 | return <span>Pick a target, then where it goes.</span>; |
| 226 | })(); |
| 227 | |
| 228 | const treeHasResults = !isSearchActive || (visibleIds?.size ?? 0) > 0; |
| 229 | |
| 230 | const renderNodes = (nodes: TreeNode[], depth: number): ReactNode => |
| 231 | nodes.map((node) => { |
| 232 | const id = node.page.id; |
| 233 | const isMoving = id === page.id; |
| 234 | const isInMovingSubtree = movingSubtreeIds.has(id); |
| 235 | const isSelectable = !isMoving && !isInMovingSubtree; |
| 236 | |
| 237 | if (isSearchActive) { |
| 238 | if (isMoving || isInMovingSubtree) return null; |
| 239 | if (visibleIds && !visibleIds.has(id)) return null; |
| 240 | } |
| 241 | |
| 242 | const isSelected = effectiveTarget?.kind === "page" && effectiveTarget.pageId === id; |
| 243 | const hasChildren = node.children.length > 0; |
| 244 | const expanded = isExpanded(id); |
| 245 | const isMatch = matches?.has(id) ?? false; |
| 246 | const rowState: TreeRowState = isMoving |
| 247 | ? "moving" |
| 248 | : isInMovingSubtree |
| 249 | ? "blocked" |
| 250 | : isSelected |
| 251 | ? "selected" |
| 252 | : "selectable"; |
| 253 | |
| 254 | return ( |
| 255 | <div key={id}> |
| 256 | <TreeRow |
| 257 | label={node.page.title || DEFAULT_PAGE_TITLE} |
| 258 | depth={depth} |
| 259 | icon={ |
| 260 | node.page.icon ? ( |
| 261 | <EmojiIcon emoji={node.page.icon} size={14} /> |
| 262 | ) : ( |
| 263 | <FileText className="h-3.5 w-3.5 text-zinc-500" /> |
| 264 | ) |
| 265 | } |
| 266 | hasChildren={hasChildren} |
| 267 | expanded={expanded} |
| 268 | onToggle={() => toggleNode(id)} |
| 269 | state={rowState} |
| 270 | emphasized={isMatch} |
| 271 | onSelect={() => isSelectable && selectTarget({ kind: "page", pageId: id })} |
| 272 | /> |
| 273 | {isSelected && ( |
| 274 | <PlacementChips depth={depth} relations={relationResults} selected={relation} onSelect={setRelation} /> |
| 275 | )} |
| 276 | {expanded && hasChildren && renderNodes(node.children, depth + 1)} |
| 277 | </div> |
| 278 | ); |
| 279 | }); |
| 280 | |
| 281 | return ( |
| 282 | <Dialog |
| 283 | open={open} |
| 284 | onClose={submitting ? () => {} : onClose} |
| 285 | ariaLabelledBy="sidebar-move-dialog-title" |
| 286 | className="flex w-full max-w-lg flex-col overflow-hidden max-h-[min(640px,85vh)]" |
| 287 | > |
| 288 | <div className="flex items-baseline justify-between gap-4 px-5 pt-5"> |
| 289 | <h2 |
| 290 | id="sidebar-move-dialog-title" |
| 291 | className="min-w-0 truncate text-[15px] font-medium tracking-tight text-zinc-100" |
| 292 | > |
| 293 | Move <span className="text-zinc-400">“{page.title || DEFAULT_PAGE_TITLE}”</span> |
| 294 | </h2> |
| 295 | <kbd className="shrink-0 font-mono text-[11px] text-zinc-600">esc</kbd> |
| 296 | </div> |
| 297 | |
| 298 | <label className="mt-3 flex items-center gap-2.5 border-b border-zinc-700/60 px-5 pb-3"> |
| 299 | <Search className="h-3.5 w-3.5 text-zinc-500" aria-hidden="true" /> |
| 300 | <input |
| 301 | autoFocus |
| 302 | value={query} |
| 303 | onChange={(e) => setQuery(e.target.value)} |
| 304 | placeholder="Find a page…" |
| 305 | aria-label="Find a page to move near" |
| 306 | className="w-full bg-transparent text-sm text-zinc-100 outline-none placeholder:text-zinc-500" |
| 307 | /> |
| 308 | </label> |
| 309 | |
| 310 | <div className="min-h-0 flex-1 overflow-y-auto py-1"> |
| 311 | {!isSearchActive && ( |
| 312 | <> |
| 313 | <TopLevelRow selected={effectiveTarget?.kind === "root"} onSelect={() => selectTarget({ kind: "root" })} /> |
| 314 | {effectiveTarget?.kind === "root" && ( |
| 315 | <PlacementChips depth={0} relations={relationResults} selected={relation} onSelect={setRelation} /> |
| 316 | )} |
| 317 | <div className="mx-5 my-1 h-px bg-zinc-700/40" aria-hidden="true" /> |
| 318 | </> |
| 319 | )} |
| 320 | {treeHasResults ? ( |
| 321 | renderNodes(tree, 0) |
| 322 | ) : ( |
| 323 | <div className="px-5 py-10 text-center text-sm text-zinc-500">No page matches that.</div> |
| 324 | )} |
| 325 | </div> |
| 326 | |
| 327 | <div className="flex items-center justify-between gap-4 border-t border-zinc-700/60 px-5 py-3"> |
| 328 | <div className="min-w-0 flex-1 truncate text-xs text-zinc-500">{footerStatus}</div> |
| 329 | <div className="flex items-center gap-1.5"> |
| 330 | <Button variant="ghost" size="sm" onClick={onClose} disabled={submitting}> |
| 331 | Cancel |
| 332 | </Button> |
| 333 | <Button |
| 334 | variant="primary" |
| 335 | size="sm" |
| 336 | loading={submitting} |
| 337 | disabled={!selectedResolution || !selectedResolution.ok} |
| 338 | onClick={handleConfirm} |
| 339 | > |
| 340 | Move |
| 341 | </Button> |
| 342 | </div> |
| 343 | </div> |
| 344 | </Dialog> |
| 345 | ); |
| 346 | } |
| 347 | |
| 348 | type TreeRowState = "selectable" | "selected" | "moving" | "blocked"; |
| 349 | |
| 350 | interface TreeRowProps { |
| 351 | label: string; |
| 352 | depth: number; |
| 353 | icon: ReactNode; |
| 354 | hasChildren: boolean; |
| 355 | expanded: boolean; |
| 356 | onToggle: () => void; |
| 357 | state: TreeRowState; |
| 358 | emphasized: boolean; |
| 359 | onSelect: () => void; |
| 360 | } |
| 361 | |
| 362 | function TreeRow({ label, depth, icon, hasChildren, expanded, onToggle, state, emphasized, onSelect }: TreeRowProps) { |
| 363 | const indent = INDENT_BASE_PX + depth * INDENT_PER_DEPTH_PX; |
| 364 | const selected = state === "selected"; |
| 365 | const selectable = state === "selectable" || state === "selected"; |
| 366 | const muted = state === "moving" || state === "blocked"; |
| 367 | const movingMarker = state === "moving"; |
| 368 | return ( |
| 369 | <div |
| 370 | className={`flex items-stretch transition-colors ${ |
| 371 | selected ? "bg-zinc-700/25" : selectable ? "hover:bg-zinc-700/15" : "" |
| 372 | }`} |
| 373 | style={{ paddingLeft: indent }} |
| 374 | > |
| 375 | <span className="flex shrink-0 items-center py-1.5" style={{ width: CHEVRON_COLUMN_PX }}> |
| 376 | {hasChildren && ( |
| 377 | <button |
| 378 | type="button" |
| 379 | onClick={(e) => { |
| 380 | e.stopPropagation(); |
| 381 | onToggle(); |
| 382 | }} |
| 383 | className="flex h-4 w-4 items-center justify-center rounded text-zinc-500 hover:bg-zinc-700 hover:text-zinc-300" |
| 384 | aria-label={expanded ? "Collapse" : "Expand"} |
| 385 | tabIndex={-1} |
| 386 | > |
| 387 | <ChevronRight className={`h-3 w-3 transition-transform ${expanded ? "rotate-90" : ""}`} /> |
| 388 | </button> |
| 389 | )} |
| 390 | </span> |
| 391 | <button |
| 392 | type="button" |
| 393 | onClick={onSelect} |
| 394 | disabled={!selectable} |
| 395 | aria-pressed={selected} |
| 396 | className={`flex min-w-0 flex-1 items-center gap-2 py-1.5 pr-5 text-left outline-none focus-visible:bg-zinc-700/30 ${ |
| 397 | selectable ? "" : "cursor-not-allowed" |
| 398 | }`} |
| 399 | > |
| 400 | <span className="flex h-5 w-5 shrink-0 items-center justify-center">{icon}</span> |
| 401 | <span |
| 402 | className={`min-w-0 flex-1 truncate text-sm ${ |
| 403 | muted ? "text-zinc-500" : emphasized ? "font-medium text-zinc-100" : "text-zinc-200" |
| 404 | }`} |
| 405 | > |
| 406 | {label} |
| 407 | </span> |
| 408 | {movingMarker && <span className="shrink-0 text-xs italic text-zinc-500">moving</span>} |
| 409 | </button> |
| 410 | </div> |
| 411 | ); |
| 412 | } |
| 413 | |
| 414 | interface TopLevelRowProps { |
| 415 | selected: boolean; |
| 416 | onSelect: () => void; |
| 417 | } |
| 418 | |
| 419 | function TopLevelRow({ selected, onSelect }: TopLevelRowProps) { |
| 420 | return ( |
| 421 | <div |
| 422 | className={`flex items-stretch transition-colors ${selected ? "bg-zinc-700/25" : "hover:bg-zinc-700/15"}`} |
| 423 | style={{ paddingLeft: INDENT_BASE_PX }} |
| 424 | > |
| 425 | <span className="flex shrink-0 items-center py-1.5" style={{ width: CHEVRON_COLUMN_PX }} aria-hidden="true" /> |
| 426 | <button |
| 427 | type="button" |
| 428 | onClick={onSelect} |
| 429 | aria-pressed={selected} |
| 430 | className="flex min-w-0 flex-1 items-center gap-2 py-1.5 pr-5 text-left outline-none focus-visible:bg-zinc-700/30" |
| 431 | > |
| 432 | <span className="flex h-5 w-5 shrink-0 items-center justify-center" aria-hidden="true"> |
| 433 | <span className="h-[1.5px] w-3 rounded-full bg-zinc-500" /> |
| 434 | </span> |
| 435 | <span className="min-w-0 flex-1 truncate text-sm text-zinc-200">Top level</span> |
| 436 | <span className="shrink-0 text-xs text-zinc-500">No parent</span> |
| 437 | </button> |
| 438 | </div> |
| 439 | ); |
| 440 | } |
| 441 | |
| 442 | interface PlacementChipsProps { |
| 443 | depth: number; |
| 444 | relations: { relation: MoveRelation; label: string; result: MoveResult }[]; |
| 445 | selected: MoveRelation | null; |
| 446 | onSelect: (relation: MoveRelation) => void; |
| 447 | } |
| 448 | |
| 449 | function PlacementChips({ depth, relations, selected, onSelect }: PlacementChipsProps) { |
| 450 | const pl = INDENT_BASE_PX + depth * INDENT_PER_DEPTH_PX + CHIP_OFFSET_PX; |
| 451 | return ( |
| 452 | <div |
| 453 | role="group" |
| 454 | aria-label="Placement" |
| 455 | className="flex items-center gap-0.5 pb-2.5 pr-5" |
| 456 | style={{ paddingLeft: pl }} |
| 457 | > |
| 458 | {relations.map(({ relation, label, result }) => { |
| 459 | const active = selected === relation; |
| 460 | const disabled = !result.ok; |
| 461 | return ( |
| 462 | <button |
| 463 | key={relation} |
| 464 | type="button" |
| 465 | aria-pressed={active} |
| 466 | disabled={disabled} |
| 467 | title={disabled ? result.message : undefined} |
| 468 | onClick={(e) => { |
| 469 | e.stopPropagation(); |
| 470 | onSelect(relation); |
| 471 | }} |
| 472 | className={`rounded-md px-2 py-0.5 text-xs font-medium transition-colors ${ |
| 473 | disabled |
| 474 | ? "cursor-not-allowed text-zinc-600" |
| 475 | : active |
| 476 | ? "bg-zinc-100 text-zinc-900" |
| 477 | : "text-zinc-400 hover:bg-zinc-700/60 hover:text-zinc-100" |
| 478 | }`} |
| 479 | > |
| 480 | {label} |
| 481 | </button> |
| 482 | ); |
| 483 | })} |
| 484 | </div> |
| 485 | ); |
| 486 | } |