'use client' import { useCallback, useEffect, useRef, 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, Share2, 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; version: number } type CollectionItem = { id: string; collectionId: string; bookmarkId: string propertyValues: Record; position: number; version: number addedAt: string; updatedAt: string; bookmark: Bookmark } type CollectionShare = { userId: string; name: string | null; email: string | null; createdAt?: string } type CollectionSummary = { id: string; name: string; description: string | null; icon: string | null version: number; createdAt: string; updatedAt: string shared: boolean owner?: { id: string; name: string | null; email: string | null; image: string | null } sharedWith?: CollectionShare[] _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 = { 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 = { 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 { 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 ( {bookmark.favicon // eslint-disable-next-line @next/next/no-img-element -- remote favicons stay no-referrer (web/AGENTS.md) ? : } {bookmark.title || bookmark.url} {host} ) } const asText = (v: unknown) => (typeof v === 'string' || typeof v === 'number' ? String(v) : '') function plainValue(prop: CollectionProperty, value: unknown): string { if (prop.type === 'CHECKBOX') return value === true ? 'Yes' : 'No' if (prop.type === 'SELECT') return prop.config.options?.find((o) => o.id === value)?.label ?? '—' if (prop.type === 'MULTI_SELECT') { if (!Array.isArray(value)) return '—' return value.map((id) => prop.config.options?.find((o) => o.id === id)?.label).filter(Boolean).join(', ') || '—' } if (prop.type === 'DATE' && typeof value === 'string' && value) { if (prop.config.mode === 'date') return value.slice(0, 10) const date = new Date(value) return Number.isNaN(date.getTime()) ? value : date.toLocaleString('en-US', { timeZone: 'UTC' }) } if (value === null || value === undefined || value === '') return '—' return String(value) } // 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. Shared collections // are read-only: the same cell renders plain text. function ValueEditor({ prop, value, onSave, readOnly = false }: { prop: CollectionProperty; value: unknown; onSave: (v: unknown) => Promise; readOnly?: boolean }) { 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) if (readOnly) return {plainValue(prop, value)} 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 { void onSave(c === true) }} /> case 'SELECT': { const options = prop.config.options ?? [] return ( ) } 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 ( {options.map((o) => ( ))} {options.length === 0 &&

No options yet

}
) } case 'DATE': return prop.config.mode === 'datetime' ? { const v = e.target.value; void onSave(v ? new Date(v).toISOString() : null) }} /> : { void onSave(e.target.value || null) }} /> case 'NUMBER': case 'TEXT': case 'URL': default: return ( 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([]) const [activeId, setActiveId] = useState(null) const [detail, setDetail] = useState(null) const [items, setItems] = useState([]) const [cursor, setCursor] = useState(null) const [draggedItemId, setDraggedItemId] = useState(null) const [boardDropKey, setBoardDropKey] = useState(null) const [viewId, setViewId] = useState(null) const [newName, setNewName] = useState('') const [nameDraft, setNameDraft] = useState('') const [addOpen, setAddOpen] = useState(false) const [addQuery, setAddQuery] = useState('') const [addResults, setAddResults] = useState([]) 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 [shareOpen, setShareOpen] = useState(false) const [shareQuery, setShareQuery] = useState('') const [shareResults, setShareResults] = useState<{ id: string; name: string | null; email: string }[]>([]) // Review M-11: switching collections/views quickly used to let an older // response overwrite the newer selection. Each loader has a request // sequence and only the latest may apply its result. const detailSeq = useRef(0) const itemsSeq = useRef(0) 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) => { const seq = ++detailSeq.current try { const res = await fetch(`/api/collections/${id}`) if (seq !== detailSeq.current) return if (!res.ok) { setDetail(null); return } const data: CollectionDetail = await res.json() if (seq !== detailSeq.current) return setDetail(data) setNameDraft(data.name) setViewId((current) => (current && data.views.some((v) => v.id === current) ? current : data.views[0]?.id ?? null)) } catch { if (seq === detailSeq.current) toast.error('Could not load collection') } }, []) const loadItems = useCallback(async (id: string, view: string | null, append?: string | null) => { const seq = ++itemsSeq.current 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 (seq !== itemsSeq.current) return if (!res.ok) return const rows: CollectionItem[] = await res.json() if (seq !== itemsSeq.current) return setItems((current) => (append ? [...current, ...rows] : rows)) setCursor(res.headers.get('X-Next-Cursor')) } catch { if (seq === itemsSeq.current) 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) { detailSeq.current++; setDetail(null); return } loadDetail(activeId) }, 0) return () => clearTimeout(t) }, [activeId, loadDetail]) useEffect(() => { const t = setTimeout(() => { if (!activeId) { itemsSeq.current++; 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 => { 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) => 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 // Shared collections are explicitly read-only: hiding mutation controls is // the UI half of the server's owner-only mutation gate. const readOnly = detail?.shared === true const shareCollection = async (userId: string) => { if (!detail) return const res = await fetch(`/api/collections/${detail.id}/share`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userIds: [userId] }), }) if (!res.ok) { toast.error('Could not share collection'); return } toast.success('Collection shared') setShareQuery(''); setShareResults([]) loadDetail(detail.id); loadCollections() } const unshareCollection = async (userId: string) => { if (!detail) return const res = await fetch(`/api/collections/${detail.id}/share/${userId}`, { method: 'DELETE' }) if (!res.ok) { toast.error('Could not revoke collection access'); return } toast.success('Access revoked') loadDetail(detail.id); loadCollections() } useEffect(() => { if (!shareOpen) return const q = shareQuery.trim() if (q.length < 2) return const t = setTimeout(async () => { try { const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`) if (res.ok) setShareResults(await res.json()) } catch { /* transient */ } }, 250) return () => clearTimeout(t) }, [shareOpen, shareQuery]) 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 || detail.shared) 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 || detail.shared || !confirm(`Move collection "${detail.name}" to the trash? Bookmarks are kept and it can be restored.`)) 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 = {} 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) => { 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 if (prop.type === 'DATE' && prop.config?.mode === 'datetime') { // Review M-14: datetime-local emits a local string; the server stores // fixed-width UTC. const parsed = new Date(fValue) value = Number.isNaN(parsed.getTime()) ? '' : parsed.toISOString() } 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 // Review M-14: MULTI_SELECT filters also carry an option id; show its // label like SELECT instead of the raw id. if ((prop.type === 'SELECT' || prop.type === 'MULTI_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 = () => (
Bookmark {(detail?.properties ?? []).map((p) => ( {p.name} {!readOnly && } ))} {items.map((item) => ( {(detail?.properties ?? []).map((p) => ( saveValue(item, p.id, v)} readOnly={readOnly} /> ))} {!readOnly && } ))} {items.length === 0 && ( No items{filterClauses.length ? " match this view's filters" : ''}. Add existing bookmarks — nothing is duplicated. )}
) const renderBoard = () => { if (!groupProp || groupProp.type !== 'SELECT') { return (

Board views group by a select property.

{!readOnly &&
}
) } 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 (
{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 (
{ 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) // This is a drop handler, not render; the compiler rule cannot // see through the async helper's captured state. // eslint-disable-next-line react-hooks/refs 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', )} >
{col.color && } {col.label} {colItems.length}
{colItems.map((item) => (
{!readOnly && { 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'}`} > }
{readOnly ? ( {plainValue(groupProp, item.propertyValues?.[groupProp.id])} ) : ( )} {!readOnly && }
))} {colItems.length === 0 && (

{isDropTarget ? 'Drop here' : 'Empty'}

)}
) })}
) } return (
{/* Collection list */}

Collections

setNewName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') createCollection() }} placeholder="New collection" className="h-8 text-sm" aria-label="New collection name" />
{collections.map((c) => ( ))} {collections.length === 0 && (

Collections hold workflow state per project. The same bookmark can live in many — nothing is duplicated.

)}
{/* Detail */}
{!detail ? (

{collections.length ? 'Select a collection' : 'No collections yet'}

A collection is a saved workflow over existing bookmarks: per-collection status, priority, dates — independent everywhere the link appears.

) : ( <>
{readOnly ? ( {detail.name} ) : ( 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" /> )} {detail._count?.items ?? 0} item{(detail._count?.items ?? 0) === 1 ? '' : 's'} {readOnly ? ( Shared by {detail.owner?.name || detail.owner?.email || 'another user'} · read-only ) : (
)}
{/* View tabs */}
{detail.views.map((v) => { const Icon = VIEW_ICONS[v.type] ?? Table2 return ( ) })} {!readOnly && } {activeView && !readOnly && (
{ setFilterOpen(o); if (o) { setFProp(detail.properties[0]?.id ?? ''); setFOp(FILTER_OPS[detail.properties[0]?.type ?? 'TEXT']?.[0] ?? 'eq') } }}> {!['empty', 'notEmpty'].includes(fOp) && (() => { const p = detail.properties.find((x) => x.id === fProp) if (p?.type === 'SELECT' || p?.type === 'MULTI_SELECT') return ( ) // Review M-14: DATE filters used to render a plain // text box, so a normal date always failed server // validation. Match the property editor's inputs. if (p?.type === 'DATE') return p.config?.mode === 'datetime' ? setFValue(e.target.value)} aria-label="Filter value" className="h-8 text-sm" /> : setFValue(e.target.value)} aria-label="Filter value" className="h-8 text-sm" /> if (p?.type === 'CHECKBOX') return ( ) return setFValue(e.target.value)} placeholder="Value" aria-label="Filter value" className="h-8 text-sm" /> })()}
)}
{/* Active filter chips */} {filterClauses.length > 0 && (
{activeView?.filters?.operator ?? 'AND'}: {filterClauses.map((c, i) => ( {clauseLabel(c)} {!readOnly && } ))}
)} {activeView?.type === 'BOARD' ? renderBoard() : renderTable()} {activeView && (activeView.type === 'CALENDAR' || activeView.type === 'LIST') && (

Calendar and list layouts render as a table in this phase.

)} {cursor && ( )} )}
{/* Add bookmarks dialog */} { setAddOpen(o); if (!o) setAddQuery('') }}> Add existing bookmarks

Membership is contextual — adding here never duplicates or re-owns the bookmark.

setAddQuery(e.target.value)} placeholder="Search bookmarks…" aria-label="Search bookmarks" />
{addResults.map((b) => { const member = items.some((i) => i.bookmarkId === b.id) return (

{b.title || b.url}

{b.url}

) })} {addResults.length === 0 &&

No bookmarks found

}
{/* New property dialog */} New property
setPropName(e.target.value)} placeholder="Name (e.g. Status)" aria-label="Property name" />

