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
+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>
)
}