Files
Andrew S Erwin 4e421f6ea2
release gate / check (push) Failing after 6m43s
Address code review findings across API, web and deployment
High severity:
- wire TRUSTED_PROXY through Compose/deploy/docs and warn at web startup
- bucket public rate limits on effective client IP, add per-bookmark budget,
  and forward the signed browser identity from the share page
- disable first-run bootstrap in server deployments and document seeding
- serialize last-admin demotion in one advisory-locked transaction

Medium/low:
- cap and batch share recipient inserts
- add a shared outbound budget for preview/CardDAV/IMAP/signal work
- pin CardDAV dials to 443 and bound IMAP logout
- fix rightward kanban column drags and normalize cards on column delete
- classify backend outages in auth guards, add request timeouts and an
  error boundary, remove the dead login route, surface throttling
- enforce pinned/unpinned page reorder grouping server-side
- avoid preview work for stale bookmark versions; drop duplicate dialog fetch
- sequence-guard collection/tag/calendar/quick-find loads
- validate property config and fix MULTI_SELECT/DATE filter inputs
- document index maintenance; apply same-origin check to sign-out
- trim signup names, unique shared dates and kanban keys (migration 010),
  reset reference cache on identity changes, hide OIDC issuer, return 413
  for oversized direct bodies, escape Markdown link destinations
- remove dead template files and duplicate test helper

Verified with go build/vet, full PostgreSQL race suite, db convergence,
tsc, both ESLint configs and a Next production build.
2026-10-20 00:13:30 -05:00

454 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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)
// Review M-4: a column drop needs a direction. "Before the target" made a
// rightward drag onto the adjacent column a no-op (the source was already
// before it), so the pointer's half of the target decides before/after.
const [columnDropAfter, setColumnDropAfter] = useState(false)
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)
setColumnDropAfter(false)
}, [])
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, afterTarget: boolean) => {
if (sourceId === targetId) return
const from = columns.findIndex(c => c.id === sourceId)
if (from < 0 || !columns.some(c => c.id === targetId)) return
const next = [...columns]
const [moved] = next.splice(from, 1)
// The target's index after removal, then before or after it according to
// the pointer half the drop reported.
let insertAt = next.findIndex(c => c.id === targetId)
if (insertAt < 0) return
if (afterTarget) insertAt += 1
next.splice(insertAt, 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'
const rect = e.currentTarget.getBoundingClientRect()
setColumnDropAfter(e.clientX > rect.left + rect.width / 2)
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, columnDropAfter)
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>
)
}