import { useCallback, useMemo, useState, type ReactNode } from "react"; import { ChevronRight, FileText, Search } from "lucide-react"; import { Dialog } from "@/client/components/ui/dialog"; import { Button } from "@/client/components/ui/button"; import { EmojiIcon } from "@/client/components/ui/emoji-icon"; import { buildPageMap, resolveMoveRelative, resolveMoveToRoot, type MoveRelation, type MoveResult, type PageTreeIndex, } from "@/client/lib/page-tree-model"; import { DEFAULT_PAGE_TITLE } from "@/shared/constants"; import type { Page } from "@/shared/types"; interface SidebarMoveDialogProps { open: boolean; page: Page; allPages: Page[]; index?: PageTreeIndex; onClose: () => void; onConfirm: (result: Extract) => Promise; } const EMPTY_ANCESTORS: ReadonlySet = new Set(); type TargetSelection = { kind: "root" } | { kind: "page"; pageId: string } | null; interface TreeNode { page: Page; children: TreeNode[]; } const PAGE_RELATIONS = ["before", "inside", "after"] as const; const ROOT_RELATIONS = ["root-top", "root-bottom"] as const; const RELATION_LABEL: Record = { before: "Before", inside: "Inside", after: "After", "root-top": "At top", "root-bottom": "At bottom", }; const INDENT_BASE_PX = 20; const INDENT_PER_DEPTH_PX = 16; const CHEVRON_COLUMN_PX = 20; const CHIP_OFFSET_PX = 48; function buildTree(pages: Page[]): TreeNode[] { const active = pages.filter((candidate) => !candidate.archived_at); const byParent = new Map(); for (const candidate of active) { const arr = byParent.get(candidate.parent_id) ?? []; arr.push(candidate); byParent.set(candidate.parent_id, arr); } for (const arr of byParent.values()) arr.sort((a, b) => a.position - b.position); const build = (parentId: string | null): TreeNode[] => (byParent.get(parentId) ?? []).map((candidate) => ({ page: candidate, children: build(candidate.id) })); return build(null); } export function SidebarMoveDialog({ open, page, allPages, index: providedIndex, onClose, onConfirm, }: SidebarMoveDialogProps) { const [query, setQuery] = useState(""); const [target, setTarget] = useState(null); const [relation, setRelation] = useState(null); const [submitting, setSubmitting] = useState(false); const [manualExpand, setManualExpand] = useState>({}); const byId = useMemo(() => providedIndex?.byId ?? buildPageMap(allPages), [providedIndex, allPages]); const tree = useMemo(() => buildTree(allPages), [allPages]); // Cache ancestor sets per page (amortized O(n) build, O(1) lookup), so the // search/autoExpand passes don't re-walk parent chains for every node. const ancestorsByPage = useMemo(() => { const cache = new Map>(); function compute(id: string): Set { const cached = cache.get(id); if (cached) return cached; const node = byId.get(id); let result: Set; if (!node?.parent_id) { result = new Set(); } else { result = new Set(compute(node.parent_id)); result.add(node.parent_id); } cache.set(id, result); return result; } for (const candidate of allPages) compute(candidate.id); return cache; }, [allPages, byId]); const ancestorsOf = useCallback( (id: string | null): ReadonlySet => (id ? (ancestorsByPage.get(id) ?? EMPTY_ANCESTORS) : EMPTY_ANCESTORS), [ancestorsByPage], ); const movingSubtreeIds = useMemo(() => { const result = new Set(); for (const candidate of allPages) { if (candidate.archived_at || candidate.id === page.id) continue; if (ancestorsOf(candidate.id).has(page.id)) result.add(candidate.id); } return result; }, [allPages, ancestorsOf, page.id]); const isSearchActive = query.trim().length > 0; const { matches, visibleIds } = useMemo(() => { if (!isSearchActive) return { matches: null as Set | null, visibleIds: null as Set | null }; const needle = query.trim().toLowerCase(); const hits = new Set(); const visible = new Set(); for (const candidate of allPages) { if (candidate.archived_at) continue; if (candidate.id === page.id) continue; if (movingSubtreeIds.has(candidate.id)) continue; if (candidate.title.toLowerCase().includes(needle)) { hits.add(candidate.id); visible.add(candidate.id); for (const id of ancestorsOf(candidate.id)) visible.add(id); } } return { matches: hits, visibleIds: visible }; }, [allPages, ancestorsOf, isSearchActive, movingSubtreeIds, page.id, query]); const effectiveTarget = useMemo(() => { if (!target) return null; if (target.kind === "root") return isSearchActive ? null : target; if (visibleIds && !visibleIds.has(target.pageId)) return null; return target; }, [isSearchActive, target, visibleIds]); const autoExpandIds = useMemo(() => { const result = new Set(); for (const id of ancestorsOf(page.id)) result.add(id); if (effectiveTarget?.kind === "page") { for (const id of ancestorsOf(effectiveTarget.pageId)) result.add(id); } if (matches) { for (const id of matches) { for (const anc of ancestorsOf(id)) result.add(anc); } } return result; }, [ancestorsOf, effectiveTarget, matches, page.id]); const isExpanded = useCallback( (id: string): boolean => (id in manualExpand ? manualExpand[id] : autoExpandIds.has(id)), [autoExpandIds, manualExpand], ); const toggleNode = useCallback( (id: string) => setManualExpand((prev) => { const was = id in prev ? prev[id] : autoExpandIds.has(id); return { ...prev, [id]: !was }; }), [autoExpandIds], ); const selectedPage = effectiveTarget?.kind === "page" ? (byId.get(effectiveTarget.pageId) ?? null) : null; const relationResults = useMemo(() => { if (!effectiveTarget) return []; if (effectiveTarget.kind === "root") { return ROOT_RELATIONS.map((candidate) => ({ relation: candidate as MoveRelation, label: RELATION_LABEL[candidate], result: resolveMoveToRoot(allPages, page, candidate === "root-top" ? "top" : "bottom", providedIndex), })); } if (!selectedPage) return []; return PAGE_RELATIONS.map((candidate) => ({ relation: candidate as MoveRelation, label: RELATION_LABEL[candidate], result: resolveMoveRelative({ allPages, page, targetPage: selectedPage, relation: candidate, index: providedIndex, }), })); }, [allPages, effectiveTarget, page, providedIndex, selectedPage]); const selectedRelation = relation ? (relationResults.find((candidate) => candidate.relation === relation) ?? null) : null; const selectedResolution = selectedRelation?.result ?? null; const selectTarget = useCallback((next: TargetSelection) => { setTarget(next); setRelation(null); }, []); const handleConfirm = async () => { if (!selectedResolution || !selectedResolution.ok || submitting) return; setSubmitting(true); try { await onConfirm(selectedResolution); onClose(); } finally { setSubmitting(false); } }; const footerStatus: ReactNode = (() => { if (selectedResolution?.ok) { return {selectedResolution.proposal.previewLabel}; } if (selectedResolution && !selectedResolution.ok) { return {selectedResolution.message}; } if (effectiveTarget) return Pick where it goes.; return Pick a target, then where it goes.; })(); const treeHasResults = !isSearchActive || (visibleIds?.size ?? 0) > 0; const renderNodes = (nodes: TreeNode[], depth: number): ReactNode => nodes.map((node) => { const id = node.page.id; const isMoving = id === page.id; const isInMovingSubtree = movingSubtreeIds.has(id); const isSelectable = !isMoving && !isInMovingSubtree; if (isSearchActive) { if (isMoving || isInMovingSubtree) return null; if (visibleIds && !visibleIds.has(id)) return null; } const isSelected = effectiveTarget?.kind === "page" && effectiveTarget.pageId === id; const hasChildren = node.children.length > 0; const expanded = isExpanded(id); const isMatch = matches?.has(id) ?? false; const rowState: TreeRowState = isMoving ? "moving" : isInMovingSubtree ? "blocked" : isSelected ? "selected" : "selectable"; return (
) : ( ) } hasChildren={hasChildren} expanded={expanded} onToggle={() => toggleNode(id)} state={rowState} emphasized={isMatch} onSelect={() => isSelectable && selectTarget({ kind: "page", pageId: id })} /> {isSelected && ( )} {expanded && hasChildren && renderNodes(node.children, depth + 1)}
); }); return ( {} : onClose} ariaLabelledBy="sidebar-move-dialog-title" className="flex w-full max-w-lg flex-col overflow-hidden max-h-[min(640px,85vh)]" >
esc
{!isSearchActive && ( <> selectTarget({ kind: "root" })} /> {effectiveTarget?.kind === "root" && ( )}
{footerStatus}
); } type TreeRowState = "selectable" | "selected" | "moving" | "blocked"; interface TreeRowProps { label: string; depth: number; icon: ReactNode; hasChildren: boolean; expanded: boolean; onToggle: () => void; state: TreeRowState; emphasized: boolean; onSelect: () => void; } function TreeRow({ label, depth, icon, hasChildren, expanded, onToggle, state, emphasized, onSelect }: TreeRowProps) { const indent = INDENT_BASE_PX + depth * INDENT_PER_DEPTH_PX; const selected = state === "selected"; const selectable = state === "selectable" || state === "selected"; const muted = state === "moving" || state === "blocked"; const movingMarker = state === "moving"; return (
{hasChildren && ( )}
); } interface TopLevelRowProps { selected: boolean; onSelect: () => void; } function TopLevelRow({ selected, onSelect }: TopLevelRowProps) { return (
); } interface PlacementChipsProps { depth: number; relations: { relation: MoveRelation; label: string; result: MoveResult }[]; selected: MoveRelation | null; onSelect: (relation: MoveRelation) => void; } function PlacementChips({ depth, relations, selected, onSelect }: PlacementChipsProps) { const pl = INDENT_BASE_PX + depth * INDENT_PER_DEPTH_PX + CHIP_OFFSET_PX; return (
{relations.map(({ relation, label, result }) => { const active = selected === relation; const disabled = !result.ok; return ( ); })}
); }