Skip to content
File

Blob: src/client/components/sidebar/sidebar-move-dialog.tsx

typescript487 lines
1import { useCallback, useMemo, useState, type ReactNode } from "react";
2import { ChevronRight, FileText, Search } from "lucide-react";
3import { Dialog } from "@/client/components/ui/dialog";
4import { Button } from "@/client/components/ui/button";
5import { EmojiIcon } from "@/client/components/ui/emoji-icon";
6import {
7 buildPageMap,
8 resolveMoveRelative,
9 resolveMoveToRoot,
10 type MoveRelation,
11 type MoveResult,
12 type PageTreeIndex,
13} from "@/client/lib/page-tree-model";
14import { DEFAULT_PAGE_TITLE } from "@/shared/constants";
15import type { Page } from "@/shared/types";
16 
17interface 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 
26const EMPTY_ANCESTORS: ReadonlySet<string> = new Set();
27 
28type TargetSelection = { kind: "root" } | { kind: "page"; pageId: string } | null;
29 
30interface TreeNode {
31 page: Page;
32 children: TreeNode[];
33}
34 
35const PAGE_RELATIONS = ["before", "inside", "after"] as const;
36const ROOT_RELATIONS = ["root-top", "root-bottom"] as const;
37 
38const 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 
46const INDENT_BASE_PX = 20;
47const INDENT_PER_DEPTH_PX = 16;
48const CHEVRON_COLUMN_PX = 20;
49const CHIP_OFFSET_PX = 48;
50 
51function 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 
65export 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">&ldquo;{page.title || DEFAULT_PAGE_TITLE}&rdquo;</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 
348type TreeRowState = "selectable" | "selected" | "moving" | "blocked";
349 
350interface 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 
362function 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 
414interface TopLevelRowProps {
415 selected: boolean;
416 onSelect: () => void;
417}
418 
419function 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 
442interface PlacementChipsProps {
443 depth: number;
444 relations: { relation: MoveRelation; label: string; result: MoveResult }[];
445 selected: MoveRelation | null;
446 onSelect: (relation: MoveRelation) => void;
447}
448 
449function 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}