1"use client";
2
3import { useEffect, useRef, useState, type ReactNode } from "react";
4import Link from "next/link";
5import {
6 useSortable,
7 SortableContext,
8 verticalListSortingStrategy
9} from "@dnd-kit/sortable";
10import { CSS } from "@dnd-kit/utilities";
11import {
12 Archive,
13 Check,
14 ChevronRight,
15 Copy,
16 Database,
17 ExternalLink,
18 File,
19 FileText,
20 Folder,
21 FolderOpen,
22 GripVertical,
23 KeyRound,
24 Link as LinkIcon,
25 MoreHorizontal,
26 Package,
27 Pencil,
28 Share2,
29 Star,
30 Trash2
31} from "lucide-react";
32import {
33 useDeletePage,
34 usePageTree,
35 useToggleFavorite,
36 useUpdatePage
37} from "@/hooks/use-pages";
38import { useWorkspace } from "@/hooks/use-workspace";
39import { pageApi } from "@/lib/api";
40import { toast, useUiStore } from "@/stores/ui-store";
41import { cn } from "@/lib/utils";
42import type { PageTreeEntry } from "@/lib/types";
43
44interface PageTreeNodeProps {
45 entry: PageTreeEntry;
46 depth: number;
47 workspaceSlug: string;
48 workspaceId: string;
49
53 autoExpand?: Set<string>;
54}
55
56
66export function PageTreeNode({ entry, depth, workspaceSlug, workspaceId, autoExpand }: PageTreeNodeProps) {
67
68
69
70
71 const shouldAuto = autoExpand?.has(entry.id) ?? false;
72 const [expanded, setExpanded] = useState(shouldAuto);
73 useEffect(() => {
74 if (shouldAuto) setExpanded(true);
75 }, [shouldAuto]);
76 const [renaming, setRenaming] = useState(false);
77 const [ctxOpen, setCtxOpen] = useState(false);
78
79 const [ctxPos, setCtxPos] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
80 const rowRef = useRef<HTMLLIElement>(null);
81
82
83 const childrenQuery = usePageTree(workspaceId, entry.id);
84 const children = entry.children ?? childrenQuery.data ?? [];
85
86
87
88
89 const hasChildren = Boolean(children.length > 0 || entry.hasMore || (expanded && childrenQuery.isLoading));
90
91 const updatePage = useUpdatePage(workspaceId);
92 const deletePage = useDeletePage(workspaceId);
93 const favorite = useToggleFavorite();
94 const setMove = useUiStore((s) => s.setMove);
95
96
97 const pageSelection = useUiStore((s) => s.pageSelection);
98 const togglePageSelect = useUiStore((s) => s.togglePageSelect);
99 const selected = pageSelection.workspaceId === workspaceId && pageSelection.ids.includes(entry.id);
100
101 const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
102 id: entry.id,
103 data: { type: "page", parentId: entry.parentId }
104 });
105
106 useEffect(() => {
107 if (!ctxOpen) return;
108 const onClick = (e: MouseEvent) => {
109 if (rowRef.current && !rowRef.current.contains(e.target as Node)) {
110 setCtxOpen(false);
111 }
112 };
113 const onKey = (e: KeyboardEvent) => {
114 if (e.key === "Escape") {
115 setCtxOpen(false);
116 }
117 };
118 document.addEventListener("mousedown", onClick);
119 document.addEventListener("keydown", onKey);
120 return () => {
121 document.removeEventListener("mousedown", onClick);
122 document.removeEventListener("keydown", onKey);
123 };
124 }, [ctxOpen]);
125
126 const icon = pageRowIcon(entry, hasChildren, expanded);
127
128 const href =
129 entry.type === "page" || entry.type === "database"
130 ? `/w/${workspaceSlug}/page/${entry.id}`
131 : undefined;
132
133 const commitRename = (value: string) => {
134 setRenaming(false);
135 const title = value.trim();
136 if (!title || title === entry.title) return;
137 updatePage.mutate(
138 { id: entry.id, title },
139 {
140 onSuccess: () => toast("success", "Renamed", title),
141 onError: (e: Error) => toast("error", "Rename failed", e.message)
142 }
143 );
144 };
145
146 const addSubPage = () => {
147
148
149 window.dispatchEvent(
150 new CustomEvent("factory:new-page", {
151 detail: { parentId: entry.id }
152 })
153 );
154 };
155
156 const duplicate = () => {
157 window.dispatchEvent(
158 new CustomEvent("factory:page-action", {
159 detail: { action: "duplicate", pageId: entry.id, parentId: entry.parentId }
160 })
161 );
162 };
163
164 const remove = () => {
165 deletePage.mutate(entry.id, {
166 onSuccess: () => toast("info", "Moved to trash", entry.title || "Untitled"),
167 onError: (e: Error) => toast("error", "Delete failed", e.message)
168 });
169 };
170
171
172
173
174
175
176
177
178 const { data: workspace } = useWorkspace(workspaceSlug);
179 const role = (workspace?.role ?? "").toLowerCase();
180 const isGuest = role === "guest";
181 const isOwner = role === "owner" || role === "admin";
182
183 const exportMarkdown = async () => {
184 setCtxOpen(false);
185 try {
186 const res = await pageApi.exportMarkdown(entry.id);
187 const text = await res.text();
188 const m = /filename="?([^";]+)"?/.exec(res.headers.get("content-disposition") ?? "");
189 const blob = new Blob([text], { type: "text/markdown;charset=utf-8" });
190 const url = URL.createObjectURL(blob);
191 const a = document.createElement("a");
192 a.href = url;
193 a.download = m?.[1] || `${entry.id}.md`;
194 document.body.appendChild(a);
195 a.click();
196 a.remove();
197 URL.revokeObjectURL(url);
198 toast("success", "Exported as Markdown", a.download);
199 } catch (e) {
200 toast("error", "Export failed", e instanceof Error ? e.message : String(e));
201 }
202 };
203
204 const contextItems: Array<{
205 id: string;
206 label: string;
207 icon: typeof File;
208 onClick: () => void;
209 disabled?: boolean;
210 destructive?: boolean;
211 }> = [
212 { id: "open", label: "Open", icon: File, onClick: () => { setCtxOpen(false); window.location.href = href ?? "/"; } },
213 { id: "open-tab", label: "Open in new tab", icon: ExternalLink, disabled: !href, onClick: () => href && window.open(href, "_blank", "noopener") },
214 { id: "duplicate", label: "Duplicate", icon: Copy, onClick: () => { setCtxOpen(false); duplicate(); } },
215 { id: "rename", label: "Rename", icon: Pencil, onClick: () => { setCtxOpen(false); setRenaming(true); } },
216 { id: "move", label: "Move to…", icon: FolderOpen, onClick: () => { setCtxOpen(false); setMove(true, { pageId: entry.id, workspaceId, title: entry.title || "Untitled" }); } },
217 {
218 id: "copy-link",
219 label: "Copy link",
220 icon: LinkIcon,
221 onClick: async () => {
222 setCtxOpen(false);
223 if (!href) return;
224 const url = new URL(href, window.location.origin).toString();
225 try {
226 await navigator.clipboard.writeText(url);
227 toast("success", "Link copied");
228 } catch {
229 toast("error", "Could not copy link");
230 }
231 }
232 },
233 { id: "favorite", label: "Favorite", icon: Star, onClick: () => { setCtxOpen(false); favorite.mutate(entry.id); } },
234
235 { id: "archive", label: "Archive", icon: Archive, onClick: () => {
236 setCtxOpen(false);
237 updatePage.mutate(
238 { id: entry.id, archived: true },
239 { onSuccess: () => toast("success", "Archived", entry.title || "Untitled") }
240 );
241 } },
242
243
244 { id: "trash", label: "Move to trash", icon: Trash2, destructive: true, onClick: () => { setCtxOpen(false); remove(); } },
245
246
247 { id: "export", label: "Export as Markdown…", icon: Package, onClick: () => void exportMarkdown() },
248
249
250
251
252 { id: "share", label: "Share…", icon: Share2, onClick: () => { setCtxOpen(false); window.open(`/w/${workspaceSlug}/page/${entry.id}/share`, "_self"); } },
253
254
255 { id: "permissions", label: "Permissions…", icon: KeyRound, onClick: () => { setCtxOpen(false); window.open(`/w/${workspaceSlug}/page/${entry.id}/share`, "_self"); } }
256 ];
257
258
259
260
261
262 const gated = contextItems.filter((item) => {
263 if (isGuest && !["open", "open-tab", "copy-link", "favorite", "export", "share"].includes(item.id)) return false;
264 if (!isOwner && item.id === "permissions") return false;
265 return true;
266 });
267
268 return (
269 <li
270 ref={(node) => {
271 rowRef.current = node;
272 setNodeRef(node);
273 }}
274 data-testid={`sidebar-page-${entry.id}`}
275 className="relative list-none"
276 >
277 <div
278 onContextMenu={(e) => {
279 e.preventDefault();
280 setCtxPos({ x: e.clientX, y: e.clientY });
281 setCtxOpen(true);
282 }}
283 className={cn(
284 "group/row flex items-center gap-1 rounded-md py-1 pr-1 text-sm hover:bg-accent",
285 isDragging && "opacity-40"
286 )}
287 style={{
288 transform: CSS.Transform.toString(transform),
289 transition,
290 paddingLeft: `${depth * 12 + 4}px`
291 }}
292 >
293 {}
295 <button
296 type="button"
297 role="checkbox"
298 aria-checked={selected}
299 aria-label={`Select ${entry.title || "page"} for bulk actions`}
300 data-testid={`tree-select-${entry.id}`}
301 onClick={() => togglePageSelect(workspaceId, entry.id)}
302 className={cn(
303 "flex size-4 shrink-0 items-center justify-center rounded border text-background",
304 selected ? "border-primary bg-primary" : "border-border/70 hover:border-border",
305 !selected && "invisible group-hover/row:visible"
306 )}
307 >
308 {selected ? <Check className="size-3" aria-hidden /> : null}
309 </button>
310
311 {}
312 <button
313 type="button"
314 className="hidden cursor-grab rounded p-0.5 text-muted-foreground hover:text-foreground active:cursor-grabbing group-hover/row:block"
315 aria-label={`Drag to reorder ${entry.title || "page"}`}
316 {...attributes}
317 {...listeners}
318 >
319 <GripVertical className="size-3.5" aria-hidden />
320 </button>
321
322 {}
323 <button
324 type="button"
325 onClick={() => setExpanded((v) => !v)}
326 className={cn(
327 "flex size-4 items-center justify-center rounded text-muted-foreground hover:text-foreground",
328 !hasChildren && "invisible"
329 )}
330 aria-label={expanded ? "Collapse" : "Expand"}
331 aria-expanded={expanded}
332 >
333 <ChevronRight className={cn("size-3.5 transition-transform", expanded && "rotate-90")} aria-hidden />
334 </button>
335
336 {}
338 <span className="flex size-4 shrink-0 items-center justify-center">{icon}</span>
339
340 {renaming ? (
341 <input
342 autoFocus
343 defaultValue={entry.title}
344 onBlur={(e) => commitRename(e.target.value)}
345 onKeyDown={(e) => {
346 if (e.key === "Enter") commitRename((e.target as HTMLInputElement).value);
347 if (e.key === "Escape") setRenaming(false);
348 }}
349 className="min-w-0 flex-1 rounded border border-input bg-background px-1.5 py-0.5 text-sm"
350 aria-label="Rename page"
351 />
352 ) : href ? (
353 <Link
354 href={href}
355 onDoubleClick={() => setRenaming(true)}
356
357
358 className="min-w-0 flex-1 truncate text-foreground/90 hover:text-foreground"
359 title={entry.title || "Untitled"}
360 >
361 {entry.title || "Untitled"}
362 </Link>
363 ) : (
364 <button
365 type="button"
366 onDoubleClick={() => setRenaming(true)}
367 className="min-w-0 flex-1 truncate text-left text-muted-foreground"
368 >
369 {entry.title || "Untitled"}
370 </button>
371 )}
372
373 {}
376 <span className="hidden items-center gap-0.5 group-hover/row:flex">
377 <TreeAction title="Add sub-page" onClick={addSubPage}>
378 <File className="size-3.5" aria-hidden />
379 </TreeAction>
380 <TreeAction title="Duplicate" onClick={duplicate}>
381 <Copy className="size-3.5" aria-hidden />
382 </TreeAction>
383 <TreeAction
384 title={entry.type === "page" ? "Favorite" : "Favorite unavailable"}
385 onClick={() => favorite.mutate(entry.id)}
386 >
387 <Star className="size-3.5" aria-hidden />
388 </TreeAction>
389 <span className="relative">
390 {}
392 <TreeAction
393 title="More"
394 testId={`page-menu-kebab-${entry.id}`}
395 onClick={(e) => {
396 const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
397 setCtxPos({ x: r.right, y: r.bottom });
398 setCtxOpen((v) => !v);
399 }}
400 >
401 <MoreHorizontal className="size-3.5" aria-hidden />
402 </TreeAction>
403 </span>
404 </span>
405 </div>
406
407 {}
409 {ctxOpen ? (
410 <div
411 data-testid="page-context-menu"
412 role="menu"
413 aria-label={`Page menu: ${entry.title || "Untitled"}`}
414 className="fixed z-[75] w-52 rounded-md border border-border bg-popover p-1 shadow-xl"
415 style={{
416 left: Math.max(4, Math.min(ctxPos.x, window.innerWidth - 216)),
417 top: Math.max(4, Math.min(ctxPos.y, window.innerHeight - (gated.length * 32 + 16)))
418 }}
419 onMouseDown={(e) => e.stopPropagation()}
420 >
421 {gated.map((item) => (
422 <button
423 key={item.id}
424 type="button"
425 role="menuitem"
426 data-testid={`context-menu-${item.id}`}
427 aria-disabled={item.disabled || undefined}
428 title={item.disabled ? "Not available in this phase yet" : undefined}
429 disabled={item.disabled}
430 onClick={item.onClick}
431 className={cn(
432 "flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent",
433 item.disabled && "cursor-not-allowed opacity-50",
434 item.destructive && "text-red-600 hover:text-red-700"
435 )}
436 >
437 <item.icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
438 {item.label}
439 </button>
440 ))}
441 </div>
442 ) : null}
443
444 {}
445 {expanded && hasChildren ? (
446 <ul className="space-y-px">
447 {childrenQuery.isLoading && !entry.children ? (
448 <li
449 className="py-1 text-xs text-muted-foreground"
450 style={{ paddingLeft: `${(depth + 1) * 12 + 20}px` }}
451 >
452 Loading…
453 </li>
454 ) : (
455 children.map((child) => (
456 <PageTreeNode
457 key={child.id}
458 entry={child}
459 depth={depth + 1}
460 workspaceSlug={workspaceSlug}
461 workspaceId={workspaceId}
462 autoExpand={autoExpand}
463 />
464 ))
465 )}
466 </ul>
467 ) : null}
468 </li>
469 );
470}
471
472
473export function PageTreeList({
474 entries,
475 depth,
476 workspaceSlug,
477 workspaceId,
478 autoExpand
479}: {
480 entries: PageTreeEntry[];
481 depth: number;
482 workspaceSlug: string;
483 workspaceId: string;
484 autoExpand?: Set<string>;
485}) {
486 return (
487 <SortableContext items={entries.map((e) => e.id)} strategy={verticalListSortingStrategy}>
488 <ul className="space-y-px">
489 {entries.map((entry) => (
490 <PageTreeNode
491 key={entry.id}
492 entry={entry}
493 depth={depth}
494 workspaceSlug={workspaceSlug}
495 workspaceId={workspaceId}
496 autoExpand={autoExpand}
497 />
498 ))}
499 </ul>
500 </SortableContext>
501 );
502}
503
504function TreeAction({
505 title,
506 testId,
507 onClick,
508 children
509}: {
510 title: string;
511 testId?: string;
512 onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
513 children: React.ReactNode;
514}) {
515 return (
516 <button
517 type="button"
518 title={title}
519 aria-label={title}
520 data-testid={testId}
521 onClick={onClick}
522 className="rounded p-0.5 text-muted-foreground hover:bg-background hover:text-foreground"
523 >
524 {children}
525 </button>
526 );
527}
528
529
543export function pageRowIcon(
544 entry: Pick<PageTreeEntry, "type" | "icon">,
545 hasChildren: boolean,
546 expanded: boolean
547): ReactNode {
548 if (entry.type === "database") return <Database className="size-4 text-blue-600" aria-hidden />;
549 if (entry.type === "trash") return <Trash2 className="size-4 text-muted-foreground" aria-hidden />;
550 if (entry.icon) return <span className="text-sm leading-none" aria-hidden>{entry.icon}</span>;
551 if (hasChildren) {
552 return expanded ? (
553 <FolderOpen className="size-4 text-amber-500/80" aria-hidden />
554 ) : (
555 <Folder className="size-4 text-amber-500/80" aria-hidden />
556 );
557 }
558 return <FileText className="size-4 text-muted-foreground" aria-hidden />;
559}
560