'use client' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { Suspense, useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' import { cn } from '@/lib/utils' import { Calendar, Tags, Users, Kanban, Link2, Settings, ChevronLeft, ChevronRight, ChevronDown, FileText, Pin, PinOff, Plus, Shield, Search, MoreHorizontal, Trash2, Pencil, Layers, GripVertical, } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { toast } from 'sonner' import { PAGES_CHANGED, nextUntitledTitle, notifyPagesChanged, type PageSummary } from '@/lib/pages' import { reorderPages } from '@/lib/page-order' // Screens below are filters/views over the same bookmark collection, not // separate data types. Pages are the primary interface objects. const viewItems = [ { href: '/bookmarks', label: 'All Bookmarks', icon: Link2 }, { href: '/calendar', label: 'Calendar', icon: Calendar }, { href: '/tags', label: 'Tags', icon: Tags }, { href: '/kanban', label: 'Kanban', icon: Kanban }, { href: '/collections', label: 'Collections', icon: Layers }, { href: '/contacts', label: 'Contacts', icon: Users }, ] // Section collapse state persists per browser. useSyncExternalStore keeps it // hydration-safe (server snapshot uses the default; the client snapshot reads // localStorage after mount) without setState inside an effect. const SECTION_STORE_EVENT = 'computer:sidebar-sections' function readSection(key: string, fallback: boolean): boolean { try { const stored = window.localStorage.getItem(key) return stored === '0' || stored === '1' ? stored === '1' : fallback } catch { return fallback } } function usePersistentState(key: string, initial: boolean) { const subscribe = useCallback((onChange: () => void) => { window.addEventListener('storage', onChange) window.addEventListener(SECTION_STORE_EVENT, onChange) return () => { window.removeEventListener('storage', onChange) window.removeEventListener(SECTION_STORE_EVENT, onChange) } }, []) const getSnapshot = useCallback(() => readSection(key, initial), [key, initial]) const getServerSnapshot = useCallback(() => initial, [initial]) const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) const update = useCallback((next: boolean) => { try { window.localStorage.setItem(key, next ? '1' : '0') } catch { /* non-fatal */ } window.dispatchEvent(new Event(SECTION_STORE_EVENT)) }, [key]) return [value, update] as const } interface SidebarProps { open: boolean onToggle: () => void onQuickFind: () => void pathname: string isAdmin?: boolean } export function Sidebar({ open, onToggle, onQuickFind, pathname, isAdmin = false }: SidebarProps) { return ( ) } function SectionHeader({ label, open, expanded, onToggle, action }: { label: string open: boolean expanded: boolean onToggle: () => void action?: { label: string; onClick: () => void } }) { if (!open) return null return (
{action && ( )}
) } function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathname: string; isAdmin?: boolean }) { const router = useRouter() const searchParams = useSearchParams() const [pages, setPages] = useState([]) const [pagesExpanded, setPagesExpanded] = usePersistentState('computer:sidebar:pages', true) const [viewsExpanded, setViewsExpanded] = usePersistentState('computer:sidebar:views', true) const [renamingId, setRenamingId] = useState(null) const [renameValue, setRenameValue] = useState('') const [pageDrop, setPageDrop] = useState<{ id: string; after: boolean } | null>(null) const [collapsedPages, setCollapsedPages] = useState>(() => new Set()) const draggedPageRef = useRef(null) const activePageId = pathname === '/pages' ? searchParams.get('id') : null const load = useCallback(() => { fetch('/api/pages') .then((r) => (r.ok ? r.json() : [])) .then((d: PageSummary[]) => setPages(d ?? [])) .catch((e) => console.error('Failed to load pages', e)) }, []) useEffect(() => { load() window.addEventListener(PAGES_CHANGED, load) return () => window.removeEventListener(PAGES_CHANGED, load) }, [load]) const createPage = async (parentId?: string | null) => { const title = nextUntitledTitle(pages.map((p) => p.title)) try { const res = await fetch('/api/pages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(parentId ? { title, parentId } : { title }), }) if (!res.ok) { toast.error('Could not create page'); return } const p = await res.json() if (parentId) setCollapsedPages((current) => { const next = new Set(current); next.delete(parentId); return next }) notifyPagesChanged() router.push(`/pages?id=${p.id}`) } catch { toast.error('Could not create page') } } const patchPage = async (id: string, body: Record) => { try { const res = await fetch(`/api/pages/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) if (!res.ok) { toast.error('Could not update page'); return } load() notifyPagesChanged() } catch { toast.error('Could not update page') } } const startRename = (p: PageSummary) => { setRenamingId(p.id) setRenameValue(p.title) } const commitRename = async (p: PageSummary) => { const t = renameValue.trim() setRenamingId(null) if (!t || t === p.title) return await patchPage(p.id, { title: t }) } const removePage = async (p: PageSummary) => { if (!confirm(`Delete page "${p.title}"? Bookmarks are kept.`)) return try { const res = await fetch(`/api/pages/${p.id}`, { method: 'DELETE' }) if (!res.ok) { toast.error('Could not delete page'); return } load() notifyPagesChanged() } catch { toast.error('Could not delete page') } } const savePageOrder = async (next: PageSummary[]) => { setPages(next) try { const res = await fetch('/api/pages', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order: next.map((p) => p.id) }), }) if (!res.ok) { toast.error('Could not reorder pages'); load(); return } notifyPagesChanged() } catch { toast.error('Could not reorder pages') load() } } const dropPage = (target: PageSummary, after: boolean) => { const source = draggedPageRef.current draggedPageRef.current = null setPageDrop(null) if (!source) return const next = reorderPages(pages, source, target.id, after) if (next === 'blocked') { toast.error('Pinned and unpinned pages keep separate groups'); return } if (next) void savePageOrder(next) } const toggleCollapsed = (id: string) => { setCollapsedPages((current) => { const next = new Set(current) if (next.has(id)) next.delete(id) else next.add(id) return next }) } // Flatten the owner's page tree for display. The server already orders the // list (pinned first, then position); preserving that order per parent keeps // pinned pages predictable while nested children follow their parent. const pageRows: { page: PageSummary; depth: number; hasChildren: boolean }[] = [] { const known = new Set(pages.map((p) => p.id)) const byParent = new Map() for (const p of pages) { const key = p.parentId && known.has(p.parentId) ? p.parentId : null const list = byParent.get(key) ?? [] list.push(p) byParent.set(key, list) } const visited = new Set() const visit = (parent: string | null, depth: number) => { for (const p of byParent.get(parent) ?? []) { if (visited.has(p.id)) continue visited.add(p.id) const children = byParent.get(p.id) ?? [] pageRows.push({ page: p, depth, hasChildren: children.length > 0 }) if (!collapsedPages.has(p.id)) visit(p.id, depth + 1) } } visit(null, 0) // Defensive: a corrupt/cyclic pre-existing hierarchy must still render // every page once rather than disappear from the sidebar. for (const p of pages) { if (!visited.has(p.id)) pageRows.push({ page: p, depth: 0, hasChildren: (byParent.get(p.id) ?? []).length > 0 }) } } const viewEntries = isAdmin ? [...viewItems, { href: '/admin', label: 'Admin', icon: Shield }] : viewItems return ( ) }