'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 (
)
}
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 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 () => {
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({ title }) })
if (!res.ok) { toast.error('Could not create page'); return }
const p = await res.json()
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 viewEntries = isAdmin ? [...viewItems, { href: '/admin', label: 'Admin', icon: Shield }] : viewItems
return (
)
}