Type is permanent — conversions that could destroy values are refused by design.

{(propType === 'SELECT' || propType === 'MULTI_SELECT') && (
{propOptions.map((o, i) => (
setPropOptions(propOptions.map((x, j) => (j === i ? { ...x, color: e.target.value } : x)))} className="h-8 w-10 shrink-0 p-1" /> 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" />
))}
)} {propType === 'DATE' && ( )}
{/* New view dialog */} New view
setViewName(e.target.value)} placeholder="View name" aria-label="View name" />

A view is a saved query — filters and grouping never copy records.

{/* Share dialog */} { setShareOpen(o); if (!o) { setShareQuery(''); setShareResults([]) } }}> Share collection

Recipients get read-only access to this collection and the links you own inside it. Links you can only see through someone else's share are omitted, so sharing never passes a third-party grant on. They receive the same trimmed link view as other shared bookmarks; contacts, alerts and mailbox settings stay private. Revoking access takes effect immediately.

{detail && (
{(detail.sharedWith ?? []).map((s) => (
{s.name || s.email || s.userId}
))} {(detail.sharedWith?.length ?? 0) === 0 &&

Not shared with anyone yet.

}
)} setShareQuery(e.target.value)} placeholder="Search people by name or email" aria-label="Search people to share with" />
{shareResults.map((u) => { const already = detail?.sharedWith?.some((s) => s.userId === u.id) return (

{u.name || u.email}

{u.name &&

{u.email}

}
) })} {shareQuery.trim().length >= 2 && shareResults.length === 0 &&

No matching people

}
) }