'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 },
{ href: '/trash', label: 'Trash', icon: Trash2 },
]
// 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 (
)
}
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(`Move page "${p.title}" to the trash? Bookmarks are kept and it can be restored.`)) 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 (
)
}