initial commit on Federted servers
release gate / check (push) Failing after 8m35s

This commit is contained in:
Andrew S Erwin
2026-10-19 16:23:55 -05:00
commit b9721ff13f
217 changed files with 31534 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
'use client'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Eye, Shield } from 'lucide-react'
import { Button } from '@/components/ui/button'
type U = { id: string; name: string | null; email: string; role: string; _count?: { bookmarks: number; pages: number; tags: number } }
export function AdminClient({ currentUserId }: { currentUserId: string }) {
const [users, setUsers] = useState<U[]>([])
const load = () =>
fetch('/api/admin/users')
.then((r) => (r.ok ? r.json() : []))
.then((d) => setUsers(Array.isArray(d) ? d : d?.users ?? []))
.catch((e) => console.error('Failed to load users', e))
useEffect(() => { load() }, [])
const viewAs = async (userId: string) => {
const res = await fetch('/api/admin/view-as', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }) })
if (!res.ok) return toast.error('Could not switch account')
window.location.href = '/pages'
}
const toggleRole = async (u: U) => {
const res = await fetch('/api/admin/users', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: u.id, role: u.role === 'ADMIN' ? 'USER' : 'ADMIN' }) })
if (!res.ok) return toast.error((await res.json().catch(() => null))?.error ?? 'Could not change role')
load()
}
return (
<div className="mx-auto max-w-4xl">
<h1 className="font-display text-2xl font-bold">Admin</h1>
<p className="mb-6 text-sm text-muted-foreground">All accounts in the group. “View as” opens an account with a visible banner; exit any time.</p>
<div className="divide-y divide-border/60 rounded-xl bg-card shadow-sm ring-1 ring-border/50">
{users.map((u) => (
<div key={u.id} className="flex flex-wrap items-center gap-3 p-4">
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{u.name || u.email} {u.role === 'ADMIN' && <Shield className="ml-1 inline h-3.5 w-3.5 text-primary" />}</p>
<p className="truncate text-xs text-muted-foreground">{u.email} · {u._count?.bookmarks ?? 0} bookmarks · {u._count?.pages ?? 0} pages</p>
</div>
<Button size="sm" variant="outline" disabled={u.id === currentUserId} onClick={() => toggleRole(u)}>{u.role === 'ADMIN' ? 'Make user' : 'Make admin'}</Button>
<Button size="sm" disabled={u.id === currentUserId} onClick={() => viewAs(u.id)}><Eye className="mr-1 h-4 w-4" />View as</Button>
</div>
))}
{users.length === 0 && <p className="p-6 text-center text-sm text-muted-foreground">No users found.</p>}
</div>
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
export const dynamic = 'force-dynamic'
import { redirect } from 'next/navigation'
import { getRealUser } from '@/lib/auth-guard'
import { AdminClient } from './client'
export default async function AdminPage() {
const user = await getRealUser()
if (!user) redirect('/login')
if (user.role !== 'ADMIN') redirect('/pages')
return <AdminClient currentUserId={user.id} />
}
+192
View File
@@ -0,0 +1,192 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { BookmarkDetailSheet } from '@/components/bookmark-detail-sheet'
import { ExternalLink, Search, Trash2, ChevronUp, ChevronDown, Inbox, Share2 } from 'lucide-react'
import { toast } from 'sonner'
import type { Bookmark } from '@/components/app-shell'
import { fetchBookmarks } from '@/lib/bookmarks'
export function BookmarksClient({ initialSearch = null, initialOpen = null }: { initialSearch?: string | null; initialOpen?: string | null }) {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [sharedBookmarks, setSharedBookmarks] = useState<Bookmark[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [selected, setSelected] = useState<Bookmark | null>(null)
// Review CR-04: the sheet must receive the CURRENT row after mutations —
// its conflict banner ("Load latest version") and successful saves need
// the fresh version, or the next save would send a stale base.
const selectedRef = useRef<Bookmark | null>(null)
selectedRef.current = selected
const refreshSelected = useCallback(async () => {
const id = selectedRef.current?.id
if (!id) return
try {
const res = await fetch(`/api/bookmarks/${encodeURIComponent(id)}`, { cache: 'no-store' })
if (res.ok) setSelected(await res.json())
} catch { /* the list load surfaces errors */ }
}, [])
const [tab, setTab] = useState<'mine' | 'shared'>('mine')
const [sortField, setSortField] = useState<'title' | 'updatedAt' | 'kanbanStatus'>('updatedAt')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
// Debounced, abortable, sequence-guarded load: a slow response for an older
// query can never overwrite newer results (same discipline as pages client).
const loadSeq = useRef(0)
const abortRef = useRef<AbortController | null>(null)
const load = useCallback(() => {
abortRef.current?.abort()
const ac = new AbortController()
abortRef.current = ac
const seq = ++loadSeq.current
setLoading(true)
const params = new URLSearchParams()
if (search) params.set('search', search)
;(async () => {
try {
const mine = await fetchBookmarks(params, ac.signal)
if (seq !== loadSeq.current) return
setBookmarks(mine ?? [])
} catch {
if (seq === loadSeq.current && !ac.signal.aborted) toast.error('Could not load bookmarks')
} finally {
if (seq === loadSeq.current) setLoading(false)
}
})()
}, [search])
// The shared list is not search-filtered; load it once and on changes only.
const loadShared = useCallback(async () => {
try { setSharedBookmarks(await fetchBookmarks(new URLSearchParams({ shared: 'true' })) ?? []) }
catch { /* the mine-tab load surfaces errors */ }
}, [])
useEffect(() => {
const timer = setTimeout(load, search ? 250 : 0)
const reload = () => { load(); void loadShared() }
window.addEventListener('computer:bookmarks-changed', reload)
return () => { clearTimeout(timer); window.removeEventListener('computer:bookmarks-changed', reload); abortRef.current?.abort() }
}, [load, loadShared, search])
useEffect(() => { const timer = setTimeout(loadShared, 0); return () => clearTimeout(timer) }, [loadShared])
// Quick Find and the dashboard redirect land here with ?search=; the prop
// comes from the server component so query changes apply without a remount.
useEffect(() => {
const timer = setTimeout(() => setSearch(initialSearch || ''), 0)
return () => clearTimeout(timer)
}, [initialSearch])
// Quick Find deep-links straight to a bookmark's detail sheet via ?open=<id>.
useEffect(() => {
if (!initialOpen) return
const timer = setTimeout(async () => {
try {
const res = await fetch(`/api/bookmarks/${encodeURIComponent(initialOpen)}`)
if (res.ok) setSelected(await res.json())
} catch { /* stale or inaccessible id; the list still renders */ }
}, 0)
return () => clearTimeout(timer)
}, [initialOpen])
const displayBookmarks = tab === 'mine' ? bookmarks : sharedBookmarks
const sorted = [...(displayBookmarks ?? [])].sort((a: any, b: any) => {
const va = a?.[sortField] ?? ''
const vb = b?.[sortField] ?? ''
return sortDir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va))
})
const toggleSort = (field: typeof sortField) => {
if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
else { setSortField(field); setSortDir('asc') }
}
const sortIcon = (field: typeof sortField) => {
if (sortField !== field) return null
return sortDir === 'asc' ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />
}
return (
<div className="space-y-6">
<div>
<h1 className="font-display text-2xl font-bold tracking-tight">All Bookmarks</h1>
<p className="text-sm text-muted-foreground mt-1">Every saved link. Use the sidebar to filter by calendar, tags, board, or contacts.</p>
</div>
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Filter..." value={search} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)} className="pl-9" />
</div>
<div className="flex rounded-lg border overflow-hidden">
<button onClick={() => setTab('mine')} className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors ${tab === 'mine' ? 'bg-primary text-primary-foreground' : 'bg-card hover:bg-muted'}`}>
<Inbox className="h-3.5 w-3.5" /> Mine
</button>
<button onClick={() => setTab('shared')} className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors ${tab === 'shared' ? 'bg-primary text-primary-foreground' : 'bg-card hover:bg-muted'}`}>
<Share2 className="h-3.5 w-3.5" /> Shared
</button>
</div>
</div>
<div className="rounded-lg border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 text-left">
<th className="px-4 py-3 font-medium cursor-pointer" onClick={() => toggleSort('title')}>
<span className="flex items-center gap-1">Title {sortIcon('title')}</span>
</th>
<th className="px-4 py-3 font-medium">URL</th>
<th className="px-4 py-3 font-medium">Tags</th>
<th className="px-4 py-3 font-medium cursor-pointer" onClick={() => toggleSort('kanbanStatus')}>
<span className="flex items-center gap-1">Status {sortIcon('kanbanStatus')}</span>
</th>
<th className="px-4 py-3 font-medium cursor-pointer" onClick={() => toggleSort('updatedAt')}>
<span className="flex items-center gap-1">Updated {sortIcon('updatedAt')}</span>
</th>
<th className="px-4 py-3 font-medium w-20">Actions</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">Loading...</td></tr>
) : sorted.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">No bookmarks found</td></tr>
) : sorted.map((b: Bookmark) => (
<tr key={b.id} tabIndex={0} className="hover:bg-muted/30 cursor-pointer focus-visible:outline-2 focus-visible:outline-primary" onClick={() => setSelected(b)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelected(b) } }}>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{b.favicon && <img src={b.favicon} alt="" referrerPolicy="no-referrer" className="h-4 w-4 rounded" onError={(e: any) => { e.target.style.display = 'none' }} />}
<span className="truncate max-w-[200px]">{b.title ?? 'Untitled'}</span>
</div>
</td>
<td className="px-4 py-3">
<a href={b.url} target="_blank" rel="noopener noreferrer" onClick={(e: React.MouseEvent) => e.stopPropagation()} className="text-primary hover:underline truncate max-w-[200px] block">
{(() => { try { return new URL(b.url).hostname } catch { return b.url } })()}
</a>
</td>
<td className="px-4 py-3">
<div className="flex gap-1">
{(b.tags ?? []).slice(0, 2).map((bt: any) => (
<Badge key={bt?.tag?.id} variant="secondary" className="text-[10px] px-1.5 py-0" style={{ backgroundColor: `${bt?.tag?.color}20`, color: bt?.tag?.color }}>{bt?.tag?.name}</Badge>
))}
</div>
</td>
<td className="px-4 py-3"><Badge variant="outline" className="text-[10px]">{b.kanbanStatus?.replace('_', ' ')}</Badge></td>
<td className="px-4 py-3 text-xs text-muted-foreground">{new Date(b.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })}</td>
<td className="px-4 py-3">
<div className="flex gap-1" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<a href={b.url} target="_blank" rel="noopener noreferrer"><ExternalLink className="h-4 w-4 text-muted-foreground hover:text-primary" /></a>
{tab === 'mine' && (
<button onClick={async () => { if (!confirm('Delete?')) return; try { const res = await fetch(`/api/bookmarks/${b.id}`, { method: 'DELETE' }); if (res.ok) { toast.success('Deleted'); load() } else toast.error('Could not delete bookmark') } catch { toast.error('Could not delete bookmark') } }}><Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" /></button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<BookmarkDetailSheet bookmark={selected} onClose={() => setSelected(null)} onUpdate={() => { load(); void refreshSelected() }} />
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
import { BookmarksClient } from './client'
// Quick Find deep-links (?open=<id>, ?search=<q>) arrive through searchParams
// so the client reacts to query changes without remounting.
export default async function BookmarksPage({ searchParams }: { searchParams: Promise<{ search?: string; open?: string }> }) {
const { search, open } = await searchParams
return <BookmarksClient initialSearch={search ?? null} initialOpen={open ?? null} />
}
+171
View File
@@ -0,0 +1,171 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Button } from '@/components/ui/button'
import { BookmarkCard } from '@/components/bookmark-card'
import { BookmarkDetailSheet } from '@/components/bookmark-detail-sheet'
import { ChevronLeft, ChevronRight, Share2 } from 'lucide-react'
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, subMonths, getDay } from 'date-fns'
import type { Bookmark } from '@/components/app-shell'
import { Input } from '@/components/ui/input'
import { toast } from 'sonner'
import { fetchBookmarks } from '@/lib/bookmarks'
const INITIAL_DATE = new Date(2026, 9, 1)
export function CalendarClient() {
const [currentMonth, setCurrentMonth] = useState<Date>(INITIAL_DATE)
const [today, setToday] = useState<Date>(INITIAL_DATE)
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [selectedDay, setSelectedDay] = useState<Date | null>(null)
const [selectedBookmark, setSelectedBookmark] = useState<Bookmark | null>(null)
const [loading, setLoading] = useState(true)
const [sharedDates, setSharedDates] = useState<any[]>([])
const [shareQuery, setShareQuery] = useState('')
const [shareUsers, setShareUsers] = useState<any[]>([])
// Debounced user search; failures keep the previous results silently.
useEffect(() => {
const q = shareQuery.trim()
const t = setTimeout(async () => {
if (q.length < 2) { setShareUsers([]); return }
try {
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`)
if (res.ok) setShareUsers(await res.json())
} catch { /* transient; retried on next keystroke */ }
}, q.length < 2 ? 0 : 250)
return () => clearTimeout(t)
}, [shareQuery])
useEffect(() => {
const timer = setTimeout(() => { const now = new Date(); setCurrentMonth(now); setToday(now) }, 0)
return () => clearTimeout(timer)
}, [])
const load = useCallback(async () => {
setLoading(true)
try {
const [mine, shared, dates] = await Promise.all([fetchBookmarks(), fetchBookmarks(new URLSearchParams({ shared: 'true' })), fetch('/api/shared-dates')])
setBookmarks([...mine, ...shared])
if (dates.ok) setSharedDates(await dates.json())
} catch { toast.error('Could not load calendar') }
finally { setLoading(false) }
}, [])
useEffect(() => { const timer = setTimeout(load, 0); window.addEventListener('computer:bookmarks-changed', load); return () => { clearTimeout(timer); window.removeEventListener('computer:bookmarks-changed', load) } }, [load])
const days = useMemo(() => {
const start = startOfMonth(currentMonth)
const end = endOfMonth(currentMonth)
return eachDayOfInterval({ start, end })
}, [currentMonth])
const startDayOfWeek = getDay(startOfMonth(currentMonth))
const bookmarksByDay = useMemo(() => {
const map: Record<string, Bookmark[]> = {}
for (const b of bookmarks) {
if (b?.dueDate) {
const key = b.dueDate.slice(0, 10)
if (!map[key]) map[key] = []
map[key].push(b)
}
}
return map
}, [bookmarks])
const selectedBookmarks = useMemo(() => {
if (!selectedDay) return []
const key = format(selectedDay, 'yyyy-MM-dd')
return bookmarksByDay[key] ?? []
}, [selectedDay, bookmarksByDay])
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-bold tracking-tight">Calendar</h1>
<p className="text-sm text-muted-foreground mt-1">Bookmarks organized by due date</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon-sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="font-medium text-sm min-w-[140px] text-center">{format(currentMonth, 'MMMM yyyy')}</span>
<Button variant="outline" size="icon-sm" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-7 gap-px bg-border rounded-lg overflow-hidden">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(d => (
<div key={d} className="bg-muted/50 px-2 py-2 text-center text-xs font-medium text-muted-foreground">{d}</div>
))}
{Array.from({ length: startDayOfWeek }).map((_, i) => (
<div key={`empty-${i}`} className="bg-card min-h-[80px]" />
))}
{days.map(day => {
const key = format(day, 'yyyy-MM-dd')
const dayBookmarks = bookmarksByDay[key] ?? []
const isSelected = selectedDay ? isSameDay(day, selectedDay) : false
const isToday = isSameDay(day, today)
return (
<div
key={key}
role="button"
tabIndex={0}
aria-pressed={isSelected}
aria-label={format(day, 'EEEE, MMMM d, yyyy')}
onClick={() => setSelectedDay(day)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedDay(day) } }}
className={`bg-card min-h-[80px] p-1.5 cursor-pointer transition-colors hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-primary ${
isSelected ? 'ring-2 ring-primary ring-inset' : ''
}`}
>
<span className={`text-xs font-medium inline-flex h-6 w-6 items-center justify-center rounded-full ${
isToday ? 'bg-primary text-primary-foreground' : 'text-foreground'
}`}>
{format(day, 'd')}
</span>
<div className="space-y-0.5 mt-0.5">
{sharedDates.filter(s => s.date.slice(0, 10) === key).map(s => <p key={s.id} className="truncate text-[10px] text-primary" title={s.note || ''}>Shared: {s.creator.name || s.creator.email}</p>)}
{dayBookmarks.slice(0, 2).map((b: Bookmark) => (
<div key={b.id} role="button" tabIndex={0} className="text-[10px] truncate rounded bg-primary/10 text-primary px-1 py-0.5 cursor-pointer focus-visible:outline-1 focus-visible:outline-primary" onClick={(e: React.MouseEvent) => { e.stopPropagation(); setSelectedBookmark(b) }} onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); setSelectedBookmark(b) } }}>
{b.title ?? 'Untitled'}
</div>
))}
{dayBookmarks.length > 2 && (
<div className="text-[10px] text-muted-foreground px-1">+{dayBookmarks.length - 2} more</div>
)}
</div>
</div>
)
})}
</div>
{selectedDay && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-medium">{format(selectedDay, 'EEEE, MMMM d, yyyy')}</h2>
<div className="flex gap-2"><Input placeholder="Share date with user…" value={shareQuery} onChange={e => setShareQuery(e.target.value)} /></div>
</div>
{shareUsers.map(u => <Button key={u.id} size="sm" variant="outline" onClick={async () => { const res = await fetch('/api/shared-dates', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date: format(selectedDay, 'yyyy-MM-dd'), recipientIds: [u.id] }) }); if (!res.ok) { toast.error('Could not share date'); return } toast.success('Date shared'); setShareQuery(''); setShareUsers([]); load() }}>Share with {u.name || u.email}</Button>)}
{sharedDates.filter(s => s.date.slice(0, 10) === format(selectedDay, 'yyyy-MM-dd')).map(s => <div key={s.id} className="flex items-center gap-2 text-xs text-muted-foreground"><span>{s.creator.name || s.creator.email} shared with {s.recipient.name || s.recipient.email}</span><Button size="xs" variant="ghost" onClick={async () => { const res = await fetch(`/api/shared-dates/${s.id}`, { method: 'DELETE' }); if (!res.ok) { toast.error('Only the owner can stop sharing'); return } load() }}>Stop sharing</Button></div>)}
{selectedBookmarks.length === 0 ? (
<p className="text-sm text-muted-foreground">No bookmarks on this date</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{selectedBookmarks.map((b: Bookmark) => (
<BookmarkCard key={b.id} bookmark={b} compact onClick={() => setSelectedBookmark(b)} />
))}
</div>
)}
</div>
)}
<BookmarkDetailSheet bookmark={selectedBookmark} onClose={() => setSelectedBookmark(null)} onUpdate={load} />
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { CalendarClient } from './client'
export default function CalendarPage() {
return <CalendarClient />
}
+883
View File
@@ -0,0 +1,883 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { CalendarDays, Columns3, Filter, GripVertical, Kanban, Layers, List, Plus, Table2, Trash2, X } from 'lucide-react'
import { toast } from 'sonner'
import type { Bookmark } from '@/components/app-shell'
import { cn } from '@/lib/utils'
// Collections workspace (Notion-like upgrades phase 2). Contextual workflow
// state over existing bookmarks: the same link can be "Done" in one
// collection and "Unread" in another, and removing a membership never
// deletes the bookmark. Every mutating PATCH carries the expected version;
// a 409 reloads instead of clobbering (the DATA-05 contract).
type PropertyOption = { id: string; label: string; color?: string }
type CollectionProperty = {
id: string; name: string
type: 'TEXT' | 'NUMBER' | 'SELECT' | 'MULTI_SELECT' | 'DATE' | 'CHECKBOX' | 'URL'
position: number; config: { options?: PropertyOption[]; mode?: 'date' | 'datetime' }; version: number
}
type FilterClause = { propertyId: string; op: string; value?: unknown }
type CollectionView = {
id: string; name: string; type: 'TABLE' | 'BOARD' | 'CALENDAR' | 'LIST'; position: number
filters: { operator: 'AND' | 'OR'; clauses: FilterClause[] }
sorts: { field: string; dir: string }[]
groupByPropertyId: string | null; displaySettings: Record<string, unknown>; version: number
}
type CollectionItem = {
id: string; collectionId: string; bookmarkId: string
propertyValues: Record<string, unknown>; position: number; version: number
addedAt: string; updatedAt: string; bookmark: Bookmark
}
type CollectionSummary = {
id: string; name: string; description: string | null; icon: string | null
version: number; createdAt: string; updatedAt: string
_count: { items: number; properties: number; views: number }
}
type CollectionDetail = CollectionSummary & { properties: CollectionProperty[]; views: CollectionView[] }
const VIEW_ICONS = { TABLE: Table2, BOARD: Columns3, CALENDAR: CalendarDays, LIST: List } as const
const OP_LABELS: Record<string, string> = {
eq: 'is', ne: 'is not', contains: 'contains', gt: 'after/above', gte: '≥', lt: 'before/below', lte: '≤',
empty: 'is empty', notEmpty: 'is not empty',
}
// Mirrors the server allowlists in internal/handlers/collections.go.
const FILTER_OPS: Record<string, string[]> = {
TEXT: ['eq', 'ne', 'contains', 'empty', 'notEmpty'],
URL: ['eq', 'ne', 'contains', 'empty', 'notEmpty'],
NUMBER: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'empty', 'notEmpty'],
DATE: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'empty', 'notEmpty'],
SELECT: ['eq', 'ne', 'empty', 'notEmpty'],
CHECKBOX: ['eq', 'ne', 'empty', 'notEmpty'],
MULTI_SELECT: ['contains', 'empty', 'notEmpty'],
}
const PROPERTY_TYPES = ['TEXT', 'NUMBER', 'SELECT', 'MULTI_SELECT', 'DATE', 'CHECKBOX', 'URL'] as const
const NONE = '__none__'
async function json(res: Response): Promise<any> { return res.json().catch(() => null) }
// datetime-local inputs need local "YYYY-MM-DDTHH:mm"; the server stores a
// fixed-width UTC RFC3339 string, so convert through Date on both ends.
function toLocalInput(stored: unknown): string {
if (typeof stored !== 'string' || !stored) return ''
const d = new Date(stored)
if (Number.isNaN(d.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function BookmarkCell({ bookmark }: { bookmark: Bookmark }) {
const host = (() => { try { return new URL(bookmark.url).hostname } catch { return bookmark.url } })()
return (
<Link href={`/bookmarks?open=${bookmark.id}`} className="flex min-w-0 items-center gap-2 hover:underline">
{bookmark.favicon
// eslint-disable-next-line @next/next/no-img-element -- remote favicons stay no-referrer (web/AGENTS.md)
? <img src={bookmark.favicon} alt="" referrerPolicy="no-referrer" className="h-4 w-4 shrink-0 rounded-sm" />
: <Layers className="h-4 w-4 shrink-0 text-muted-foreground" />}
<span className="min-w-0">
<span className="block max-w-[16rem] truncate text-sm">{bookmark.title || bookmark.url}</span>
<span className="block max-w-[16rem] truncate text-xs text-muted-foreground">{host}</span>
</span>
</Link>
)
}
const asText = (v: unknown) => (typeof v === 'string' || typeof v === 'number' ? String(v) : '')
// Per-type inline value editor. Saves immediately on commit gestures
// (change/blur/Enter); failures toast and the prop value re-renders the
// server state, so a rejected edit never sticks locally.
function ValueEditor({ prop, value, onSave }: { prop: CollectionProperty; value: unknown; onSave: (v: unknown) => Promise<void> }) {
const [text, setText] = useState(() => asText(value))
const [seen, setSeen] = useState(value)
// Adjusting state when a prop changes: the React-documented render-time
// pattern (guarded, converges immediately) instead of a cascading effect.
if (value !== seen) {
setSeen(value)
setText(asText(value))
}
const [open, setOpen] = useState(false)
const commitText = async () => {
const next = text.trim()
const current = asText(value)
if (next === current) return
if (next === '') { await onSave(null); return }
await onSave(prop.type === 'NUMBER' ? Number(next) : next)
}
switch (prop.type) {
case 'CHECKBOX':
return <Checkbox aria-label={prop.name} checked={value === true} onCheckedChange={(c) => { void onSave(c === true) }} />
case 'SELECT': {
const options = prop.config.options ?? []
return (
<Select
value={typeof value === 'string' && value ? value : NONE}
onValueChange={(id) => { void onSave(id === NONE ? null : id) }}
>
<SelectTrigger className="h-8 w-[9.5rem]" aria-label={prop.name}><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>—</SelectItem>
{options.map((o) => (
<SelectItem key={o.id} value={o.id}>
<span className="flex items-center gap-1.5">
{o.color && <span className="h-2 w-2 rounded-full" style={{ backgroundColor: o.color }} />}
{o.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)
}
case 'MULTI_SELECT': {
const options = prop.config.options ?? []
const selected = new Set(Array.isArray(value) ? (value as string[]) : [])
const toggle = async (id: string) => {
const next = new Set(selected)
if (next.has(id)) next.delete(id); else next.add(id)
await onSave([...next])
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button type="button" aria-label={`Edit ${prop.name}`} className="flex min-h-8 w-[9.5rem] flex-wrap items-center gap-1 rounded-md border border-input px-2 py-1 text-left hover:bg-muted">
{selected.size === 0 && <span className="text-xs text-muted-foreground">—</span>}
{options.filter((o) => selected.has(o.id)).map((o) => (
<Badge key={o.id} variant="secondary" className="gap-1 text-xs" style={o.color ? { borderColor: o.color } : undefined}>{o.label}</Badge>
))}
</button>
</PopoverTrigger>
<PopoverContent className="w-52 space-y-2" align="start">
{options.map((o) => (
<label key={o.id} className="flex items-center gap-2 text-sm">
<Checkbox checked={selected.has(o.id)} onCheckedChange={() => { void toggle(o.id) }} />
{o.color && <span className="h-2 w-2 rounded-full" style={{ backgroundColor: o.color }} />}
{o.label}
</label>
))}
{options.length === 0 && <p className="text-xs text-muted-foreground">No options yet</p>}
</PopoverContent>
</Popover>
)
}
case 'DATE':
return prop.config.mode === 'datetime'
? <Input type="datetime-local" aria-label={prop.name} className="h-8 w-[13rem]" value={toLocalInput(value)} onChange={(e) => { const v = e.target.value; void onSave(v ? new Date(v).toISOString() : null) }} />
: <Input type="date" aria-label={prop.name} className="h-8 w-[9.5rem]" value={typeof value === 'string' ? value : ''} onChange={(e) => { void onSave(e.target.value || null) }} />
case 'NUMBER':
case 'TEXT':
case 'URL':
default:
return (
<Input
aria-label={prop.name}
className="h-8 w-[9.5rem]"
type={prop.type === 'NUMBER' ? 'number' : 'text'}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={() => { void commitText() }}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
/>
)
}
}
export function CollectionsClient() {
const [collections, setCollections] = useState<CollectionSummary[]>([])
const [activeId, setActiveId] = useState<string | null>(null)
const [detail, setDetail] = useState<CollectionDetail | null>(null)
const [items, setItems] = useState<CollectionItem[]>([])
const [cursor, setCursor] = useState<string | null>(null)
const [draggedItemId, setDraggedItemId] = useState<string | null>(null)
const [boardDropKey, setBoardDropKey] = useState<string | null>(null)
const [viewId, setViewId] = useState<string | null>(null)
const [newName, setNewName] = useState('')
const [nameDraft, setNameDraft] = useState('')
const [addOpen, setAddOpen] = useState(false)
const [addQuery, setAddQuery] = useState('')
const [addResults, setAddResults] = useState<Bookmark[]>([])
const [propOpen, setPropOpen] = useState(false)
const [propName, setPropName] = useState('')
const [propType, setPropType] = useState<(typeof PROPERTY_TYPES)[number]>('SELECT')
const [propOptions, setPropOptions] = useState<{ label: string; color: string }[]>([])
const [propMode, setPropMode] = useState<'date' | 'datetime'>('date')
const [viewOpen, setViewOpen] = useState(false)
const [viewName, setViewName] = useState('')
const [viewType, setViewType] = useState<'TABLE' | 'BOARD' | 'CALENDAR' | 'LIST'>('TABLE')
const [filterOpen, setFilterOpen] = useState(false)
const [fProp, setFProp] = useState('')
const [fOp, setFOp] = useState('eq')
const [fValue, setFValue] = useState('')
const loadCollections = useCallback(async () => {
try {
const res = await fetch('/api/collections')
if (res.ok) setCollections(await res.json())
} catch { toast.error('Could not load collections') }
}, [])
const loadDetail = useCallback(async (id: string) => {
try {
const res = await fetch(`/api/collections/${id}`)
if (!res.ok) { setDetail(null); return }
const data: CollectionDetail = await res.json()
setDetail(data)
setNameDraft(data.name)
setViewId((current) => (current && data.views.some((v) => v.id === current) ? current : data.views[0]?.id ?? null))
} catch { toast.error('Could not load collection') }
}, [])
const loadItems = useCallback(async (id: string, view: string | null, append?: string | null) => {
try {
const params = new URLSearchParams({ limit: '200' })
if (view) params.set('viewId', view)
if (append) params.set('cursor', append)
const res = await fetch(`/api/collections/${id}/items?${params}`)
if (!res.ok) return
const rows: CollectionItem[] = await res.json()
setItems((current) => (append ? [...current, ...rows] : rows))
setCursor(res.headers.get('X-Next-Cursor'))
} catch { toast.error('Could not load items') }
}, [])
useEffect(() => { const t = setTimeout(loadCollections, 0); return () => clearTimeout(t) }, [loadCollections])
useEffect(() => {
const t = setTimeout(() => {
if (!activeId && collections.length) setActiveId(collections[0].id)
}, 0)
return () => clearTimeout(t)
}, [collections, activeId])
useEffect(() => {
const t = setTimeout(() => {
if (!activeId) { setDetail(null); return }
loadDetail(activeId)
}, 0)
return () => clearTimeout(t)
}, [activeId, loadDetail])
useEffect(() => {
const t = setTimeout(() => {
if (!activeId) { setItems([]); return }
loadItems(activeId, viewId)
}, 0)
return () => clearTimeout(t)
}, [activeId, viewId, loadItems])
// Versioned mutation helper: 409 means another session moved first — the
// server state wins, we reload, and nothing is clobbered (DATA-05 lesson).
const mutate = async (res: Response, reload: () => void, okMessage?: string): Promise<boolean> => {
if (res.ok) { if (okMessage) toast.success(okMessage); return true }
if (res.status === 409) {
const body = await json(res)
toast.error(body?.error || 'Changed elsewhere; reloaded the latest version')
reload()
return false
}
const body = await json(res)
toast.error(body?.error || 'Request failed')
return false
}
const patch = (url: string, body: Record<string, unknown>) =>
fetch(url, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const activeView = detail?.views.find((v) => v.id === viewId) ?? null
const groupProp = activeView?.groupByPropertyId ? detail?.properties.find((p) => p.id === activeView.groupByPropertyId) : null
const createCollection = async () => {
const name = newName.trim()
if (!name) return
const res = await fetch('/api/collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) })
if (!res.ok) { await mutate(res, loadCollections); return }
const created = await res.json().catch(() => null)
toast.success('Collection created')
setNewName('')
await loadCollections()
if (created?.id) setActiveId(created.id)
}
const saveName = async () => {
if (!detail) return
const name = nameDraft.trim()
if (!name || name === detail.name) { setNameDraft(detail.name); return }
const res = await patch(`/api/collections/${detail.id}`, { version: detail.version, name })
if (await mutate(res, () => loadDetail(detail.id), 'Renamed')) {
setDetail((d) => (d ? { ...d, name, version: d.version + 1 } : d))
loadCollections()
} else setNameDraft(detail.name)
}
const deleteCollection = async () => {
if (!detail || !confirm(`Delete collection "${detail.name}"? Bookmarks are kept.`)) return
const res = await fetch(`/api/collections/${detail.id}`, { method: 'DELETE' })
if (await mutate(res, loadCollections, 'Collection deleted')) {
setActiveId(null)
setDetail(null)
loadCollections()
}
}
const createProperty = async () => {
if (!detail) return
const name = propName.trim()
if (!name) return
const config: Record<string, unknown> = {}
if (propType === 'SELECT' || propType === 'MULTI_SELECT') {
config.options = propOptions.filter((o) => o.label.trim()).map((o) => ({ label: o.label.trim(), ...(o.color ? { color: o.color } : {}) }))
}
if (propType === 'DATE') config.mode = propMode
const res = await fetch(`/api/collections/${detail.id}/properties`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, type: propType, config }),
})
if (await mutate(res, () => loadDetail(detail.id), 'Property added')) {
setPropOpen(false); setPropName(''); setPropOptions([])
loadDetail(detail.id)
}
}
const deleteProperty = async (prop: CollectionProperty) => {
if (!detail || !confirm(`Delete property "${prop.name}"? Its values are removed from every item.`)) return
const res = await fetch(`/api/collections/${detail.id}/properties/${prop.id}`, { method: 'DELETE' })
if (await mutate(res, () => loadDetail(detail.id), 'Property deleted')) {
loadDetail(detail.id)
loadItems(detail.id, viewId)
}
}
const createView = async () => {
if (!detail) return
const name = viewName.trim()
if (!name) return
const res = await fetch(`/api/collections/${detail.id}/views`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, type: viewType }),
})
if (!res.ok) { await mutate(res, () => loadDetail(detail.id)); return }
const created = await res.json().catch(() => null)
toast.success('View added')
setViewOpen(false); setViewName('')
await loadDetail(detail.id)
if (created?.id) setViewId(created.id)
}
const deleteView = async () => {
if (!detail || !activeView || !confirm(`Delete view "${activeView.name}"?`)) return
const res = await fetch(`/api/collections/${detail.id}/views/${activeView.id}`, { method: 'DELETE' })
if (await mutate(res, () => loadDetail(detail.id), 'View deleted')) {
setViewId(null)
loadDetail(detail.id)
}
}
const patchView = async (changes: Record<string, unknown>) => {
if (!detail || !activeView) return
const res = await patch(`/api/collections/${detail.id}/views/${activeView.id}`, { version: activeView.version, ...changes })
if (res.ok) {
const updated: CollectionView = await res.json()
setDetail((d) => (d ? { ...d, views: d.views.map((v) => (v.id === updated.id ? updated : v)) } : d))
loadItems(detail.id, updated.id)
} else await mutate(res, () => loadDetail(detail.id))
}
const addItem = async (bookmarkId: string) => {
if (!detail) return
const res = await fetch(`/api/collections/${detail.id}/items`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bookmarkId }),
})
if (res.ok) {
loadItems(detail.id, viewId)
loadCollections()
} else if (res.status === 409) toast.error('Already in this collection')
else { const body = await json(res); toast.error(body?.error || 'Could not add bookmark') }
}
const removeItem = async (item: CollectionItem) => {
if (!detail) return
const res = await fetch(`/api/collections/${detail.id}/items/${item.id}`, { method: 'DELETE' })
if (await mutate(res, () => loadItems(detail.id, viewId))) {
setItems((current) => current.filter((i) => i.id !== item.id))
loadCollections()
}
}
const saveValue = async (item: CollectionItem, propId: string, value: unknown) => {
if (!detail) return
const res = await patch(`/api/collections/${detail.id}/items/${item.id}`, {
version: item.version, propertyValues: { [propId]: value },
})
if (res.ok) {
const updated: CollectionItem = await res.json()
setItems((current) => current.map((i) => (i.id === updated.id ? updated : i)))
} else await mutate(res, () => loadItems(detail.id, viewId))
}
// Board drag-and-drop and the per-card select both go through the same
// versioned membership PATCH. Update optimistically, then reconcile with the
// server; any failure reloads so a rejected value never sticks locally.
const moveItemToGroup = async (item: CollectionItem, next: string | null) => {
if (!detail || !groupProp) return
const current = item.propertyValues?.[groupProp.id]
const normalized = current === undefined || current === '' ? null : current
if (normalized === next) return
setItems((current) => current.map((i) => i.id === item.id ? {
...i, propertyValues: { ...i.propertyValues, [groupProp.id]: next },
} : i))
const res = await patch(`/api/collections/${detail.id}/items/${item.id}`, {
version: item.version, propertyValues: { [groupProp.id]: next },
})
if (res.ok) {
const updated: CollectionItem = await res.json()
setItems((current) => current.map((i) => (i.id === updated.id ? updated : i)))
return
}
const handled = await mutate(res, () => loadItems(detail.id, viewId))
if (!handled && res.status !== 409) await loadItems(detail.id, viewId)
}
// Debounced bookmark search for the add dialog.
useEffect(() => {
if (!addOpen) return
const q = addQuery.trim()
const t = setTimeout(async () => {
try {
const params = new URLSearchParams({ limit: '8' })
if (q) params.set('search', q)
const res = await fetch(`/api/bookmarks?${params}`)
if (res.ok) setAddResults((await res.json()).slice(0, 8))
} catch { /* transient; retried on next keystroke */ }
}, 250)
return () => clearTimeout(t)
}, [addQuery, addOpen])
const filterClauses = activeView?.filters?.clauses ?? []
const setClause = (clauses: FilterClause[]) => { void patchView({ filters: { operator: activeView?.filters?.operator ?? 'AND', clauses } }) }
const addClause = () => {
if (!fProp || !fOp) return
const prop = detail?.properties.find((p) => p.id === fProp)
if (!prop) return
let value: unknown
if (!['empty', 'notEmpty'].includes(fOp)) {
if (prop.type === 'NUMBER') value = Number(fValue)
else if (prop.type === 'CHECKBOX') value = fValue === 'true'
else value = fValue
if (fValue === '' || (prop.type === 'NUMBER' && Number.isNaN(value))) { toast.error('Filter value required'); return }
}
setClause([...filterClauses, value === undefined ? { propertyId: fProp, op: fOp } : { propertyId: fProp, op: fOp, value }])
setFilterOpen(false); setFValue('')
}
const clauseLabel = (c: FilterClause) => {
const prop = detail?.properties.find((p) => p.id === c.propertyId)
if (!prop) return 'unknown'
let shown = c.value
if (prop.type === 'SELECT' && typeof c.value === 'string') shown = prop.config.options?.find((o) => o.id === c.value)?.label ?? c.value
if (prop.type === 'CHECKBOX') shown = c.value === true ? 'checked' : 'unchecked'
return `${prop.name} ${OP_LABELS[c.op] ?? c.op}${shown !== undefined ? ` ${shown}` : ''}`
}
const renderTable = () => (
<div className="overflow-x-auto rounded-lg border border-border/60">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[16rem]">Bookmark</TableHead>
{(detail?.properties ?? []).map((p) => (
<TableHead key={p.id} className="min-w-[11rem]">
<span className="flex items-center gap-1.5">
{p.name}
<button type="button" aria-label={`Delete property ${p.name}`} className="text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100" onClick={() => deleteProperty(p)}>
<Trash2 className="h-3 w-3" />
</button>
</span>
</TableHead>
))}
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id} className="group">
<TableCell><BookmarkCell bookmark={item.bookmark} /></TableCell>
{(detail?.properties ?? []).map((p) => (
<TableCell key={p.id}><ValueEditor prop={p} value={item.propertyValues?.[p.id]} onSave={(v) => saveValue(item, p.id, v)} /></TableCell>
))}
<TableCell>
<button type="button" aria-label="Remove from collection" className="text-muted-foreground hover:text-destructive" onClick={() => removeItem(item)}>
<Trash2 className="h-4 w-4" />
</button>
</TableCell>
</TableRow>
))}
{items.length === 0 && (
<TableRow>
<TableCell colSpan={(detail?.properties.length ?? 0) + 2} className="py-10 text-center text-sm text-muted-foreground">
No items{filterClauses.length ? " match this view's filters" : ''}. Add existing bookmarks — nothing is duplicated.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)
const renderBoard = () => {
if (!groupProp || groupProp.type !== 'SELECT') {
return (
<div className="rounded-lg border border-dashed border-border p-8 text-center">
<Kanban className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Board views group by a select property.</p>
<div className="mx-auto mt-3 w-56">
<Select
value={NONE}
onValueChange={(id) => { if (id !== NONE) patchView({ groupByPropertyId: id }) }}
>
<SelectTrigger aria-label="Group by property"><SelectValue placeholder="Choose a select property…" /></SelectTrigger>
<SelectContent>
{(detail?.properties ?? []).filter((p) => p.type === 'SELECT').map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
}
const options = groupProp.config.options ?? []
const columns = [...options.map((o) => ({ key: o.id, label: o.label, color: o.color })), { key: NONE, label: 'No value', color: undefined }]
return (
<div className="flex gap-3 overflow-x-auto pb-2">
{columns.map((col) => {
const colItems = items.filter((i) => {
const v = i.propertyValues?.[groupProp.id]
return col.key === NONE ? (v === undefined || v === null) : v === col.key
})
const isDropTarget = !!draggedItemId && boardDropKey === col.key
return (
<div
key={col.key}
data-board-group={col.key}
onDragOver={(e) => {
if (!draggedItemId) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setBoardDropKey(col.key)
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setBoardDropKey(null)
}}
onDrop={(e) => {
e.preventDefault()
const item = items.find((i) => i.id === draggedItemId)
if (item) void moveItemToGroup(item, col.key === NONE ? null : col.key)
setDraggedItemId(null)
setBoardDropKey(null)
}}
className={cn(
'w-64 shrink-0 rounded-lg border border-border/60 bg-muted/30 p-2 transition-[box-shadow,background-color,border-color] duration-150',
isDropTarget && 'border-primary/40 bg-primary/[0.04] ring-2 ring-primary/40',
)}
>
<div className="mb-2 flex items-center gap-1.5 px-1 text-sm font-medium">
{col.color && <span className="h-2 w-2 rounded-full" style={{ backgroundColor: col.color }} />}
{col.label}
<span className="ml-auto text-xs text-muted-foreground">{colItems.length}</span>
</div>
<div className="space-y-2">
{colItems.map((item) => (
<div
key={item.id}
data-collection-item-id={item.id}
className={cn(
'rounded-md border border-border bg-card p-2 shadow-sm transition-[transform,box-shadow,opacity] duration-150 group',
draggedItemId === item.id && 'opacity-40',
)}
>
<div className="flex items-start gap-1">
<span
data-drag-handle
draggable
onDragStart={(e) => {
setDraggedItemId(item.id)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', item.id)
}}
onDragEnd={() => {
setDraggedItemId(null)
setBoardDropKey(null)
}}
className="mt-0.5 cursor-grab text-muted-foreground/50 transition-colors hover:text-muted-foreground active:cursor-grabbing"
title="Drag to another group"
aria-label={`Drag ${item.bookmark.title ?? 'bookmark'}`}
>
<GripVertical className="h-3.5 w-3.5" />
</span>
<div className="min-w-0 flex-1">
<BookmarkCell bookmark={item.bookmark} />
</div>
</div>
<div className="mt-1.5 flex items-center justify-between gap-1">
<Select
value={typeof item.propertyValues?.[groupProp.id] === 'string' ? (item.propertyValues[groupProp.id] as string) : NONE}
onValueChange={(id) => { void moveItemToGroup(item, id === NONE ? null : id) }}
>
<SelectTrigger className="h-7 w-28 text-xs" aria-label={`Move ${item.bookmark.title ?? 'card'}`}><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>—</SelectItem>
{options.map((o) => <SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<button type="button" aria-label="Remove from collection" className="text-muted-foreground hover:text-destructive" onClick={() => removeItem(item)}>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
{colItems.length === 0 && (
<p className={cn('px-1 py-3 text-center text-xs text-muted-foreground', isDropTarget && 'text-primary')}>
{isDropTarget ? 'Drop here' : 'Empty'}
</p>
)}
</div>
</div>
)
})}
</div>
)
}
return (
<div className="flex h-full flex-col">
<div className="flex flex-1 overflow-hidden">
{/* Collection list */}
<div className="flex w-60 shrink-0 flex-col border-r border-border/60 p-3">
<h1 className="mb-3 flex items-center gap-2 font-display text-lg font-semibold tracking-tight">
<Layers className="h-4 w-4" /> Collections
</h1>
<div className="mb-3 flex gap-1.5">
<Input value={newName} onChange={(e) => setNewName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') createCollection() }} placeholder="New collection" className="h-8 text-sm" aria-label="New collection name" />
<Button size="sm" variant="outline" className="h-8 shrink-0 px-2" onClick={createCollection} aria-label="Create collection"><Plus className="h-4 w-4" /></Button>
</div>
<div className="flex-1 space-y-1 overflow-y-auto">
{collections.map((c) => (
<button
key={c.id}
onClick={() => { setActiveId(c.id); setViewId(null) }}
className={cn('flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors', c.id === activeId ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground')}
>
<Layers className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">{c.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">{c._count.items}</span>
</button>
))}
{collections.length === 0 && (
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
Collections hold workflow state per project. The same bookmark can live in many — nothing is duplicated.
</p>
)}
</div>
</div>
{/* Detail */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{!detail ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
<Layers className="h-10 w-10 text-muted-foreground" />
<h2 className="font-display text-xl font-semibold">{collections.length ? 'Select a collection' : 'No collections yet'}</h2>
<p className="max-w-sm text-sm text-muted-foreground">
A collection is a saved workflow over existing bookmarks: per-collection status, priority, dates — independent everywhere the link appears.
</p>
</div>
) : (
<>
<div className="mb-4 flex flex-wrap items-center gap-2">
<Input value={nameDraft} onChange={(e) => setNameDraft(e.target.value)} onBlur={saveName} onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} aria-label="Collection name" className="w-72 bg-transparent font-display text-2xl font-bold tracking-tight" />
<span className="text-xs text-muted-foreground">{detail._count?.items ?? 0} item{(detail._count?.items ?? 0) === 1 ? '' : 's'}</span>
<div className="ml-auto flex items-center gap-1.5">
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}><Plus className="mr-1 h-4 w-4" />Add bookmarks</Button>
<Button size="sm" variant="outline" onClick={() => setPropOpen(true)}><Plus className="mr-1 h-4 w-4" />Property</Button>
<Button size="sm" variant="ghost" onClick={deleteCollection} aria-label="Delete collection" title="Delete collection"><Trash2 className="h-4 w-4 text-destructive" /></Button>
</div>
</div>
{/* View tabs */}
<div className="mb-3 flex flex-wrap items-center gap-1.5">
{detail.views.map((v) => {
const Icon = VIEW_ICONS[v.type] ?? Table2
return (
<button
key={v.id}
onClick={() => setViewId(v.id)}
className={cn('flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm transition-colors', v.id === viewId ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground')}
>
<Icon className="h-3.5 w-3.5" /> {v.name}
</button>
)
})}
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => setViewOpen(true)} aria-label="Add view"><Plus className="h-4 w-4" /></Button>
{activeView && (
<div className="ml-auto flex items-center gap-1.5">
<Popover open={filterOpen} onOpenChange={(o) => { setFilterOpen(o); if (o) { setFProp(detail.properties[0]?.id ?? ''); setFOp(FILTER_OPS[detail.properties[0]?.type ?? 'TEXT']?.[0] ?? 'eq') } }}>
<PopoverTrigger asChild>
<Button size="sm" variant="ghost" className="h-7 gap-1 px-2 text-muted-foreground"><Filter className="h-3.5 w-3.5" />Filter{filterClauses.length > 0 && ` (${filterClauses.length})`}</Button>
</PopoverTrigger>
<PopoverContent className="w-80 space-y-2" align="end">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Add a filter clause</Label>
<Select value={fProp || undefined} onValueChange={(id) => { setFProp(id); const p = detail.properties.find((x) => x.id === id); setFOp(FILTER_OPS[p?.type ?? 'TEXT']?.[0] ?? 'eq'); setFValue('') }}>
<SelectTrigger aria-label="Filter property"><SelectValue placeholder="Property" /></SelectTrigger>
<SelectContent>
{detail.properties.filter((p) => FILTER_OPS[p.type]).map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectContent>
</Select>
<Select value={fOp} onValueChange={setFOp}>
<SelectTrigger aria-label="Filter operator"><SelectValue /></SelectTrigger>
<SelectContent>
{(FILTER_OPS[detail.properties.find((p) => p.id === fProp)?.type ?? 'TEXT'] ?? []).map((op) => <SelectItem key={op} value={op}>{OP_LABELS[op] ?? op}</SelectItem>)}
</SelectContent>
</Select>
{!['empty', 'notEmpty'].includes(fOp) && (() => {
const p = detail.properties.find((x) => x.id === fProp)
if (p?.type === 'SELECT') return (
<Select value={fValue || undefined} onValueChange={setFValue}>
<SelectTrigger aria-label="Filter value"><SelectValue placeholder="Value" /></SelectTrigger>
<SelectContent>{(p.config.options ?? []).map((o) => <SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>)}</SelectContent>
</Select>
)
if (p?.type === 'CHECKBOX') return (
<Select value={fValue || undefined} onValueChange={setFValue}>
<SelectTrigger aria-label="Filter value"><SelectValue placeholder="Value" /></SelectTrigger>
<SelectContent><SelectItem value="true">checked</SelectItem><SelectItem value="false">unchecked</SelectItem></SelectContent>
</Select>
)
return <Input value={fValue} onChange={(e) => setFValue(e.target.value)} placeholder="Value" aria-label="Filter value" className="h-8 text-sm" />
})()}
<Button size="sm" className="w-full" onClick={addClause}>Apply filter</Button>
</PopoverContent>
</Popover>
<Button size="sm" variant="ghost" className="h-7 px-2 text-muted-foreground" onClick={deleteView} aria-label="Delete view" title="Delete view"><Trash2 className="h-3.5 w-3.5" /></Button>
</div>
)}
</div>
{/* Active filter chips */}
{filterClauses.length > 0 && (
<div className="mb-3 flex flex-wrap items-center gap-1.5">
<span className="text-xs text-muted-foreground">{activeView?.filters?.operator ?? 'AND'}:</span>
{filterClauses.map((c, i) => (
<Badge key={i} variant="secondary" className="gap-1">
{clauseLabel(c)}
<button type="button" aria-label="Remove filter" onClick={() => setClause(filterClauses.filter((_, j) => j !== i))}><X className="h-3 w-3" /></button>
</Badge>
))}
</div>
)}
{activeView?.type === 'BOARD' ? renderBoard() : renderTable()}
{activeView && (activeView.type === 'CALENDAR' || activeView.type === 'LIST') && (
<p className="mt-2 text-xs text-muted-foreground">Calendar and list layouts render as a table in this phase.</p>
)}
{cursor && (
<Button size="sm" variant="outline" className="mt-3" onClick={() => loadItems(detail.id, viewId, cursor)}>Load more</Button>
)}
</>
)}
</div>
</div>
{/* Add bookmarks dialog */}
<Dialog open={addOpen} onOpenChange={(o) => { setAddOpen(o); if (!o) setAddQuery('') }}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle>Add existing bookmarks</DialogTitle></DialogHeader>
<p className="text-xs text-muted-foreground">Membership is contextual — adding here never duplicates or re-owns the bookmark.</p>
<Input autoFocus value={addQuery} onChange={(e) => setAddQuery(e.target.value)} placeholder="Search bookmarks…" aria-label="Search bookmarks" />
<div className="max-h-72 space-y-1 overflow-y-auto">
{addResults.map((b) => {
const member = items.some((i) => i.bookmarkId === b.id)
return (
<div key={b.id} className="flex items-center gap-2 rounded-md border border-border/60 px-2 py-1.5">
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{b.title || b.url}</p>
<p className="truncate text-xs text-muted-foreground">{b.url}</p>
</div>
<Button size="sm" variant={member ? 'ghost' : 'outline'} disabled={member} onClick={() => addItem(b.id)}>
{member ? 'Added' : 'Add'}
</Button>
</div>
)
})}
{addResults.length === 0 && <p className="py-6 text-center text-sm text-muted-foreground">No bookmarks found</p>}
</div>
</DialogContent>
</Dialog>
{/* New property dialog */}
<Dialog open={propOpen} onOpenChange={setPropOpen}>
<DialogContent className="max-w-md">
<DialogHeader><DialogTitle>New property</DialogTitle></DialogHeader>
<div className="space-y-3">
<Input value={propName} onChange={(e) => setPropName(e.target.value)} placeholder="Name (e.g. Status)" aria-label="Property name" />
<Select value={propType} onValueChange={(t) => setPropType(t as (typeof PROPERTY_TYPES)[number])}>
<SelectTrigger aria-label="Property type"><SelectValue /></SelectTrigger>
<SelectContent>
{PROPERTY_TYPES.map((t) => <SelectItem key={t} value={t}>{t.replace('_', ' ').toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase())}</SelectItem>)}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Type is permanent — conversions that could destroy values are refused by design.</p>
{(propType === 'SELECT' || propType === 'MULTI_SELECT') && (
<div className="space-y-2">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Options</Label>
{propOptions.map((o, i) => (
<div key={i} className="flex items-center gap-2">
<Input type="color" aria-label="Option color" value={o.color || '#6366f1'} onChange={(e) => setPropOptions(propOptions.map((x, j) => (j === i ? { ...x, color: e.target.value } : x)))} className="h-8 w-10 shrink-0 p-1" />
<Input value={o.label} onChange={(e) => setPropOptions(propOptions.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))} placeholder="Option label" aria-label="Option label" className="h-8 text-sm" />
<Button size="sm" variant="ghost" className="h-8 px-2" aria-label="Remove option" onClick={() => setPropOptions(propOptions.filter((_, j) => j !== i))}><X className="h-4 w-4" /></Button>
</div>
))}
<Button size="sm" variant="outline" onClick={() => setPropOptions([...propOptions, { label: '', color: '#6366f1' }])}><Plus className="mr-1 h-4 w-4" />Option</Button>
</div>
)}
{propType === 'DATE' && (
<Select value={propMode} onValueChange={(m) => setPropMode(m as 'date' | 'datetime')}>
<SelectTrigger aria-label="Date mode"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="date">Date only (never shifts across timezones)</SelectItem>
<SelectItem value="datetime">Date &amp; time (stored UTC)</SelectItem>
</SelectContent>
</Select>
)}
<Button className="w-full" onClick={createProperty}>Create property</Button>
</div>
</DialogContent>
</Dialog>
{/* New view dialog */}
<Dialog open={viewOpen} onOpenChange={setViewOpen}>
<DialogContent className="max-w-sm">
<DialogHeader><DialogTitle>New view</DialogTitle></DialogHeader>
<div className="space-y-3">
<Input autoFocus value={viewName} onChange={(e) => setViewName(e.target.value)} placeholder="View name" aria-label="View name" />
<Select value={viewType} onValueChange={(t) => setViewType(t as typeof viewType)}>
<SelectTrigger aria-label="View type"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="TABLE">Table</SelectItem>
<SelectItem value="BOARD">Board</SelectItem>
<SelectItem value="CALENDAR">Calendar (renders as table for now)</SelectItem>
<SelectItem value="LIST">List (renders as table for now)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">A view is a saved query — filters and grouping never copy records.</p>
<Button className="w-full" onClick={createView}>Create view</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { CollectionsClient } from './client'
export default function CollectionsPage() {
return <CollectionsClient />
}
+121
View File
@@ -0,0 +1,121 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { BookmarkCard } from '@/components/bookmark-card'
import { BookmarkDetailSheet } from '@/components/bookmark-detail-sheet'
import { Users, RefreshCw, Mail, Phone } from 'lucide-react'
import { toast } from 'sonner'
import type { Bookmark } from '@/components/app-shell'
import { fetchBookmarks } from '@/lib/bookmarks'
interface Contact {
id: string
uid: string
displayName: string
email: string | null
phone: string | null
_count?: { bookmarks: number }
}
export function ContactsClient() {
const [contacts, setContacts] = useState<Contact[]>([])
const [selectedContact, setSelectedContact] = useState<string | null>(null)
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [selectedBookmark, setSelectedBookmark] = useState<Bookmark | null>(null)
const [syncing, setSyncing] = useState(false)
const [loading, setLoading] = useState(true)
const loadContacts = useCallback(async () => {
setLoading(true)
try {
const res = await fetch('/api/contacts')
if (res.ok) setContacts(await res.json())
} catch { toast.error('Could not load contacts') }
finally { setLoading(false) }
}, [])
const loadBookmarks = useCallback(async (contactId: string) => {
try { setBookmarks(await fetchBookmarks(new URLSearchParams({ contactId }))) }
catch { toast.error('Could not load bookmarks') }
}, [])
useEffect(() => { const timer = setTimeout(loadContacts, 0); return () => clearTimeout(timer) }, [loadContacts])
useEffect(() => { const timer = setTimeout(() => { if (selectedContact) loadBookmarks(selectedContact) }, 0); return () => clearTimeout(timer) }, [selectedContact, loadBookmarks])
useEffect(() => { const reload = () => { loadContacts(); if (selectedContact) loadBookmarks(selectedContact) }; window.addEventListener('computer:bookmarks-changed', reload); return () => window.removeEventListener('computer:bookmarks-changed', reload) }, [loadContacts, loadBookmarks, selectedContact])
const syncContacts = async () => {
setSyncing(true)
toast.info('Configure your Nextcloud settings in Settings → Nextcloud to sync contacts')
setSyncing(false)
}
return (
<div className="flex gap-6 h-[calc(100vh-120px)]">
<div className="w-72 shrink-0 space-y-4 overflow-y-auto">
<div className="flex items-center justify-between">
<h1 className="font-display text-2xl font-bold tracking-tight">Contacts</h1>
<Button variant="outline" size="sm" onClick={syncContacts} loading={syncing} className="gap-1.5">
<RefreshCw className="h-3 w-3" /> Sync
</Button>
</div>
{loading ? (
<div className="space-y-2">{Array.from({ length: 5 }).map((_, i) => <div key={i} className="h-14 rounded-lg bg-muted animate-pulse" />)}</div>
) : contacts.length === 0 ? (
<div className="text-center py-8">
<Users className="h-10 w-10 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No contacts yet</p>
<p className="text-xs text-muted-foreground mt-1">Sync from Nextcloud in Settings</p>
</div>
) : (
<div className="space-y-1">
{contacts.map((c: Contact) => (
<div
key={c.id}
role="button"
tabIndex={0}
aria-pressed={selectedContact === c.id}
onClick={() => setSelectedContact(c.id)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedContact(c.id) } }}
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 cursor-pointer transition-colors focus-visible:outline-2 focus-visible:outline-primary ${
selectedContact === c.id ? 'bg-accent' : 'hover:bg-muted'
}`}
>
<div className="h-9 w-9 rounded-full bg-primary/20 flex items-center justify-center text-sm font-bold text-primary shrink-0">
{(c.displayName ?? '?')[0]?.toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{c.displayName}</p>
{c.email && <p className="text-xs text-muted-foreground truncate flex items-center gap-1"><Mail className="h-3 w-3" />{c.email}</p>}
</div>
<span className="text-xs text-muted-foreground">{c._count?.bookmarks ?? 0}</span>
</div>
))}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto">
{selectedContact ? (
<div className="space-y-4">
<h2 className="font-medium">Bookmarks for {contacts.find(c => c.id === selectedContact)?.displayName}</h2>
{bookmarks.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">No bookmarks linked to this contact</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{bookmarks.map((b: Bookmark) => <BookmarkCard key={b.id} bookmark={b} compact onClick={() => setSelectedBookmark(b)} />)}
</div>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-center">
<Users className="h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">Select a contact to view linked bookmarks</p>
</div>
)}
</div>
<BookmarkDetailSheet bookmark={selectedBookmark} onClose={() => setSelectedBookmark(null)} onUpdate={() => { if (selectedContact) loadBookmarks(selectedContact) }} />
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { ContactsClient } from './client'
export default function ContactsPage() {
return <ContactsClient />
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from 'next/navigation'
// The dashboard was folded into the "All Bookmarks" filter view.
export default async function DashboardPage({ searchParams }: { searchParams: Promise<{ search?: string }> }) {
const { search } = await searchParams
redirect(search ? `/bookmarks?search=${encodeURIComponent(search)}` : '/bookmarks')
}
+444
View File
@@ -0,0 +1,444 @@
'use client'
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSession } from 'next-auth/react'
import { BookmarkDetailSheet } from '@/components/bookmark-detail-sheet'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { ExternalLink, Calendar, GripVertical, Plus, MoreHorizontal, Pencil, Trash2, ArrowLeft, ArrowRight, Check, X } from 'lucide-react'
import { toast } from 'sonner'
import type { Bookmark } from '@/components/app-shell'
import { fetchBookmarks } from '@/lib/bookmarks'
import { invalidateReference } from '@/lib/reference-data'
import { cn } from '@/lib/utils'
type Column = { id: string; label: string; color: string; position: number; key: string | null }
type CardDrop = { columnId: string; index: number }
const PALETTE = ['#64748b', '#3b82f6', '#f59e0b', '#22c55e', '#8b5cf6', '#ec4899', '#ef4444', '#14b8a6']
function columnFor(b: Bookmark, cols: Column[]) {
const explicit = (b as any).kanbanColumnId as string | null
if (explicit && cols.some(c => c.id === explicit)) return explicit
return cols.find(c => c.key === b.kanbanStatus)?.id ?? cols[0]?.id
}
function sortCards(a: Bookmark, b: Bookmark, currentUserId?: string) {
// Owned cards keep their persisted board order. Shared cards are read-only
// and sort after owned ones so they cannot disturb the owner's positions.
if (currentUserId) {
const aOwned = a.ownerId === currentUserId
const bOwned = b.ownerId === currentUserId
if (aOwned !== bOwned) return aOwned ? -1 : 1
}
const ap = a.kanbanPosition ?? 0
const bp = b.kanbanPosition ?? 0
if (ap !== bp) return ap - bp
return b.updatedAt.localeCompare(a.updatedAt) || b.id.localeCompare(a.id)
}
export function KanbanClient() {
const { data: session } = useSession()
const currentUserId = session?.user?.id
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [columns, setColumns] = useState<Column[]>([])
const [loading, setLoading] = useState(true)
const [selectedBookmark, setSelectedBookmark] = useState<Bookmark | null>(null)
const [draggedId, setDraggedId] = useState<string | null>(null)
const [draggedColumnId, setDraggedColumnId] = useState<string | null>(null)
const [cardDrop, setCardDrop] = useState<CardDrop | null>(null)
const [columnDropId, setColumnDropId] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [editLabel, setEditLabel] = useState('')
const [adding, setAdding] = useState(false)
const [newLabel, setNewLabel] = useState('')
const suppressClick = useRef(false)
const load = useCallback(async () => {
setLoading(true)
try {
const [bookmarks, cRes] = await Promise.all([fetchBookmarks(), fetch('/api/kanban/columns')])
setBookmarks(bookmarks)
if (cRes.ok) setColumns(await cRes.json())
} catch (e) { console.error('Kanban load failed', e) }
setLoading(false)
}, [])
useEffect(() => { const timer = setTimeout(load, 0); window.addEventListener('computer:bookmarks-changed', load); return () => { clearTimeout(timer); window.removeEventListener('computer:bookmarks-changed', load) } }, [load])
const isOwned = useCallback((b: Bookmark) => !currentUserId || b.ownerId === currentUserId, [currentUserId])
const cardsByColumn = useMemo(() => {
const groups: Record<string, Bookmark[]> = {}
for (const col of columns) groups[col.id] = []
for (const b of bookmarks) {
const columnId = columnFor(b, columns)
if (columnId && groups[columnId]) groups[columnId].push(b)
}
for (const list of Object.values(groups)) list.sort((a, b) => sortCards(a, b, currentUserId))
return groups
}, [bookmarks, columns, currentUserId])
const clearDrag = useCallback(() => {
setDraggedId(null)
setDraggedColumnId(null)
setCardDrop(null)
setColumnDropId(null)
}, [])
const finishDrag = useCallback(() => {
clearDrag()
// Drag gestures can still emit a click in some browsers; do not open the
// detail sheet when the pointer was dragging a card.
setTimeout(() => { suppressClick.current = false }, 0)
}, [clearDrag])
const saveColumnOrder = useCallback(async (next: Column[]) => {
setColumns(next)
const res = await fetch('/api/kanban/columns', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order: next.map(c => c.id) }) })
if (res.ok) invalidateReference('columns')
else { toast.error('Could not reorder columns'); load() }
}, [load])
const reorderColumn = useCallback((sourceId: string, targetId: string) => {
if (sourceId === targetId) return
const from = columns.findIndex(c => c.id === sourceId)
const to = columns.findIndex(c => c.id === targetId)
if (from < 0 || to < 0) return
const next = [...columns]
const [moved] = next.splice(from, 1)
// Drop before the target column; this is the least surprising direction
// for both leftward and rightward moves.
next.splice(from < to ? to - 1 : to, 0, moved)
saveColumnOrder(next)
}, [columns, saveColumnOrder])
// Position among the owner's cards, ignoring read-only shared cards and the
// card being dragged. The server normalizes the target column in this order.
const ownedDropIndex = useCallback((columnId: string, rawIndex: number, movedId: string) => {
const list = cardsByColumn[columnId] ?? []
let index = 0
for (let i = 0; i < Math.min(rawIndex, list.length); i++) {
const card = list[i]
if (card.id === movedId || !isOwned(card)) continue
index++
}
return index
}, [cardsByColumn, isOwned])
const moveCardTo = useCallback(async (bookmarkId: string, columnId: string, position: number) => {
const current = bookmarks.find(b => b.id === bookmarkId)
if (!current) return
if (currentUserId && current.ownerId !== currentUserId) {
toast.error('Only the owner can move a shared bookmark')
return
}
const target = (cardsByColumn[columnId] ?? []).filter(b => b.id !== bookmarkId && isOwned(b))
const clamped = Math.max(0, Math.min(position, target.length))
const optimistic = [...target]
optimistic.splice(clamped, 0, { ...current, kanbanColumnId: columnId })
const positions = new Map(optimistic.map((b, i) => [b.id, i]))
setBookmarks(prev => prev.map(b => {
if (b.id === bookmarkId) return { ...b, kanbanColumnId: columnId, kanbanPosition: clamped }
const next = positions.get(b.id)
return next === undefined ? b : { ...b, kanbanPosition: next }
}))
try {
const res = await fetch(`/api/kanban/cards/${bookmarkId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
// Optimistic concurrency (review CR-04): the move carries the card's
// version; a card edited or moved elsewhere is a 409, and the board
// reloads the true state instead of silently clobbering it.
body: JSON.stringify({ columnId, position: clamped, version: current.version ?? 0 }),
})
if (res.status === 409) {
toast.error('This bookmark was changed elsewhere — the board reloaded without your move')
load()
return
}
if (!res.ok) throw new Error(await res.text())
const saved = await res.json().catch(() => null)
if (saved) setBookmarks(prev => prev.map(b => b.id === bookmarkId ? saved : b))
} catch (e) { console.error(e); toast.error('Failed to move'); load() }
}, [bookmarks, cardsByColumn, currentUserId, isOwned, load])
const addColumn = async () => {
if (!newLabel.trim()) return
const res = await fetch('/api/kanban/columns', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: newLabel, color: PALETTE[columns.length % PALETTE.length] }),
})
if (res.ok) { const col = await res.json(); setColumns(c => [...c, col]); setNewLabel(''); setAdding(false); invalidateReference('columns') }
else toast.error('Could not add column')
}
const saveColumn = async (id: string, data: Partial<Column>) => {
const res = await fetch(`/api/kanban/columns/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
if (res.ok) { const col = await res.json(); setColumns(cs => cs.map(c => c.id === id ? col : c)); invalidateReference('columns') }
else toast.error('Could not update column')
setEditingId(null)
}
const deleteColumn = async (col: Column) => {
if (columns.length <= 1) { toast.error('A board needs at least one column'); return }
const dest = columns.find(c => c.id !== col.id)
if (!confirm(`Delete "${col.label}"? Its bookmarks will move to "${dest?.label}".`)) return
const res = await fetch(`/api/kanban/columns/${col.id}`, { method: 'DELETE' })
if (res.ok) { toast.success('Column deleted'); invalidateReference('columns'); load() } else toast.error((await res.json())?.error ?? 'Could not delete')
}
const moveColumn = (index: number, dir: -1 | 1) => {
const next = [...columns]
const j = index + dir
if (j < 0 || j >= next.length) return
;[next[index], next[j]] = [next[j], next[index]]
saveColumnOrder(next)
}
// Keyboard alternative to drag-and-drop: focus a card, Arrow Left/Right
// moves it to the adjacent column.
const moveCard = (b: Bookmark, dir: -1 | 1) => {
if (!isOwned(b)) { toast.error('Only the owner can move a shared bookmark'); return }
const current = columnFor(b, columns)
const idx = columns.findIndex(c => c.id === current)
const j = idx + dir
if (idx < 0 || j < 0 || j >= columns.length) return
moveCardTo(b.id, columns[j].id, cardsByColumn[columns[j].id]?.filter(c => c.id !== b.id && isOwned(c)).length ?? 0)
toast.success(`Moved to ${columns[j].label}`)
}
const handleCardDragOver = (e: React.DragEvent<HTMLElement>, col: Column, index: number) => {
if (!draggedId || draggedColumnId) return
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
const rect = e.currentTarget.getBoundingClientRect()
const before = e.clientY < rect.top + rect.height / 2
setCardDrop({ columnId: col.id, index: before ? index : index + 1 })
}
const handleColumnDragOver = (e: React.DragEvent<HTMLElement>, col: Column) => {
if (draggedColumnId) {
if (draggedColumnId === col.id) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setColumnDropId(col.id)
setCardDrop(null)
return
}
if (!draggedId) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
// The pointer is over the column body, not a card: append to the end.
setCardDrop(prev => prev?.columnId === col.id ? prev : { columnId: col.id, index: (cardsByColumn[col.id] ?? []).length })
}
const handleColumnDrop = (e: React.DragEvent<HTMLElement>, col: Column) => {
e.preventDefault()
if (draggedColumnId) {
reorderColumn(draggedColumnId, col.id)
finishDrag()
return
}
if (draggedId) {
const rawIndex = cardDrop?.columnId === col.id ? cardDrop.index : (cardsByColumn[col.id] ?? []).length
moveCardTo(draggedId, col.id, ownedDropIndex(col.id, rawIndex, draggedId))
finishDrag()
}
}
const handleColumnDragLeave = (e: React.DragEvent<HTMLElement>) => {
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return
if (draggedColumnId) setColumnDropId(null)
if (draggedId) setCardDrop(null)
}
return (
<div className="space-y-6">
<div className="flex items-end justify-between gap-4">
<div>
<h1 className="font-display text-2xl font-bold tracking-tight">Kanban Board</h1>
<p className="text-sm text-muted-foreground mt-1">Drag cards within or between columns, drag a column by its grip to reorder it, or focus a card and press Arrow Left/Right. Touch users can use each card’s menu.</p>
</div>
{!adding ? (
<Button size="sm" variant="outline" className="gap-1.5" onClick={() => setAdding(true)}><Plus className="h-4 w-4" /> Add column</Button>
) : (
<div className="flex items-center gap-1.5">
<Input autoFocus value={newLabel} onChange={e => setNewLabel(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addColumn(); if (e.key === 'Escape') setAdding(false) }} placeholder="Column name" className="h-8 w-40" />
<Button size="icon-sm" onClick={addColumn}><Check className="h-4 w-4" /></Button>
<Button size="icon-sm" variant="ghost" onClick={() => setAdding(false)}><X className="h-4 w-4" /></Button>
</div>
)}
</div>
<div className="flex gap-4 overflow-x-auto pb-2 h-[calc(100vh-180px)]">
{columns.map((col, idx) => {
const colBookmarks = cardsByColumn[col.id] ?? []
const isCardTarget = !!draggedId && cardDrop?.columnId === col.id
const isColumnTarget = !!draggedColumnId && columnDropId === col.id && draggedColumnId !== col.id
return (
<div
key={col.id}
data-column-id={col.id}
className={cn(
'rounded-lg border bg-muted/30 flex flex-col w-72 shrink-0 transition-[box-shadow,background-color,border-color] duration-150',
isCardTarget && 'bg-primary/[0.04] border-primary/30 shadow-sm',
isColumnTarget && 'ring-2 ring-primary/50 border-primary/40',
)}
style={{ borderTopColor: col.color, borderTopWidth: 3 }}
onDragOver={e => handleColumnDragOver(e, col)}
onDragLeave={handleColumnDragLeave}
onDrop={e => handleColumnDrop(e, col)}
>
<div className="px-3 py-2 border-b flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<span
draggable
onDragStart={e => {
setDraggedColumnId(col.id)
setDraggedId(null)
setCardDrop(null)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', col.id)
}}
onDragEnd={finishDrag}
className="cursor-grab active:cursor-grabbing text-muted-foreground/40 hover:text-muted-foreground transition-colors"
title="Drag to reorder column"
aria-label={`Drag ${col.label} column`}
>
<GripVertical className="h-4 w-4" />
</span>
{editingId === col.id ? (
<Input autoFocus value={editLabel} onChange={e => setEditLabel(e.target.value)} onBlur={() => saveColumn(col.id, { label: editLabel })}
onKeyDown={e => { if (e.key === 'Enter') saveColumn(col.id, { label: editLabel }); if (e.key === 'Escape') setEditingId(null) }} className="h-7 text-sm" />
) : (
<h3 className="text-sm font-medium truncate flex items-center gap-2">
<span className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: col.color }} />{col.label}
</h3>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<span className="text-xs text-muted-foreground bg-muted rounded-full px-2 py-0.5">{colBookmarks.length}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon-sm" variant="ghost" aria-label={`Column ${col.label} options`}><MoreHorizontal className="h-4 w-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => { setEditingId(col.id); setEditLabel(col.label) }}><Pencil className="h-3.5 w-3.5 mr-2" />Rename</DropdownMenuItem>
<DropdownMenuItem disabled={idx === 0} onClick={() => moveColumn(idx, -1)}><ArrowLeft className="h-3.5 w-3.5 mr-2" />Move left</DropdownMenuItem>
<DropdownMenuItem disabled={idx === columns.length - 1} onClick={() => moveColumn(idx, 1)}><ArrowRight className="h-3.5 w-3.5 mr-2" />Move right</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="flex gap-1.5 px-2 py-1.5">
{PALETTE.map(c => (
<button key={c} aria-label={`Color ${c}`} onClick={() => saveColumn(col.id, { color: c })} className="h-4 w-4 rounded-full ring-offset-1 hover:ring-2 ring-ring" style={{ backgroundColor: c }} />
))}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={() => deleteColumn(col)}><Trash2 className="h-3.5 w-3.5 mr-2" />Delete column</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{loading ? (
Array.from({ length: 3 }).map((_, i) => <div key={i} className="h-20 rounded-lg bg-muted/50 animate-pulse" />)
) : colBookmarks.length === 0 ? (
<div className={cn('flex h-24 items-center justify-center rounded-lg border border-dashed text-xs text-muted-foreground transition-colors', isCardTarget && 'border-primary/50 bg-primary/[0.03] text-primary')}>
{draggedId ? 'Drop card here' : 'No cards yet'}
</div>
) : colBookmarks.map((b, index) => {
const canMove = isOwned(b)
const showLine = isCardTarget && cardDrop?.index === index
const currentColumn = columnFor(b, columns)
return (
<Fragment key={b.id}>
{showLine && <div className="mx-1 h-1 rounded-full bg-primary/70 shadow-sm" />}
<div
draggable={canMove}
role="button"
tabIndex={0}
data-bookmark-id={b.id}
aria-label={canMove
? `${b.title ?? 'Untitled'}. Enter opens details; Arrow Left or Right moves to the adjacent column.`
: `${b.title ?? 'Untitled'} (shared). Enter opens details.`}
onDragStart={e => {
if (!canMove) { e.preventDefault(); return }
suppressClick.current = true
setDraggedId(b.id)
setDraggedColumnId(null)
setColumnDropId(null)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', b.id)
}}
onDragEnd={finishDrag}
onDragOver={e => handleCardDragOver(e, col, index)}
onClick={() => { if (!suppressClick.current) setSelectedBookmark(b) }}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedBookmark(b) }
else if (canMove && e.key === 'ArrowLeft') { e.preventDefault(); moveCard(b, -1) }
else if (canMove && e.key === 'ArrowRight') { e.preventDefault(); moveCard(b, 1) }
}}
className={cn(
'rounded-lg border bg-card p-3 shadow-sm transition-[transform,box-shadow,opacity] duration-150 group focus-visible:outline-2 focus-visible:outline-primary',
canMove ? 'cursor-grab active:cursor-grabbing hover:-translate-y-0.5 hover:shadow-md' : 'cursor-default',
draggedId === b.id && 'opacity-40 scale-[0.98]',
)}
>
<div className="flex items-start gap-2">
<GripVertical className={cn('h-4 w-4 text-muted-foreground/50 mt-0.5 shrink-0', !canMove && 'opacity-30')} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{b.favicon && <img src={b.favicon} alt="" referrerPolicy="no-referrer" className="h-3.5 w-3.5 rounded" onError={(e: any) => { e.target.style.display = 'none' }} />}
<p className="text-sm font-medium truncate">{b.title ?? 'Untitled'}</p>
{!canMove && <Badge variant="outline" className="text-[9px] px-1 py-0 shrink-0">Shared</Badge>}
</div>
<div className="flex flex-wrap gap-1 mt-1.5">
{(b.tags ?? []).slice(0, 2).map((bt: any) => (
<Badge key={bt?.tag?.id} variant="secondary" className="text-[9px] px-1 py-0" style={{ backgroundColor: `${bt?.tag?.color}20`, color: bt?.tag?.color }}>{bt?.tag?.name}</Badge>
))}
</div>
{b.dueDate && (
<p className="text-[10px] text-muted-foreground mt-1 flex items-center gap-1">
<Calendar className="h-3 w-3" />
{new Date(b.dueDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })}
</p>
)}
</div>
<div className="flex items-center gap-0.5 opacity-60 group-hover:opacity-100 focus-within:opacity-100 shrink-0" onDragStart={e => { e.preventDefault(); e.stopPropagation() }}>
<a href={b.url} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()} className="rounded p-0.5" aria-label="Open link">
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground hover:text-primary" />
</a>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon-sm" variant="ghost" className="h-6 w-6" onClick={e => e.stopPropagation()} aria-label={`${b.title ?? 'Bookmark'} options`}><MoreHorizontal className="h-3.5 w-3.5" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setSelectedBookmark(b)}><ExternalLink className="h-3.5 w-3.5 mr-2" />Open details</DropdownMenuItem>
{canMove && columns.length > 1 && <DropdownMenuSeparator />}
{canMove && columns.filter(c => c.id !== currentColumn).map(c => (
<DropdownMenuItem key={c.id} onClick={() => moveCardTo(b.id, c.id, cardsByColumn[c.id]?.filter(x => x.id !== b.id && isOwned(x)).length ?? 0)}>
<span className="h-2 w-2 rounded-full mr-2 shrink-0" style={{ backgroundColor: c.color }} />Move to {c.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</Fragment>
)
})}
{!loading && isCardTarget && cardDrop?.index === colBookmarks.length && colBookmarks.length > 0 && (
<div className="mx-1 h-1 rounded-full bg-primary/70 shadow-sm" />
)}
</div>
</div>
)
})}
</div>
<BookmarkDetailSheet bookmark={selectedBookmark} onClose={() => setSelectedBookmark(null)} onUpdate={load} />
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { KanbanClient } from './client'
export default function KanbanPage() {
return <KanbanClient />
}
+11
View File
@@ -0,0 +1,11 @@
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { AppShellWrapper } from '@/components/app-shell-wrapper'
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await auth()
if (!session?.user) redirect('/login')
// Pass the server session down so the shell and screens render during SSR
// instead of being discarded and refetched on the client.
return <AppShellWrapper session={session}>{children}</AppShellWrapper>
}
+699
View File
@@ -0,0 +1,699 @@
'use client'
import { useCallback, useEffect, useRef, useState, type ClipboardEvent } from 'react'
import { useRouter } from 'next/navigation'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { toast } from 'sonner'
import {
ChevronLeft, ChevronRight, Code, Download, Eye, FileText, GripVertical, Heading1, Heading2, Heading3,
Link2, List, ListOrdered, ListTodo, Minus, Pencil, Pin, PinOff, Plus, Quote, Search, Trash2, Type, X,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { PageAutosave, PageConflictError, type PageSaveState } from '@/lib/page-autosave'
import { PAGES_CHANGED, nextUntitledTitle, notifyActivePage, notifyPagesChanged, type PageSummary } from '@/lib/pages'
import { reorderPages } from '@/lib/page-order'
import { fetchLinkPreview } from '@/lib/link-preview'
import { LinkPreviewCard } from '@/components/link-preview-card'
import type { Element, ElementContent } from 'hast'
export { PAGES_CHANGED }
// Notion-style slash menu: typing "/" at the start of a line offers blocks
// that insert their Markdown equivalent. The textarea stays the source of
// truth, so autosave and conflict handling are untouched.
const SLASH_BLOCKS = [
{ key: 'text', label: 'Text', desc: 'Plain paragraph', icon: Type, prefix: '' },
{ key: 'h1', label: 'Heading 1', desc: 'Large section heading', icon: Heading1, prefix: '# ' },
{ key: 'h2', label: 'Heading 2', desc: 'Medium section heading', icon: Heading2, prefix: '## ' },
{ key: 'h3', label: 'Heading 3', desc: 'Small section heading', icon: Heading3, prefix: '### ' },
{ key: 'todo', label: 'To-do list', desc: 'Track tasks with a checkbox', icon: ListTodo, prefix: '- [ ] ' },
{ key: 'bullet', label: 'Bulleted list', desc: 'Simple bulleted list', icon: List, prefix: '- ' },
{ key: 'num', label: 'Numbered list', desc: 'List with numbering', icon: ListOrdered, prefix: '1. ' },
{ key: 'quote', label: 'Quote', desc: 'Capture a quote', icon: Quote, prefix: '> ' },
{ key: 'div', label: 'Divider', desc: 'Visual section break', icon: Minus, prefix: '---\n' },
{ key: 'code', label: 'Code', desc: 'Code snippet block', icon: Code, prefix: '```\n' },
] as const
type LinkedBookmark = { id: string; url: string; title: string | null; favicon: string | null; ogImage: string | null; ogTitle: string | null; ogDescription: string | null; description: string | null }
type PageDetail = PageSummary & { content: string; version: number; bookmarks: { bookmark: LinkedBookmark }[] }
const notify = notifyPagesChanged
const host = (u: string) => { try { return new URL(u).hostname.replace(/^www\./, '') } catch { return u } }
const textOf = (nodes: ElementContent[]): string =>
nodes.map((n) => (n.type === 'text' ? n.value : n.type === 'element' ? textOf(n.children) : '')).join('')
// A paragraph whose only content is a single external link — a bare URL
// autolinked by GFM or a lone [label](url) — renders as a Notion-style
// preview card instead of a plain <p><a>. Links woven into sentences stay
// inline text; the Markdown source remains the source of truth either way.
function standaloneLink(node: Element | undefined): { href: string; label: string } | null {
if (!node) return null
const kids = node.children.filter((c) => !(c.type === 'text' && c.value.trim() === ''))
if (kids.length !== 1) return null
const a = kids[0]
if (a.type !== 'element' || a.tagName !== 'a') return null
const href = a.properties?.href
if (typeof href !== 'string' || !/^https?:\/\//i.test(href)) return null
return { href, label: textOf(a.children) }
}
export function PagesClient({ initialId }: { initialId: string | null }) {
const router = useRouter()
const [pages, setPages] = useState<PageSummary[]>([])
const [activeId, setActiveId] = useState<string | null>(initialId)
const [page, setPage] = useState<PageDetail | null>(null)
const [filter, setFilter] = useState('')
const [mode, setMode] = useState<'edit' | 'preview'>('preview')
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [saveStates, setSaveStates] = useState<Record<string, PageSaveState>>({})
const [linkQuery, setLinkQuery] = useState('')
const [results, setResults] = useState<LinkedBookmark[]>([])
const [linkOpen, setLinkOpen] = useState(false)
const [slash, setSlash] = useState<{ query: string; start: number } | null>(null)
const [slashIndex, setSlashIndex] = useState(0)
const [tabDrop, setTabDrop] = useState<{ id: string; after: boolean } | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const pendingCaret = useRef<number | null>(null)
const suppressTabClick = useRef(false)
const draggedTabRef = useRef<string | null>(null)
const activePage = useRef(initialId)
const loadSequence = useRef(0)
const selectionSequence = useRef(0)
// Optimistic concurrency (review DATA-01): PageAutosave tracks the server
// revision per page and sends it with content saves, so a newer save from
// another tab or device answers 409 instead of being silently overwritten.
const persistContent = useCallback(async (id: string, content: string, version: number | undefined) => {
const body = JSON.stringify(typeof version === 'number' ? { content, version } : { content })
let res: Response
try {
res = await fetch(`/api/pages/${id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body,
keepalive: new TextEncoder().encode(body).length < 60000,
})
} catch (error) {
toast.error('Could not save page. Your draft is kept in this tab; retry saving.')
throw error
}
if (res.status === 409) {
// Another session saved first. Review DATA-04: the queue enters a
// paused conflict state instead of adopting the remote version, so
// nothing overwrites the newer content until the user explicitly
// chooses to overwrite, discard, or copy the draft.
let serverVersion: number | undefined
try {
const latest = await fetch(`/api/pages/${id}`, { cache: 'no-store' }).then(r => (r.ok ? r.json() : null))
if (latest && typeof latest.version === 'number') serverVersion = latest.version
} catch { /* keep the stale base; the conflict state pauses autosave either way */ }
toast.error('This page was saved in another tab or device. Autosave is paused so nothing is overwritten until you resolve the conflict.')
throw new PageConflictError(serverVersion)
}
if (!res.ok) {
toast.error('Could not save page. Your draft is kept in this tab; retry saving.')
throw new Error('Save failed')
}
const saved = await res.json().catch(() => null)
return saved && typeof saved.version === 'number' ? saved.version : undefined
}, [])
const [autosave] = useState(() => new PageAutosave(persistContent, (id, state) => setSaveStates(previous => ({ ...previous, [id]: state }))))
const saveState = activeId ? saveStates[activeId] ?? 'saved' : 'saved'
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
if (autosave.hasUnsaved()) { event.preventDefault(); event.returnValue = '' }
}
const flush = () => { void autosave.flushAll() }
window.addEventListener('beforeunload', beforeUnload)
window.addEventListener('pagehide', flush)
return () => {
window.removeEventListener('beforeunload', beforeUnload)
window.removeEventListener('pagehide', flush)
flush()
}
}, [autosave])
const loadPages = useCallback(async () => {
const res = await fetch('/api/pages')
if (!res.ok) return [] as PageSummary[]
const data: PageSummary[] = await res.json()
setPages(data)
return data
}, [])
const loadPage = useCallback(async (id: string) => {
const sequence = ++loadSequence.current
const res = await fetch(`/api/pages/${id}`)
if (sequence !== loadSequence.current || activePage.current !== id) return
if (!res.ok) { setPage(null); notifyActivePage(null); return }
const data: PageDetail = await res.json()
if (sequence !== loadSequence.current || activePage.current !== id) return
if (typeof data.version === 'number') autosave.setVersion(data.id, data.version)
// Review DATA-03: recovery compares the draft's original base version
// with the freshly fetched server version; a mismatch pauses autosave in
// the conflict state instead of silently rebasing onto the newer save.
const draft = autosave.restore(id, data.version)
setPage(data)
setTitle(data.title)
setContent(draft ?? data.content)
notifyActivePage(data.title)
if (draft !== undefined || !data.content) setMode('edit')
}, [autosave])
useEffect(() => {
const timer = setTimeout(() => loadPages().then((data) => {
if (!activePage.current && data.length) { activePage.current = data[0].id; setActiveId(data[0].id) }
}).catch((e) => console.error('Failed to load pages', e)), 0)
return () => clearTimeout(timer)
}, [loadPages])
useEffect(() => {
const timer = setTimeout(() => {
if (activeId) loadPage(activeId).catch((e) => console.error('Failed to load page', e))
else setPage(null)
}, 0)
return () => clearTimeout(timer)
}, [activeId, loadPage])
const select = async (id: string) => {
const sequence = ++selectionSequence.current
if (activePage.current && !await autosave.flush(activePage.current)) return
if (sequence !== selectionSequence.current) return
activePage.current = id
setActiveId(id)
router.replace(`/pages?id=${id}`)
}
// The sidebar can create or open pages while this workspace stays mounted.
useEffect(() => {
if (!initialId || initialId === activePage.current) return
void loadPages()
void select(initialId)
}, [initialId])
// Sidebar mutations (rename, pin, delete) announce themselves; keep the
// page list in step and drop the detail view when the open page is gone.
useEffect(() => {
const reload = () => {
void loadPages().then((data) => {
if (activePage.current && !data.some((p) => p.id === activePage.current)) {
activePage.current = null
setActiveId(null)
setPage(null)
notifyActivePage(null)
router.replace('/pages')
}
})
}
window.addEventListener(PAGES_CHANGED, reload)
return () => window.removeEventListener(PAGES_CHANGED, reload)
}, [loadPages, router])
// Leaving the pages workspace clears the breadcrumb title.
useEffect(() => () => { notifyActivePage(null) }, [])
const patch = async (body: Record<string, unknown>) => {
if (!activeId || page?.id !== activeId) return null
const res = await fetch(`/api/pages/${page.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
if (!res.ok) { toast.error('Could not save page'); return null }
// Review CR-17: metadata responses (rename/pin/link changes) carry the
// CURRENT content revision but never require or acknowledge a content
// base — adopting it here would silently rebase an in-flight stale draft
// onto another editor's save and authorize overwriting it. Only content
// saves (persistContent) and fresh page loads may move the version map.
return res
}
const createPage = async () => {
if (activePage.current && !await autosave.flush(activePage.current)) return
const res = await fetch('/api/pages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: nextUntitledTitle(pages.map((p) => p.title)) }) })
if (!res.ok) { toast.error('Could not create page'); return }
const p = await res.json()
await loadPages()
notify()
setMode('edit')
select(p.id)
}
const onContentChange = (v: string) => {
if (!page || page.id !== activeId) return
setContent(v)
autosave.edit(page.id, v)
}
// Show the block menu only while the caret sits on a bare "/token" line.
const updateSlash = (text: string, caret: number) => {
const lineStart = text.lastIndexOf('\n', caret - 1) + 1
const line = text.slice(lineStart, caret)
const m = /^\/([\w-]*)$/.exec(line)
if (m) { setSlash({ query: m[1].toLowerCase(), start: lineStart }); setSlashIndex(0) }
else setSlash(null)
}
const slashFiltered = SLASH_BLOCKS.filter((b) => !slash?.query || b.key.includes(slash.query) || b.label.toLowerCase().includes(slash.query))
const applyBlock = (block: (typeof SLASH_BLOCKS)[number]) => {
const ta = textareaRef.current
if (!slash || !ta) return
const caret = ta.selectionStart
const next = content.slice(0, slash.start) + block.prefix + content.slice(caret)
pendingCaret.current = slash.start + block.prefix.length
onContentChange(next)
setSlash(null)
}
// Restore the caret after a programmatic content rewrite (slash insert,
// "[] " shortcut). Runs once the new content has reached the textarea.
useEffect(() => {
if (pendingCaret.current === null || mode !== 'edit') return
const ta = textareaRef.current
if (!ta) return
const c = pendingCaret.current
pendingCaret.current = null
ta.focus()
ta.setSelectionRange(c, c)
}, [content, mode])
const saveTitle = async () => {
if (!page) return
const t = title.trim()
// Keep the generated "Untitled N" name until the user gives a real one.
if (!t) { setTitle(page.title); return }
if (t === page.title) return
if (await patch({ title: t })) {
setPage(current => current?.id === page.id ? { ...current, title: t } : current)
notifyActivePage(t)
await loadPages(); notify()
}
}
const togglePin = async () => {
if (!page) return
if (await patch({ pinned: !page.pinned })) {
setPage(current => current?.id === page.id ? { ...current, pinned: !page.pinned } : current)
await loadPages(); notify()
}
}
const move = async (dir: -1 | 1) => {
if (!activeId) return
const idx = pages.findIndex((p) => p.id === activeId)
const target = pages[idx + dir]
if (idx < 0 || !target) return
const next = reorderPages(pages, activeId, target.id, dir > 0)
if (next === 'blocked') { toast.error('Pinned and unpinned pages keep separate groups'); return }
if (next) await savePageOrder(next)
}
const savePageOrder = async (next: PageSummary[]) => {
setPages(next)
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'); await loadPages(); return }
await loadPages()
notify()
}
const dropTab = (target: PageSummary, after: boolean) => {
const source = draggedTabRef.current
draggedTabRef.current = null
setTabDrop(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 download = async () => {
if (!activeId) return
// A paused conflict can never flush; exporting the server copy is still
// useful, so only ordinary save failures block the download.
if (autosave.conflictVersion(activeId) === null && !await autosave.flush(activeId)) return
const a = document.createElement('a')
a.href = `/api/pages/${activeId}/markdown`
a.download = ''
document.body.appendChild(a); a.click(); a.remove()
}
// Explicit conflict resolutions (review DATA-04). Typing is never consent;
// each path is a deliberate click.
const overwriteRemote = async () => {
if (!activeId) return
if (await autosave.resolveOverwrite(activeId)) toast.success('Your draft replaced the newer version')
else toast.error('Could not save page. Your draft is kept in this tab; retry saving.')
}
const discardDraft = async () => {
if (!activeId) return
autosave.discard(activeId)
await loadPage(activeId)
}
const copyDraft = async () => {
const res = await fetch('/api/pages', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: `${page?.title ?? 'Untitled'} (draft copy)`, content }),
})
if (!res.ok) { toast.error('Could not create the copy'); return }
toast.success('Draft copied to a new page')
await loadPages(); notify()
}
const remove = async () => {
if (!page || !confirm(`Delete page "${page.title}"? Bookmarks are kept.`)) return
if (!await autosave.flush(page.id)) return
const res = await fetch(`/api/pages/${page.id}`, { method: 'DELETE' })
if (!res.ok) { toast.error('Could not delete page'); return }
autosave.forget(page.id)
const data = await loadPages(); notify()
if (data.length) select(data[0].id)
else { activePage.current = null; setActiveId(null); router.replace('/pages') }
}
useEffect(() => {
if (!linkOpen) return
const q = linkQuery.trim()
const t = setTimeout(async () => {
try {
const params = new URLSearchParams({ limit: '8' })
if (q) params.set('search', q)
const res = await fetch(`/api/bookmarks?${params}`)
if (res.ok) setResults((await res.json()).slice(0, 8))
} catch { /* transient; retried on next keystroke */ }
}, 250)
return () => clearTimeout(t)
}, [linkQuery, linkOpen])
const addBookmark = async (bookmarkId: string) => {
if (activeId && !await autosave.flush(activeId)) return
if (await patch({ addBookmarkId: bookmarkId })) {
setLinkOpen(false); setLinkQuery('')
if (activeId) await loadPage(activeId)
await loadPages()
}
}
const addUrl = async () => {
let url = linkQuery.trim()
if (!url) return
if (!/^https?:\/\//i.test(url)) url = `https://${url}`
const res = await fetch('/api/bookmarks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) })
if (!res.ok) { toast.error('Could not save URL'); return }
const b = await res.json()
await addBookmark(b.id)
toast.success('Link added')
}
const removeBookmark = async (bookmarkId: string) => {
if (await patch({ removeBookmarkId: bookmarkId })) {
if (page) setPage({ ...page, bookmarks: page.bookmarks.filter((x) => x.bookmark.id !== bookmarkId) })
await loadPages()
}
}
// Notion-style paste: a bare URL lands in the textarea untouched, and once
// its preview resolves we offer to upgrade it to a titled Markdown link.
// Nothing is rewritten without an explicit click, so undo stays intact.
const offerTitleForPastedUrl = (url: string) => {
void fetchLinkPreview(url).then((p) => {
const raw = p?.ogTitle || p?.title
if (!raw) return
const title = raw.replace(/[\[\]\n]/g, ' ').trim()
if (!title || title === url) return
toast.info('Paste as titled link?', {
description: title,
duration: 10000,
action: {
label: 'Use title',
onClick: () => {
const ta = textareaRef.current
if (!ta) return
const idx = ta.value.indexOf(url)
if (idx < 0) return
const markdown = `[${title}](${url})`
pendingCaret.current = idx + markdown.length
onContentChange(ta.value.slice(0, idx) + markdown + ta.value.slice(idx + url.length))
},
},
})
})
}
const onPaste = (e: ClipboardEvent<HTMLTextAreaElement>) => {
const text = e.clipboardData.getData('text/plain').trim()
if (/^https?:\/\/\S+$/i.test(text)) offerTitleForPastedUrl(text)
}
const visible = pages.filter((p) => p.title.toLowerCase().includes(filter.toLowerCase()))
const looksLikeUrl = /^(https?:\/\/)?[\w-]+(\.[\w-]+)+/i.test(linkQuery.trim())
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-border/60 px-4 py-2">
<div className="relative w-40 shrink-0">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Filter pages" className="h-8 pl-7 text-sm" />
</div>
<div className="flex flex-1 items-center gap-1 overflow-x-auto">
{visible.map((p) => (
<div
key={p.id}
data-page-tab={p.id}
onDragOver={(e) => {
if (!draggedTabRef.current || draggedTabRef.current === p.id) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const rect = e.currentTarget.getBoundingClientRect()
setTabDrop({ id: p.id, after: e.clientX > rect.left + rect.width / 2 })
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setTabDrop(null)
}}
onDrop={(e) => {
e.preventDefault()
const rect = e.currentTarget.getBoundingClientRect()
dropTab(p, e.clientX > rect.left + rect.width / 2)
}}
title={filter.trim() ? 'Clear the filter to reorder pages' : 'Drag the grip to reorder page'}
className={cn(
'group/tab flex shrink-0 items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors',
p.id === activeId ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground',
tabDrop?.id === p.id && 'ring-1 ring-primary/50',
)}
>
{!filter.trim() && (
<span
data-page-tab-drag
draggable
onDragStart={(e) => {
suppressTabClick.current = true
draggedTabRef.current = p.id
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', p.id)
}}
onDragEnd={() => {
draggedTabRef.current = null
setTabDrop(null)
setTimeout(() => { suppressTabClick.current = false }, 0)
}}
className="cursor-grab text-muted-foreground/40 opacity-0 transition-opacity hover:text-muted-foreground active:cursor-grabbing group-hover/tab:opacity-100"
aria-label={`Drag page ${p.title}`}
>
<GripVertical className="h-3 w-3" />
</span>
)}
<button
type="button"
onClick={() => { if (!suppressTabClick.current) select(p.id) }}
aria-label={p.title}
className="flex min-w-0 items-center gap-1.5"
>
{p.pinned ? <Pin className="h-3 w-3" /> : <FileText className="h-3 w-3" />}
<span className="max-w-[10rem] truncate">{p.title}</span>
</button>
</div>
))}
</div>
<Button size="sm" onClick={createPage}><Plus className="mr-1 h-4 w-4" />New page</Button>
</div>
{!page || page.id !== activeId ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center">
<FileText className="h-10 w-10 text-muted-foreground" />
<h2 className="font-display text-xl font-semibold">{pages.length ? 'Select a page' : 'No pages yet'}</h2>
<p className="max-w-sm text-sm text-muted-foreground">Pages are Markdown notes that collect links into a workspace. A bookmark can live on many pages.</p>
<Button onClick={createPage}><Plus className="mr-1 h-4 w-4" />Create a page</Button>
</div>
) : (
<div className="mx-auto w-full max-w-4xl flex-1 overflow-y-auto px-6 py-8">
<div className="mb-6 flex flex-wrap items-center gap-2">
<input value={title} onChange={(e) => setTitle(e.target.value)} onBlur={saveTitle} onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()} className="min-w-0 flex-1 bg-transparent font-display text-3xl font-bold outline-none" aria-label="Page title" />
<span className="text-xs text-muted-foreground">{saveState === 'saving' ? 'Saving…' : saveState === 'dirty' ? 'Unsaved' : saveState === 'conflict' ? 'Conflict' : 'Saved'}</span>
{saveState === 'dirty' && <Button size="sm" variant="outline" onClick={() => autosave.flush(page.id)}>Save now</Button>}
<Button variant="ghost" size="icon-sm" title={mode === 'edit' ? 'Preview' : 'Edit'} onClick={() => { setMode(mode === 'edit' ? 'preview' : 'edit'); setSlash(null) }}>{mode === 'edit' ? <Eye className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}</Button>
<Button variant="ghost" size="icon-sm" title={page.pinned ? 'Unpin' : 'Pin to sidebar'} onClick={togglePin}>{page.pinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}</Button>
<Button variant="ghost" size="icon-sm" title="Move tab left" onClick={() => move(-1)}><ChevronLeft className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon-sm" title="Move tab right" onClick={() => move(1)}><ChevronRight className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon-sm" title="Download .md" onClick={download}><Download className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon-sm" title="Delete page" onClick={remove}><Trash2 className="h-4 w-4 text-destructive" /></Button>
</div>
{saveState === 'conflict' && (
<div role="alert" className="mb-4 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm">
<p className="font-medium text-foreground">This page was changed elsewhere</p>
<p className="mt-1 text-muted-foreground">
Your local draft is based on an older version — or its base version is unknown —
so autosave is paused and nothing is overwritten until you choose. Your draft
stays in the editor below.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="destructive" onClick={overwriteRemote}>Overwrite with my draft</Button>
<Button size="sm" variant="outline" onClick={copyDraft}>Save my draft as a new page</Button>
<Button size="sm" variant="outline" onClick={discardDraft}>Discard my draft and load latest</Button>
</div>
</div>
)}
{mode === 'edit' ? (
<div className="relative">
<Textarea
ref={textareaRef}
value={content}
onChange={(e) => { onContentChange(e.target.value); updateSlash(e.target.value, e.target.selectionStart) }}
onClick={(e) => updateSlash(e.currentTarget.value, e.currentTarget.selectionStart)}
onPaste={onPaste}
onBlur={() => setSlash(null)}
onKeyDown={(e) => {
// While the slash menu is open, arrows/Enter/Tab/Escape drive it.
if (slash && slashFiltered.length > 0) {
if (e.key === 'ArrowDown') { e.preventDefault(); setSlashIndex((i) => (i + 1) % slashFiltered.length); return }
if (e.key === 'ArrowUp') { e.preventDefault(); setSlashIndex((i) => (i - 1 + slashFiltered.length) % slashFiltered.length); return }
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); applyBlock(slashFiltered[Math.min(slashIndex, slashFiltered.length - 1)]); return }
if (e.key === 'Escape') { e.preventDefault(); setSlash(null); return }
}
// Notion Markdown shortcut: "[]" + space becomes a to-do item.
if (e.key === ' ') {
const ta = e.currentTarget
const caret = ta.selectionStart
const lineStart = ta.value.lastIndexOf('\n', caret - 1) + 1
if (ta.value.slice(lineStart, caret) === '[]') {
e.preventDefault()
const next = ta.value.slice(0, lineStart) + '- [ ] ' + ta.value.slice(caret)
pendingCaret.current = lineStart + '- [ ] '.length
onContentChange(next)
}
}
}}
placeholder="Write Markdown notes… type / for blocks, paste links, lists, thoughts."
className="min-h-[260px] font-mono text-sm"
/>
{slash && slashFiltered.length > 0 && (
<div
onMouseDown={(e) => e.preventDefault()}
role="listbox"
aria-label="Blocks to insert"
className="absolute left-2 top-full z-20 mt-1 w-72 rounded-lg border bg-popover p-1 shadow-lg"
>
<p className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Basic blocks</p>
<div className="max-h-64 overflow-y-auto">
{slashFiltered.map((b, i) => (
<button
key={b.key}
type="button"
role="option"
aria-selected={i === slashIndex}
onMouseEnter={() => setSlashIndex(i)}
onClick={() => applyBlock(b)}
className={cn('flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left', i === slashIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50')}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded border bg-card">
<b.icon className="h-3.5 w-3.5 text-muted-foreground" />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{b.label}</span>
<span className="block truncate text-xs text-muted-foreground">{b.desc}</span>
</span>
</button>
))}
</div>
</div>
)}
</div>
) : (
<div className="prose prose-sm max-w-none dark:prose-invert min-h-[80px] cursor-text" onDoubleClick={() => setMode('edit')}>
{content ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ node, ...props }) => {
const link = standaloneLink(node)
return link ? (
<LinkPreviewCard url={link.href} label={link.label !== link.href ? link.label : undefined} />
) : (
<p {...props} />
)
},
a: ({ node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
}}
>{content}</ReactMarkdown>
) : (
<p className="text-muted-foreground">No notes yet. Double-click or press the pencil to write.</p>
)}
</div>
)}
<div className="mt-10 mb-3 flex items-center justify-between">
<h3 className="font-display text-lg font-semibold">Links on this page <span className="text-sm font-normal text-muted-foreground">({page.bookmarks.length})</span></h3>
<Popover open={linkOpen} onOpenChange={setLinkOpen}>
<PopoverTrigger asChild><Button size="sm" variant="outline"><Link2 className="mr-1 h-4 w-4" />Add link</Button></PopoverTrigger>
<PopoverContent align="end" className="w-80 p-2">
<Input autoFocus value={linkQuery} onChange={(e) => setLinkQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && looksLikeUrl && addUrl()} placeholder="Search bookmarks or paste a URL" className="h-8 text-sm" />
<div className="mt-2 max-h-64 overflow-y-auto">
{looksLikeUrl && (
<button onClick={addUrl} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted"><Plus className="h-3.5 w-3.5" />Save &amp; add “{linkQuery.trim()}”</button>
)}
{results.filter((r) => !page.bookmarks.some((x) => x.bookmark.id === r.id)).map((r) => (
<button key={r.id} onClick={() => addBookmark(r.id)} className="flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-muted">
<span className="truncate text-sm">{r.title || r.ogTitle || r.url}</span>
<span className="truncate text-xs text-muted-foreground">{host(r.url)}</span>
</button>
))}
{!looksLikeUrl && results.length === 0 && <p className="px-2 py-3 text-center text-xs text-muted-foreground">No matching bookmarks</p>}
</div>
</PopoverContent>
</Popover>
</div>
{page.bookmarks.length === 0 ? (
<div className="rounded-lg bg-muted/40 p-8 text-center text-sm text-muted-foreground">No links yet. Add existing bookmarks or paste a new URL.</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{page.bookmarks.map(({ bookmark: b }) => (
<div key={b.id} className="group relative flex gap-3 rounded-lg bg-card p-3 shadow-sm ring-1 ring-border/50 transition-shadow hover:shadow-md">
{b.ogImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={b.ogImage} alt={`Preview of ${b.title || host(b.url)}`} referrerPolicy="no-referrer" className="h-16 w-24 shrink-0 rounded object-cover bg-muted" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
) : null}
<a href={b.url} target="_blank" rel="noopener noreferrer" className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
{b.favicon && (
// eslint-disable-next-line @next/next/no-img-element
<img src={b.favicon} alt="" referrerPolicy="no-referrer" className="h-3.5 w-3.5" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
)}
<span className="truncate text-sm font-medium">{b.title || b.ogTitle || b.url}</span>
</div>
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{b.description || b.ogDescription || ''}</p>
<p className="mt-1 text-[11px] text-muted-foreground/80">{host(b.url)}</p>
</a>
<button onClick={() => removeBookmark(b.id)} title="Remove from page" className="absolute right-1.5 top-1.5 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted group-hover:opacity-100"><X className="h-3.5 w-3.5" /></button>
</div>
))}
</div>
)}
</div>
)}
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
import { PagesClient } from './client'
export default async function PagesPage({ searchParams }: { searchParams: Promise<{ id?: string }> }) {
const { id } = await searchParams
return <PagesClient initialId={id ?? null} />
}
+205
View File
@@ -0,0 +1,205 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useSession } from 'next-auth/react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { User, Mail, Server, Cloud, Shield, Check, X, AlertTriangle } from 'lucide-react'
import { toast } from 'sonner'
import { invalidateReference } from '@/lib/reference-data'
import type { OidcStatus } from '@/lib/oidc-config'
export function SettingsClient({ oidc, callbackUrl }: { oidc: OidcStatus; callbackUrl: string }) {
const { data: session } = useSession()
const [imapConfig, setImapConfig] = useState({ host: '', port: 993, tls: true, username: '', password: '', folder: 'INBOX' })
const [ncConfig, setNcConfig] = useState({ nextcloudUrl: '', cardDavPath: '', username: '', password: '' })
const [savingImap, setSavingImap] = useState(false)
const [testingImap, setTestingImap] = useState(false)
const [syncingContacts, setSyncingContacts] = useState(false)
useEffect(() => {
fetch('/api/settings/imap').then(r => r.json()).then(data => {
if (data) setImapConfig(prev => ({ ...prev, host: data.host ?? '', port: data.port ?? 993, tls: data.tls ?? true, username: data.username ?? '', folder: data.folder ?? 'INBOX' }))
}).catch(() => {})
}, [])
// Every action clears its spinner in finally and tolerates non-JSON or
// failed network responses, so no state can stick after an outage.
const saveImap = async () => {
setSavingImap(true)
try {
const res = await fetch('/api/settings/imap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// tls is forced on: legacy stored configs with tls=false migrate to
// verified TLS on the next save (review NET-02).
body: JSON.stringify({ ...imapConfig, tls: true }),
})
if (res.ok) toast.success('IMAP settings saved')
else toast.error((await res.json().catch(() => null))?.error ?? 'Failed to save')
} catch { toast.error('Failed to save') }
finally { setSavingImap(false) }
}
const testImap = async () => {
setTestingImap(true)
try {
const res = await fetch('/api/imap/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...imapConfig, tls: true }),
})
const data = await res.json().catch(() => null)
if (res.ok && data?.ok) toast.success('Connection successful!')
else toast.error(data?.error ?? 'Connection failed')
} catch { toast.error('Connection failed') }
finally { setTestingImap(false) }
}
const syncContacts = async () => {
if (!ncConfig.nextcloudUrl || !ncConfig.username || !ncConfig.password) {
toast.error('Fill in Nextcloud URL, username, and password')
return
}
setSyncingContacts(true)
try {
const res = await fetch('/api/contacts/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ncConfig),
})
const data = await res.json().catch(() => null)
if (res.ok && data?.synced !== undefined) {
// Review CR-07: the sync mirrors the address book — remotely deleted
// contacts are removed too (scoped to this source, never from partial
// responses). Surface both counts so the policy is visible.
const removed = typeof data.removed === 'number' && data.removed > 0 ? `, removed ${data.removed} deleted on the server` : ''
toast.success(`Synced ${data.synced} contacts${removed}`)
invalidateReference('contacts')
}
else toast.error(data?.error ?? 'Sync failed')
} catch { toast.error('Sync failed') }
finally { setSyncingContacts(false) }
}
return (
<div className="max-w-2xl space-y-6">
<div>
<h1 className="font-display text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-sm text-muted-foreground mt-1">Configure your integrations and preferences</p>
</div>
<Tabs defaultValue="profile" className="w-full">
<TabsList className="w-full justify-start">
<TabsTrigger value="profile" className="gap-1.5"><User className="h-3.5 w-3.5" /> Profile</TabsTrigger>
<TabsTrigger value="imap" className="gap-1.5"><Mail className="h-3.5 w-3.5" /> IMAP</TabsTrigger>
<TabsTrigger value="nextcloud" className="gap-1.5"><Cloud className="h-3.5 w-3.5" /> Nextcloud</TabsTrigger>
<TabsTrigger value="oidc" className="gap-1.5"><Shield className="h-3.5 w-3.5" /> OIDC</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="space-y-4 mt-4">
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-full bg-primary/20 flex items-center justify-center text-2xl font-bold text-primary">
{(session?.user?.name ?? session?.user?.email ?? '?')[0]?.toUpperCase()}
</div>
<div>
<p className="font-medium">{session?.user?.name ?? 'User'}</p>
<p className="text-sm text-muted-foreground" suppressHydrationWarning>{session?.user?.email}</p>
</div>
</div>
</TabsContent>
<TabsContent value="imap" className="space-y-4 mt-4">
<div className="rounded-lg border p-4 space-y-3">
<h3 className="font-medium flex items-center gap-2"><Server className="h-4 w-4" /> IMAP Configuration</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Host</label>
<Input value={imapConfig.host} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setImapConfig(p => ({ ...p, host: e.target.value }))} placeholder="imap.example.com" />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Port</label>
<Input type="number" value={imapConfig.port} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setImapConfig(p => ({ ...p, port: parseInt(e.target.value) || 993 }))} />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Username</label>
<Input value={imapConfig.username} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setImapConfig(p => ({ ...p, username: e.target.value }))} />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Password</label>
<Input type="password" value={imapConfig.password} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setImapConfig(p => ({ ...p, password: e.target.value }))} />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Folder</label>
<Input value={imapConfig.folder} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setImapConfig(p => ({ ...p, folder: e.target.value }))} />
</div>
<div className="flex items-end">
{/* Review NET-02: mailbox logins always run over verified TLS;
the backend rejects insecure settings, so there is no toggle. */}
<p className="text-sm text-muted-foreground">TLS is required (implicit TLS, port 993 by default)</p>
</div>
</div>
<div className="flex gap-2">
<Button onClick={testImap} variant="outline" loading={testingImap}>Test Connection</Button>
<Button onClick={saveImap} loading={savingImap}>Save IMAP Settings</Button>
</div>
</div>
</TabsContent>
<TabsContent value="nextcloud" className="space-y-4 mt-4">
<div className="rounded-lg border p-4 space-y-3">
<h3 className="font-medium flex items-center gap-2"><Cloud className="h-4 w-4" /> Nextcloud / CardDAV</h3>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="text-xs font-medium text-muted-foreground">Nextcloud URL</label>
<Input value={ncConfig.nextcloudUrl} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNcConfig(p => ({ ...p, nextcloudUrl: e.target.value }))} placeholder="https://nextcloud.example.com" />
</div>
<div className="col-span-2">
<label className="text-xs font-medium text-muted-foreground">CardDAV Path (optional)</label>
<Input value={ncConfig.cardDavPath} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNcConfig(p => ({ ...p, cardDavPath: e.target.value }))} placeholder="/remote.php/dav/addressbooks/users/USER/contacts/" />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Username</label>
<Input value={ncConfig.username} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNcConfig(p => ({ ...p, username: e.target.value }))} />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">App Password</label>
<Input type="password" value={ncConfig.password} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNcConfig(p => ({ ...p, password: e.target.value }))} />
</div>
</div>
<Button onClick={syncContacts} loading={syncingContacts}>Sync Contacts Now</Button>
</div>
</TabsContent>
<TabsContent value="oidc" className="space-y-4 mt-4">
<div className="rounded-lg border p-4 space-y-3">
<h3 className="font-medium flex items-center gap-2"><Shield className="h-4 w-4" /> OIDC Configuration</h3>
<p className="text-sm text-muted-foreground">Authelia single sign-on, configured via environment variables (read-only)</p>
<div className={`flex items-start gap-2 rounded-md p-3 text-sm ${oidc.enabled ? 'bg-green-500/10 text-green-700 dark:text-green-400' : 'bg-amber-500/10 text-amber-800 dark:text-amber-300'}`}>
{oidc.enabled ? <Check className="h-4 w-4 mt-0.5 shrink-0" /> : <AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />}
<span>
{oidc.enabled
? <>Configured for <span className="font-mono break-all">{oidc.issuer}</span>. Client integration is still pending.</>
: <>Not connected. Authelia sign-in is disabled until all three variables below hold real values. Users can sign in with email and password meanwhile. Setup guide: <span className="font-mono">docs/AUTHELIA.md</span></>}
</span>
</div>
<div className="space-y-2">
{([['OIDC_ISSUER', oidc.issuerSet], ['OIDC_CLIENT_ID', oidc.clientIdSet], ['OIDC_CLIENT_SECRET', oidc.clientSecretSet]] as const).map(([name, set]) => (
<div key={name} className="flex items-center gap-2">
<span className="text-xs font-mono bg-muted px-2 py-1 rounded">{name}</span>
<span className="text-sm text-muted-foreground">{set ? 'Set' : 'Not set'}</span>
{set ? <Check className="h-4 w-4 text-green-500" /> : <X className="h-4 w-4 text-muted-foreground" />}
</div>
))}
</div>
<div className="space-y-1 pt-2 border-t">
<p className="text-xs font-medium text-muted-foreground">Redirect URI to register in Authelia</p>
<p className="text-xs font-mono bg-muted px-2 py-1.5 rounded break-all" suppressHydrationWarning>{callbackUrl}</p>
</div>
</div>
</TabsContent>
</Tabs>
</div>
)
}
+24
View File
@@ -0,0 +1,24 @@
import { SettingsClient } from './client'
import { getOidcStatus } from '@/lib/oidc-config'
import { headers } from 'next/headers'
export const dynamic = 'force-dynamic'
export default async function SettingsPage() {
// The redirect URI shown for Authelia registration derives from the same
// configured origin auth.ts trusts — never from client-controllable request
// headers in production (header poisoning could hand an admin an
// attacker-hosted callback URL to register).
const envUrl = process.env.NEXTAUTH_URL || process.env.AUTH_URL || ''
let callbackUrl = '/api/auth/callback/authelia'
try {
if (envUrl) callbackUrl = `${new URL(envUrl).origin}/api/auth/callback/authelia`
} catch { /* keep the relative fallback */ }
if (!envUrl && process.env.NODE_ENV !== 'production') {
const h = await headers()
const host = h.get('x-forwarded-host') ?? h.get('host') ?? ''
const proto = h.get('x-forwarded-proto') ?? 'http'
if (host) callbackUrl = `${proto}://${host}/api/auth/callback/authelia`
}
return <SettingsClient oidc={getOidcStatus()} callbackUrl={callbackUrl} />
}
+170
View File
@@ -0,0 +1,170 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { BookmarkCard } from '@/components/bookmark-card'
import { BookmarkDetailSheet } from '@/components/bookmark-detail-sheet'
import { Plus, Tags, Trash2, Edit, Check, X } from 'lucide-react'
import { toast } from 'sonner'
import type { Bookmark, TagWithCount } from '@/components/app-shell'
import { fetchBookmarks } from '@/lib/bookmarks'
import { invalidateReference } from '@/lib/reference-data'
export function TagsClient({ initialTag = null }: { initialTag?: string | null }) {
const [tags, setTags] = useState<TagWithCount[]>([])
const [selectedTag, setSelectedTag] = useState<string | null>(null)
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [selectedBookmark, setSelectedBookmark] = useState<Bookmark | null>(null)
const [newName, setNewName] = useState('')
const [newColor, setNewColor] = useState('#6366f1')
const [editingId, setEditingId] = useState<string | null>(null)
const [editName, setEditName] = useState('')
const [editColor, setEditColor] = useState('#6366f1')
const [loading, setLoading] = useState(true)
const [effectiveId, setEffectiveId] = useState('')
const [shareQuery, setShareQuery] = useState('')
const [shareUsers, setShareUsers] = useState<any[]>([])
useEffect(() => { fetch('/api/me').then(r => r.json()).then(me => setEffectiveId(me.effectiveUser?.id || '')).catch(() => {}) }, [])
// Debounced user search; failures keep the previous results silently.
useEffect(() => {
const q = shareQuery.trim()
const t = setTimeout(async () => {
if (q.length < 2) { setShareUsers([]); return }
try {
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`)
if (res.ok) setShareUsers(await res.json())
} catch { /* transient; retried on next keystroke */ }
}, q.length < 2 ? 0 : 250)
return () => clearTimeout(t)
}, [shareQuery])
const loadTags = useCallback(async () => {
// Every tag mutation reloads through here; keep the detail sheet's cache
// in step so new/renamed tags appear in its picker immediately.
invalidateReference('tags')
try {
const res = await fetch('/api/tags')
if (res.ok) setTags(await res.json())
} catch { toast.error('Could not load tags') }
}, [])
const loadBookmarks = useCallback(async (tagId: string) => {
setLoading(true)
try { setBookmarks(await fetchBookmarks(new URLSearchParams({ tagId }))) }
catch { toast.error('Could not load bookmarks') }
finally { setLoading(false) }
}, [])
useEffect(() => { const timer = setTimeout(loadTags, 0); return () => clearTimeout(timer) }, [loadTags])
// Quick Find deep-links to a tag via /tags?tag=<id>.
useEffect(() => {
if (!initialTag) return
const timer = setTimeout(() => setSelectedTag(initialTag), 0)
return () => clearTimeout(timer)
}, [initialTag])
useEffect(() => { const timer = setTimeout(() => { if (selectedTag) loadBookmarks(selectedTag) }, 0); return () => clearTimeout(timer) }, [selectedTag, loadBookmarks])
useEffect(() => { const reload = () => { loadTags(); if (selectedTag) loadBookmarks(selectedTag) }; window.addEventListener('computer:bookmarks-changed', reload); return () => window.removeEventListener('computer:bookmarks-changed', reload) }, [loadTags, loadBookmarks, selectedTag])
const createTag = async () => {
if (!newName) return
const res = await fetch('/api/tags', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName, color: newColor }) })
if (res.ok) { toast.success('Tag created'); setNewName(''); loadTags() }
else toast.error('Failed')
}
const updateTag = async (id: string) => {
const response = await fetch(`/api/tags/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: editName, color: editColor }) })
if (!response.ok) { toast.error('Could not update tag'); return }
setEditingId(null)
loadTags()
}
const deleteTag = async (id: string) => {
if (!confirm('Delete this tag?')) return
const response = await fetch(`/api/tags/${id}`, { method: 'DELETE' })
if (!response.ok) { toast.error('Could not delete tag'); return }
toast.success('Tag deleted')
if (selectedTag === id) setSelectedTag(null)
loadTags()
}
return (
<div className="flex gap-6 h-[calc(100vh-120px)]">
<div className="w-64 shrink-0 space-y-4 overflow-y-auto">
<h1 className="font-display text-2xl font-bold tracking-tight">Tags</h1>
<div className="flex gap-1.5">
<Input placeholder="New tag" value={newName} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewName(e.target.value)} className="flex-1" />
<input type="color" value={newColor} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewColor(e.target.value)} className="h-9 w-9 rounded border cursor-pointer" />
<Button size="icon-sm" onClick={createTag}><Plus className="h-4 w-4" /></Button>
</div>
<div className="space-y-1">
{tags.map((t: TagWithCount) => (
<div
key={t.id}
role="button"
tabIndex={0}
aria-pressed={selectedTag === t.id}
className={`flex items-center gap-2 rounded-lg px-3 py-2 cursor-pointer transition-colors focus-visible:outline-2 focus-visible:outline-primary ${
selectedTag === t.id ? 'bg-accent' : 'hover:bg-muted'
}`}
onClick={() => setSelectedTag(t.id)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedTag(t.id) } }}
>
{editingId === t.id ? (
<>
<input type="color" value={editColor} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEditColor(e.target.value)} className="h-5 w-5 rounded cursor-pointer" />
<Input value={editName} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEditName(e.target.value)} className="h-7 text-xs flex-1" />
<button onClick={() => updateTag(t.id)}><Check className="h-3 w-3 text-green-500" /></button>
<button onClick={() => setEditingId(null)}><X className="h-3 w-3" /></button>
</>
) : (
<>
<div className="h-3 w-3 rounded-full shrink-0" style={{ backgroundColor: t.color }} />
<span className="text-sm font-medium flex-1 truncate">{t.name}</span>
<span className="text-xs text-muted-foreground">{t._count?.bookmarks ?? 0}</span>
{t.ownerId === effectiveId ? <><button aria-label="Edit tag" onClick={(e: React.MouseEvent) => { e.stopPropagation(); setEditingId(t.id); setEditName(t.name); setEditColor(t.color) }}><Edit className="h-3 w-3 text-muted-foreground" /></button>
<button aria-label="Delete tag" onClick={(e: React.MouseEvent) => { e.stopPropagation(); deleteTag(t.id) }}><Trash2 className="h-3 w-3 text-muted-foreground hover:text-destructive" /></button></> : <span className="text-xs text-muted-foreground">Shared</span>}
</>
)}
</div>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{selectedTag ? (
<div className="space-y-4">
<h2 className="font-medium">Bookmarks tagged: <Badge style={{ backgroundColor: `${tags.find(t => t.id === selectedTag)?.color ?? '#6366f1'}20`, color: tags.find(t => t.id === selectedTag)?.color }}>{tags.find(t => t.id === selectedTag)?.name}</Badge></h2>
{tags.find(t => t.id === selectedTag)?.ownerId === effectiveId && <div className="space-y-2 rounded border p-3">
<p className="text-xs text-muted-foreground">Sharing is group-wide: every recipient can see all bookmarks under this tag — including ones other recipients add.</p>
<Input placeholder="Share this tag with a user…" value={shareQuery} onChange={e => setShareQuery(e.target.value)} />
{shareUsers.map(u => <Button key={u.id} size="sm" variant="outline" onClick={async () => { const res = await fetch(`/api/tags/${selectedTag}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ shareUserIds: [u.id] }) }); if (!res.ok) { toast.error('Could not share tag'); return } toast.success('Tag shared'); setShareUsers([]); setShareQuery(''); loadTags() }}>Share with {u.name || u.email}</Button>)}
{(tags.find(t => t.id === selectedTag)?.sharedWith || []).map(share => <Button key={share.userId} size="xs" variant="ghost" onClick={async () => { const res = await fetch(`/api/tags/${selectedTag}/share/${share.userId}`, { method: 'DELETE' }); if (!res.ok) { toast.error('Could not stop sharing'); return } loadTags() }}>Stop sharing with {share.user?.name || share.user?.email || `${share.userId.slice(0, 8)}…`}</Button>)}
</div>}
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => <div key={i} className="h-32 rounded-lg bg-muted animate-pulse" />)}
</div>
) : bookmarks.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">No bookmarks with this tag</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{bookmarks.map((b: Bookmark) => <BookmarkCard key={b.id} bookmark={b} compact onClick={() => setSelectedBookmark(b)} />)}
</div>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-center">
<Tags className="h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">Select a tag to view its bookmarks</p>
</div>
)}
</div>
<BookmarkDetailSheet bookmark={selectedBookmark} onClose={() => setSelectedBookmark(null)} onUpdate={() => { if (selectedTag) loadBookmarks(selectedTag) }} />
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
import { TagsClient } from './client'
// Quick Find deep-links to a tag via /tags?tag=<id>.
export default async function TagsPage({ searchParams }: { searchParams: Promise<{ tag?: string }> }) {
const { tag } = await searchParams
return <TagsClient initialTag={tag ?? null} />
}