/** * Logos sidebar: one-level page/subpage hierarchy. Create, rename, delete, reorder. */ import React, { useCallback, useEffect, useRef, useState } from 'react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { ChevronDown, ChevronRight, FileText, MoreHorizontal, Pencil, Plus, Trash2, ArrowUp, ArrowDown, } from 'lucide-react' import type { LogosPageId, LogosPageMeta } from '../state/recollectionStore' import { cn } from '@/lib/utils' const DEFAULT_PAGE_TITLE = 'Untitled' function generatePageId(): LogosPageId { return `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` } function sortByPosition(a: LogosPageMeta, b: LogosPageMeta) { return a.position - b.position } export type LogosSidebarProps = { recollectionId: string tree: LogosPageMeta[] onTreeChange: (tree: LogosPageMeta[]) => void activePageId: LogosPageId | null onSelectPage: (id: LogosPageId) => void onDeletePage?: (pageId: LogosPageId) => void } export function LogosSidebar({ recollectionId, tree, onTreeChange, activePageId, onSelectPage, onDeletePage, }: LogosSidebarProps) { const [expandedPages, setExpandedPages] = useState>(new Set()) const expandedInitializedRef = useRef(false) const [editingId, setEditingId] = useState(null) const [editTitle, setEditTitle] = useState('') const [deleteTarget, setDeleteTarget] = useState(null) const pages = tree.filter((p) => p.parentId === null).sort(sortByPosition) // Default: expand parent pages that have subpages so children are visible on first load. useEffect(() => { if (tree.length === 0 || expandedInitializedRef.current) return expandedInitializedRef.current = true const parentIdsWithChildren = tree .filter((p) => p.parentId === null && tree.some((s) => s.parentId === p.id)) .map((p) => p.id) if (parentIdsWithChildren.length > 0) { setExpandedPages((prev) => { const next = new Set(prev) parentIdsWithChildren.forEach((id) => next.add(id)) return next }) } }, [tree]) const toggleExpanded = useCallback((id: LogosPageId) => { setExpandedPages((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) }, []) const updatePage = useCallback( (id: LogosPageId, patch: Partial>) => { const next = tree.map((p) => (p.id === id ? { ...p, ...patch } : p)) onTreeChange(next) }, [tree, onTreeChange] ) const addPage = useCallback(() => { const maxPos = Math.max(0, ...pages.map((p) => p.position), -1) const newPage: LogosPageMeta = { id: generatePageId(), title: DEFAULT_PAGE_TITLE, parentId: null, position: maxPos + 1, } onTreeChange([...tree, newPage]) setEditingId(newPage.id) setEditTitle(newPage.title) onSelectPage(newPage.id) }, [tree, pages, onTreeChange, onSelectPage]) const addSubpage = useCallback( (parentId: LogosPageId) => { const siblings = tree.filter((p) => p.parentId === parentId).sort(sortByPosition) const maxPos = siblings.length === 0 ? 0 : Math.max(...siblings.map((p) => p.position)) + 1 const newPage: LogosPageMeta = { id: generatePageId(), title: DEFAULT_PAGE_TITLE, parentId, position: maxPos, } onTreeChange([...tree, newPage]) setExpandedPages((prev) => new Set(prev).add(parentId)) setEditingId(newPage.id) setEditTitle(newPage.title) onSelectPage(newPage.id) }, [tree, onTreeChange, onSelectPage] ) const startRename = useCallback((page: LogosPageMeta) => { setEditingId(page.id) setEditTitle(page.title) }, []) const commitRename = useCallback(() => { if (editingId && editTitle.trim()) { updatePage(editingId, { title: editTitle.trim() }) } setEditingId(null) setEditTitle('') }, [editingId, editTitle, updatePage]) const movePage = useCallback( (id: LogosPageId, delta: number) => { const page = tree.find((p) => p.id === id) if (!page) return const siblings = tree .filter((p) => p.parentId === page.parentId) .sort(sortByPosition) const idx = siblings.findIndex((p) => p.id === id) if (idx < 0) return const newIdx = Math.max(0, Math.min(siblings.length - 1, idx + delta)) if (newIdx === idx) return const reordered = siblings.slice() const [removed] = reordered.splice(idx, 1) reordered.splice(newIdx, 0, removed) const withNewPositions = tree.map((p) => { const i = reordered.findIndex((r) => r.id === p.id) return i >= 0 ? { ...p, position: i } : p }) onTreeChange(withNewPositions) }, [tree, onTreeChange] ) const removePage = useCallback( (page: LogosPageMeta) => { const toRemove = [page.id, ...tree.filter((p) => p.parentId === page.id).map((p) => p.id)] onTreeChange(tree.filter((p) => !toRemove.includes(p.id))) toRemove.forEach((id) => onDeletePage?.(id)) if (activePageId && toRemove.includes(activePageId)) { const remaining = tree.filter((p) => !toRemove.includes(p.id)).sort(sortByPosition) const first = remaining[0] if (first) onSelectPage(first.id) } setDeleteTarget(null) }, [tree, activePageId, onTreeChange, onDeletePage, onSelectPage] ) const subpages = (parentId: LogosPageId) => tree.filter((p) => p.parentId === parentId).sort(sortByPosition) const isPage = (p: LogosPageMeta) => p.parentId === null return (
Pages
{pages.length === 0 ? (
No pages yet.
) : (
    {pages.map((page) => { const subs = subpages(page.id) const expanded = expandedPages.has(page.id) const isActive = activePageId === page.id return (
  • startRename(page)} onSelect={() => onSelectPage(page.id)} onExpandToggle={() => toggleExpanded(page.id)} expanded={expanded} hasSubpages={subs.length > 0} onAddSubpage={() => addSubpage(page.id)} onMoveUp={() => movePage(page.id, -1)} onMoveDown={() => movePage(page.id, 1)} onDelete={() => setDeleteTarget(page)} canMoveUp={pages.indexOf(page) > 0} canMoveDown={pages.indexOf(page) < pages.length - 1} /> {expanded && (
      {subs.map((sub, i) => { const subActive = activePageId === sub.id const subEditing = editingId === sub.id return (
    • startRename(sub)} onSelect={() => onSelectPage(sub.id)} onAddSubpage={undefined} onMoveUp={() => movePage(sub.id, -1)} onMoveDown={() => movePage(sub.id, 1)} onDelete={() => setDeleteTarget(sub)} canMoveUp={i > 0} canMoveDown={i < subs.length - 1} />
    • ) })}
    )}
  • ) })}
)}
!open && setDeleteTarget(null)}> {deleteTarget && isPage(deleteTarget) ? 'Delete page and subpages?' : 'Delete subpage?'} {deleteTarget && (

{isPage(deleteTarget) && subpages(deleteTarget.id).length > 0 ? ( <> "{deleteTarget.title}" and its {subpages(deleteTarget.id).length}{' '} subpage(s) will be permanently removed. ) : ( <> "{deleteTarget.title}" will be permanently removed. )}

)}
) } type PageRowProps = { page: LogosPageMeta isSubpage: boolean isActive: boolean isEditing: boolean editTitle: string onEditTitleChange: (v: string) => void onCommitRename: () => void onStartRename: () => void onSelect: () => void onExpandToggle?: () => void expanded?: boolean hasSubpages?: boolean onAddSubpage?: () => void onMoveUp: () => void onMoveDown: () => void onDelete: () => void canMoveUp: boolean canMoveDown: boolean } function PageRow({ page, isSubpage, isActive, isEditing, editTitle, onEditTitleChange, onCommitRename, onStartRename, onSelect, onExpandToggle, expanded, hasSubpages, onAddSubpage, onMoveUp, onMoveDown, onDelete, canMoveUp, canMoveDown, }: PageRowProps) { const isParentRow = onExpandToggle != null const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') onCommitRename() if (e.key === 'Escape') { onEditTitleChange(page.title) onCommitRename() } } return (
{isParentRow ? ( // VSCode-like "twisty gutter" so labels align nicely. {hasSubpages ? ( ) : ( )} ) : isSubpage ? ( ) : null} {isSubpage && } {isEditing ? ( onEditTitleChange(e.target.value)} onBlur={onCommitRename} onKeyDown={handleKeyDown} autoFocus aria-label="Page title" /> ) : ( )} {!isEditing && ( e.stopPropagation()}> { onStartRename(); onSelect(); }}> Rename {onAddSubpage != null && ( New subpage )} Move up Move down Delete )}
) }