Files
brain/web/app/(app)/kanban/client.tsx
T
Andrew S Erwin b9721ff13f
release gate / check (push) Failing after 8m35s
initial commit on Federted servers
2026-10-19 16:23:55 -05:00

445 lines
24 KiB
TypeScript
Raw 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)
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>
)
}