"use client"; import { useEffect, useRef, useState, type ReactNode } from "react"; import Link from "next/link"; import { useSortable, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { Archive, Check, ChevronRight, Copy, Database, ExternalLink, File, FileText, Folder, FolderOpen, GripVertical, KeyRound, Link as LinkIcon, MoreHorizontal, Package, Pencil, Share2, Star, Trash2 } from "lucide-react"; import { useDeletePage, usePageTree, useToggleFavorite, useUpdatePage } from "@/hooks/use-pages"; import { useWorkspace } from "@/hooks/use-workspace"; import { pageApi } from "@/lib/api"; import { toast, useUiStore } from "@/stores/ui-store"; import { cn } from "@/lib/utils"; import type { PageTreeEntry } from "@/lib/types"; interface PageTreeNodeProps { entry: PageTreeEntry; depth: number; workspaceSlug: string; workspaceId: string; /** T-192: ids the tree should auto-expand (the ACTIVE page's ancestor * chain) so a freshly opened child page is visible in the sidebar — * the tree is lazy + collapsed-by-default, so without this the child * row never renders after navigating into it (the nested E2E). */ autoExpand?: Set; } /** * Recursive, lazy page tree row: type icon, inline rename (double-click the * title), the TASK-0045 page context menu (right-click the row or the hover * kebab — `page-context-menu` / `context-menu-*` testids), expand caret * (children load on first expand), hover actions (add sub-page, duplicate, * favorite, more) and a dnd-kit drag handle inside the shell's DndContext * (the shell's onDragEnd is a no-op — tree-row reordering is not yet a * product feature; the handle exists for cross-surface DnD parity with * block rows). */ export function PageTreeNode({ entry, depth, workspaceSlug, workspaceId, autoExpand }: PageTreeNodeProps) { // Auto-expand (T-192) seeds the initial state AND force-expands on a later // set (the node can mount after its ancestor chain resolves, or the active // page can change under a mounted node). It only ever forces OPEN — a // manual collapse afterwards sticks (shouldAuto stays true, no re-fire). const shouldAuto = autoExpand?.has(entry.id) ?? false; const [expanded, setExpanded] = useState(shouldAuto); useEffect(() => { if (shouldAuto) setExpanded(true); }, [shouldAuto]); const [renaming, setRenaming] = useState(false); const [ctxOpen, setCtxOpen] = useState(false); // Cursor anchor for the fixed-position menu (viewport-clamped below). const [ctxPos, setCtxPos] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); const rowRef = useRef(null); // Lazy children: loaded on first expand (or pre-seeded by the server). const childrenQuery = usePageTree(workspaceId, entry.id); const children = entry.children ?? childrenQuery.data ?? []; // `hasChildren` must reflect the LOADED children (this node's own query), not // only the stale entry-level hint — a child created AFTER the parent was // already in the tree otherwise never flips this to true, so the child row // never renders even when the node is expanded (T-192 nested E2E). const hasChildren = Boolean(children.length > 0 || entry.hasMore || (expanded && childrenQuery.isLoading)); const updatePage = useUpdatePage(workspaceId); const deletePage = useDeletePage(workspaceId); const favorite = useToggleFavorite(); const setMove = useUiStore((s) => s.setMove); // T-139: bulk multi-select. `selected` is true only while the current // selection belongs to THIS workspace and includes this page. const pageSelection = useUiStore((s) => s.pageSelection); const togglePageSelect = useUiStore((s) => s.togglePageSelect); const selected = pageSelection.workspaceId === workspaceId && pageSelection.ids.includes(entry.id); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: entry.id, data: { type: "page", parentId: entry.parentId } }); useEffect(() => { if (!ctxOpen) return; const onClick = (e: MouseEvent) => { if (rowRef.current && !rowRef.current.contains(e.target as Node)) { setCtxOpen(false); } }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { setCtxOpen(false); } }; document.addEventListener("mousedown", onClick); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onClick); document.removeEventListener("keydown", onKey); }; }, [ctxOpen]); const icon = pageRowIcon(entry, hasChildren, expanded); const href = entry.type === "page" || entry.type === "database" ? `/w/${workspaceSlug}/page/${entry.id}` : undefined; const commitRename = (value: string) => { setRenaming(false); const title = value.trim(); if (!title || title === entry.title) return; updatePage.mutate( { id: entry.id, title }, { onSuccess: () => toast("success", "Renamed", title), onError: (e: Error) => toast("error", "Rename failed", e.message) } ); }; const addSubPage = () => { // App-level event; the sidebar (owner of the tree root) performs the // create + router push, keeping the new page appended under the parent. window.dispatchEvent( new CustomEvent("factory:new-page", { detail: { parentId: entry.id } }) ); }; const duplicate = () => { window.dispatchEvent( new CustomEvent("factory:page-action", { detail: { action: "duplicate", pageId: entry.id, parentId: entry.parentId } }) ); }; const remove = () => { deletePage.mutate(entry.id, { onSuccess: () => toast("info", "Moved to trash", entry.title || "Untitled"), onError: (e: Error) => toast("error", "Delete failed", e.message) }); }; // TASK-0045: the page context menu (right-click on tree nodes + the header // "⋯" overflow) with the 12 actions, role-gated by the caller's workspace // role. Item testids follow tests/README.md §Selector convention // (`context-menu-*`). Per TASK-0045 the menu HIDES ungated actions // entirely (GUEST 7 / MEMBER 11 / OWNER 12); the Phase 4/13 items // (Share, Export) are visible-disabled stubs — the standard Phase 2 stub // pattern. Permissions opens the TASK-0041 dialog (owner/admin only). const { data: workspace } = useWorkspace(workspaceSlug); const role = (workspace?.role ?? "").toLowerCase(); const isGuest = role === "guest"; const isOwner = role === "owner" || role === "admin"; const exportMarkdown = async () => { setCtxOpen(false); try { const res = await pageApi.exportMarkdown(entry.id); const text = await res.text(); const m = /filename="?([^";]+)"?/.exec(res.headers.get("content-disposition") ?? ""); const blob = new Blob([text], { type: "text/markdown;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = m?.[1] || `${entry.id}.md`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); toast("success", "Exported as Markdown", a.download); } catch (e) { toast("error", "Export failed", e instanceof Error ? e.message : String(e)); } }; const contextItems: Array<{ id: string; label: string; icon: typeof File; onClick: () => void; disabled?: boolean; destructive?: boolean; }> = [ { id: "open", label: "Open", icon: File, onClick: () => { setCtxOpen(false); window.location.href = href ?? "/"; } }, { id: "open-tab", label: "Open in new tab", icon: ExternalLink, disabled: !href, onClick: () => href && window.open(href, "_blank", "noopener") }, { id: "duplicate", label: "Duplicate", icon: Copy, onClick: () => { setCtxOpen(false); duplicate(); } }, { id: "rename", label: "Rename", icon: Pencil, onClick: () => { setCtxOpen(false); setRenaming(true); } }, { id: "move", label: "Move to…", icon: FolderOpen, onClick: () => { setCtxOpen(false); setMove(true, { pageId: entry.id, workspaceId, title: entry.title || "Untitled" }); } }, { id: "copy-link", label: "Copy link", icon: LinkIcon, onClick: async () => { setCtxOpen(false); if (!href) return; const url = new URL(href, window.location.origin).toString(); try { await navigator.clipboard.writeText(url); toast("success", "Link copied"); } catch { toast("error", "Could not copy link"); } } }, { id: "favorite", label: "Favorite", icon: Star, onClick: () => { setCtxOpen(false); favorite.mutate(entry.id); } }, // TASK-0035 archive endpoint (hidden from GUEST — no EDIT). { id: "archive", label: "Archive", icon: Archive, onClick: () => { setCtxOpen(false); updatePage.mutate( { id: entry.id, archived: true }, { onSuccess: () => toast("success", "Archived", entry.title || "Untitled") } ); } }, // TASK-0039: Delete moves the page to the workspace trash (soft delete; // restore lives in the Trash view). { id: "trash", label: "Move to trash", icon: Trash2, destructive: true, onClick: () => { setCtxOpen(false); remove(); } }, // Export: wired (markdown endpoint exists); stubbed per TASK-0045's // Phase-13 format list (PDF/Word/etc. — the menu item stays). { id: "export", label: "Export as Markdown…", icon: Package, onClick: () => void exportMarkdown() }, // Share: TASK-0045 labels this a "Phase 4 UI stub", but the app already // ships a working share route (the pre-T-192 hover kebab opened it) — // wiring it keeps that functionality (rule 16); the spec-phase label is // recorded as a conflict in FEATURE_TRACKER (T-192). { id: "share", label: "Share…", icon: Share2, onClick: () => { setCtxOpen(false); window.open(`/w/${workspaceSlug}/page/${entry.id}/share`, "_self"); } }, // TASK-0041 permissions dialog — no dialog component yet; the share // route is the closest existing surface (recorded in FEATURE_TRACKER). { id: "permissions", label: "Permissions…", icon: KeyRound, onClick: () => { setCtxOpen(false); window.open(`/w/${workspaceSlug}/page/${entry.id}/share`, "_self"); } } ]; // Role gating: HIDE ungated actions (TASK-0045 criterion 1: the counts are // asserted per role). GUEST = VIEW: Open, Open in tab, Copy link, // Favorite + the 2 stubs = 7. MEMBER = EDIT: all but Permissions = 11. // OWNER/ADMIN: all 12. const gated = contextItems.filter((item) => { if (isGuest && !["open", "open-tab", "copy-link", "favorite", "export", "share"].includes(item.id)) return false; if (!isOwner && item.id === "permissions") return false; return true; }); return (
  • { rowRef.current = node; setNodeRef(node); }} data-testid={`sidebar-page-${entry.id}`} className="relative list-none" >
    { e.preventDefault(); setCtxPos({ x: e.clientX, y: e.clientY }); setCtxOpen(true); }} className={cn( "group/row flex items-center gap-1 rounded-md py-1 pr-1 text-sm hover:bg-accent", isDragging && "opacity-40" )} style={{ transform: CSS.Transform.toString(transform), transition, paddingLeft: `${depth * 12 + 4}px` }} > {/* T-139: bulk multi-select checkbox. Hidden (visibility, so the row width is stable) unless the row is hovered or already selected. */} {/* Drag handle */} {/* Expand caret */} {/* Type icon — folder (open/closed) for pages with children, doc for leaves, database/trash/emoji otherwise (see pageRowIcon). */} {icon} {renaming ? ( commitRename(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") commitRename((e.target as HTMLInputElement).value); if (e.key === "Escape") setRenaming(false); }} className="min-w-0 flex-1 rounded border border-input bg-background px-1.5 py-0.5 text-sm" aria-label="Rename page" /> ) : href ? ( setRenaming(true)} // TASK-0045: right-click opens the page context menu (the row's // onContextMenu does the work); double-click renames in place. className="min-w-0 flex-1 truncate text-foreground/90 hover:text-foreground" title={entry.title || "Untitled"} > {entry.title || "Untitled"} ) : ( )} {/* Hover actions. The old 4-item hover kebab is superseded by the TASK-0045 context menu (same Share/Move/Duplicate/Trash actions + more) — the kebab below opens THAT menu, at this anchor. */} favorite.mutate(entry.id)} > {/* Touch parity (TASK-0045 A11y): the kebab is the keyboard/ touch path to the same context menu (right-click = mouse). */} { const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); setCtxPos({ x: r.right, y: r.bottom }); setCtxOpen((v) => !v); }} >
    {/* TASK-0045 page context menu — fixed at the cursor (or kebab) with viewport clamping; `gated` = role-filtered items (see above). */} {ctxOpen ? (
    e.stopPropagation()} > {gated.map((item) => ( ))}
    ) : null} {/* Children (lazy) */} {expanded && hasChildren ? (
      {childrenQuery.isLoading && !entry.children ? (
    • Loading…
    • ) : ( children.map((child) => ( )) )}
    ) : null}
  • ); } /** Sortable wrapper for a sibling list at one tree level. */ export function PageTreeList({ entries, depth, workspaceSlug, workspaceId, autoExpand }: { entries: PageTreeEntry[]; depth: number; workspaceSlug: string; workspaceId: string; autoExpand?: Set; }) { return ( e.id)} strategy={verticalListSortingStrategy}>
      {entries.map((entry) => ( ))}
    ); } function TreeAction({ title, testId, onClick, children }: { title: string; testId?: string; onClick: (e: React.MouseEvent) => void; children: React.ReactNode; }) { return ( ); } /** * Icon for a page-tree row. Priority: * 1. database → Database (a database page is never a folder) * 2. trash → Trash2 * 3. user-set emoji/icon → the emoji span (always wins over the type) * 4. a PAGE that HAS children → folder. A page with children IS a folder * (Page.parentId nesting; no separate folder entity exists). Render it * as one: open/closed folder icon tied to the expand state so groups * read visually at a glance. * 5. otherwise → plain document (FileText). * * Exported (pure) so the folder/leaf rule is unit-testable without rendering * the whole dnd-kit-coupled row. */ export function pageRowIcon( entry: Pick, hasChildren: boolean, expanded: boolean ): ReactNode { if (entry.type === "database") return ; if (entry.type === "trash") return ; if (entry.icon) return {entry.icon}; if (hasChildren) { return expanded ? ( ) : ( ); } return ; }