release gate / check (push) Failing after 6m43s
High severity: - wire TRUSTED_PROXY through Compose/deploy/docs and warn at web startup - bucket public rate limits on effective client IP, add per-bookmark budget, and forward the signed browser identity from the share page - disable first-run bootstrap in server deployments and document seeding - serialize last-admin demotion in one advisory-locked transaction Medium/low: - cap and batch share recipient inserts - add a shared outbound budget for preview/CardDAV/IMAP/signal work - pin CardDAV dials to 443 and bound IMAP logout - fix rightward kanban column drags and normalize cards on column delete - classify backend outages in auth guards, add request timeouts and an error boundary, remove the dead login route, surface throttling - enforce pinned/unpinned page reorder grouping server-side - avoid preview work for stale bookmark versions; drop duplicate dialog fetch - sequence-guard collection/tag/calendar/quick-find loads - validate property config and fix MULTI_SELECT/DATE filter inputs - document index maintenance; apply same-origin check to sign-out - trim signup names, unique shared dates and kanban keys (migration 010), reset reference cache on identity changes, hide OIDC issuer, return 413 for oversized direct bodies, escape Markdown link destinations - remove dead template files and duplicate test helper Verified with go build/vet, full PostgreSQL race suite, db convergence, tsc, both ESLint configs and a Next production build.
445 lines
20 KiB
TypeScript
445 lines
20 KiB
TypeScript
'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 (
|
|
<aside
|
|
className={cn(
|
|
'flex flex-col border-r bg-[hsl(var(--sidebar))] text-[hsl(var(--sidebar-foreground))] transition-all duration-300 ease-in-out',
|
|
open ? 'w-60' : 'w-16'
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2 px-3 py-3">
|
|
<Link href="/pages" className="flex min-w-0 items-center gap-2">
|
|
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-primary text-primary-foreground">
|
|
<Link2 className="h-3.5 w-3.5" />
|
|
</div>
|
|
{open && <span className="truncate font-display text-sm font-bold tracking-tight">computer</span>}
|
|
</Link>
|
|
</div>
|
|
<Suspense fallback={<div className="flex-1" />}>
|
|
<SidebarNav open={open} pathname={pathname} isAdmin={isAdmin} />
|
|
</Suspense>
|
|
<div className="space-y-0.5 border-t p-2">
|
|
<button
|
|
onClick={onQuickFind}
|
|
title="Quick find (Ctrl/⌘ K)"
|
|
className="flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
|
>
|
|
<Search className="h-4 w-4 shrink-0" />
|
|
{open && <span className="flex-1 text-left">Quick find</span>}
|
|
{open && <kbd className="rounded border bg-background px-1 text-[10px] font-medium text-muted-foreground">⌘K</kbd>}
|
|
</button>
|
|
<Link
|
|
href="/settings"
|
|
className={cn(
|
|
'flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-sm transition-colors',
|
|
pathname === '/settings' ? 'bg-accent font-medium text-accent-foreground' : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
|
)}
|
|
>
|
|
<Settings className="h-4 w-4 shrink-0" />
|
|
{open && <span>Settings</span>}
|
|
</Link>
|
|
<Button variant="ghost" size="icon-sm" onClick={onToggle} className="w-full" aria-label={open ? 'Collapse sidebar' : 'Expand sidebar'}>
|
|
{open ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</aside>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className="group/header flex items-center gap-0.5 rounded-md pr-1 hover:bg-accent/40">
|
|
<button onClick={onToggle} className="flex flex-1 items-center gap-1 px-2 py-1 text-left" aria-expanded={expanded}>
|
|
<ChevronDown className={cn('h-3 w-3 shrink-0 text-muted-foreground transition-transform', !expanded && '-rotate-90')} />
|
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{label}</span>
|
|
</button>
|
|
{action && (
|
|
<button
|
|
onClick={action.onClick}
|
|
title={action.label}
|
|
aria-label={action.label}
|
|
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/header:opacity-100 focus-visible:opacity-100"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathname: string; isAdmin?: boolean }) {
|
|
const router = useRouter()
|
|
const searchParams = useSearchParams()
|
|
const [pages, setPages] = useState<PageSummary[]>([])
|
|
const [pagesExpanded, setPagesExpanded] = usePersistentState('computer:sidebar:pages', true)
|
|
const [viewsExpanded, setViewsExpanded] = usePersistentState('computer:sidebar:views', true)
|
|
const [renamingId, setRenamingId] = useState<string | null>(null)
|
|
const [renameValue, setRenameValue] = useState('')
|
|
const [pageDrop, setPageDrop] = useState<{ id: string; after: boolean } | null>(null)
|
|
const [collapsedPages, setCollapsedPages] = useState<Set<string>>(() => new Set())
|
|
const draggedPageRef = useRef<string | null>(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<string, unknown>) => {
|
|
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()
|
|
}
|
|
}
|
|
|
|
// The source id is passed in from the drop handler (reading the ref inside
|
|
// an event handler) so the compiler does not treat this helper as reading a
|
|
// ref during render.
|
|
const dropPage = (source: string | null, target: PageSummary, after: boolean) => {
|
|
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<string | null, PageSummary[]>()
|
|
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<string>()
|
|
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 (
|
|
<nav className="flex-1 space-y-0.5 overflow-y-auto p-2">
|
|
<SectionHeader label="Pages" open={open} expanded={pagesExpanded} onToggle={() => setPagesExpanded(!pagesExpanded)} action={{ label: 'New page', onClick: () => void createPage() }} />
|
|
{(pagesExpanded || !open) && pageRows.map(({ page: p, depth, hasChildren }) => {
|
|
const isActive = pathname === '/pages' && activePageId === p.id
|
|
if (renamingId === p.id) {
|
|
return (
|
|
<div key={p.id} className="px-1 py-0.5" style={{ paddingLeft: depth * 12 }}>
|
|
<Input
|
|
autoFocus
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
onBlur={() => commitRename(p)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') { e.preventDefault(); (e.target as HTMLInputElement).blur() }
|
|
if (e.key === 'Escape') setRenamingId(null)
|
|
}}
|
|
className="h-7 text-sm"
|
|
aria-label={`Rename page ${p.title}`}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
return (
|
|
<div
|
|
key={p.id}
|
|
data-page-id={p.id}
|
|
onDragOver={(e) => {
|
|
// Event handler, not render: reading the drag source is safe.
|
|
// eslint-disable-next-line react-hooks/refs
|
|
if (!draggedPageRef.current || draggedPageRef.current === p.id) return
|
|
e.preventDefault()
|
|
e.dataTransfer.dropEffect = 'move'
|
|
const rect = e.currentTarget.getBoundingClientRect()
|
|
setPageDrop({ id: p.id, after: e.clientY > rect.top + rect.height / 2 })
|
|
}}
|
|
onDragLeave={(e) => {
|
|
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setPageDrop(null)
|
|
}}
|
|
onDrop={(e) => {
|
|
e.preventDefault()
|
|
const rect = e.currentTarget.getBoundingClientRect()
|
|
// Event handler, not render: reading the drag source is safe.
|
|
// eslint-disable-next-line react-hooks/refs
|
|
dropPage(draggedPageRef.current, p, e.clientY > rect.top + rect.height / 2)
|
|
}}
|
|
style={{ paddingLeft: depth * 12 }}
|
|
className={cn(
|
|
'group/row flex items-center rounded-lg pr-1 transition-colors',
|
|
isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50',
|
|
pageDrop?.id === p.id && 'ring-1 ring-primary/50'
|
|
)}
|
|
>
|
|
{open && (hasChildren ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleCollapsed(p.id)}
|
|
aria-label={collapsedPages.has(p.id) ? `Expand ${p.title}` : `Collapse ${p.title}`}
|
|
aria-expanded={!collapsedPages.has(p.id)}
|
|
className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<ChevronDown className={cn('h-3 w-3 transition-transform', collapsedPages.has(p.id) && '-rotate-90')} />
|
|
</button>
|
|
) : <span className="w-4 shrink-0" aria-hidden />)}
|
|
{open && (
|
|
<span
|
|
data-page-drag-handle
|
|
draggable
|
|
onDragStart={(e) => {
|
|
draggedPageRef.current = p.id
|
|
e.dataTransfer.effectAllowed = 'move'
|
|
e.dataTransfer.setData('text/plain', p.id)
|
|
}}
|
|
onDragEnd={() => { draggedPageRef.current = null; setPageDrop(null) }}
|
|
className="ml-1 shrink-0 cursor-grab text-muted-foreground/40 opacity-0 transition-opacity hover:text-muted-foreground active:cursor-grabbing group-hover/row:opacity-100 focus-visible:opacity-100"
|
|
title="Drag to reorder page"
|
|
aria-label={`Drag page ${p.title}`}
|
|
>
|
|
<GripVertical className="h-3.5 w-3.5" />
|
|
</span>
|
|
)}
|
|
<Link href={`/pages?id=${p.id}`} className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-sm">
|
|
{p.pinned ? <Pin className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : <FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
|
|
{(open || isActive) && <span className={cn('truncate', isActive ? 'font-medium' : 'text-muted-foreground')}>{p.title}</span>}
|
|
</Link>
|
|
{open && (
|
|
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover/row:opacity-100">
|
|
<button
|
|
onClick={() => void createPage(p.id)}
|
|
title="New subpage"
|
|
aria-label={`New subpage under ${p.title}`}
|
|
className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button title="Page actions" aria-label={`Actions for ${p.title}`} className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground">
|
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-44">
|
|
<DropdownMenuItem onClick={() => patchPage(p.id, { pinned: !p.pinned })}>
|
|
{p.pinned ? <PinOff className="mr-2 h-3.5 w-3.5" /> : <Pin className="mr-2 h-3.5 w-3.5" />}
|
|
{p.pinned ? 'Unpin' : 'Pin to sidebar'}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => startRename(p)}>
|
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
|
Rename
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => void createPage(p.id)}>
|
|
<Plus className="mr-2 h-3.5 w-3.5" />
|
|
New subpage
|
|
</DropdownMenuItem>
|
|
{p.parentId && (
|
|
<DropdownMenuItem onClick={() => patchPage(p.id, { parentId: null })}>
|
|
<ChevronRight className="mr-2 h-3.5 w-3.5" />
|
|
Move to top level
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => removePage(p)}>
|
|
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
{open && pagesExpanded && pages.length === 0 && (
|
|
<p className="px-3 py-1 text-xs text-muted-foreground/70">No pages yet</p>
|
|
)}
|
|
|
|
<div className="pt-2">
|
|
<SectionHeader label="Views" open={open} expanded={viewsExpanded} onToggle={() => setViewsExpanded(!viewsExpanded)} />
|
|
{(viewsExpanded || !open) && viewEntries.map((item) => {
|
|
const isActive = pathname === item.href || pathname?.startsWith(item.href + '/')
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors',
|
|
isActive ? 'bg-accent font-medium text-accent-foreground' : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
|
)}
|
|
>
|
|
<item.icon className="h-4 w-4 shrink-0" />
|
|
{open && <span className="truncate">{item.label}</span>}
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
</nav>
|
|
)
|
|
}
|