'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([]) const [columns, setColumns] = useState([]) const [loading, setLoading] = useState(true) const [selectedBookmark, setSelectedBookmark] = useState(null) const [draggedId, setDraggedId] = useState(null) const [draggedColumnId, setDraggedColumnId] = useState(null) const [cardDrop, setCardDrop] = useState(null) const [columnDropId, setColumnDropId] = useState(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(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 = {} 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) => { 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, 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, 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, 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) => { if (e.currentTarget.contains(e.relatedTarget as Node | null)) return if (draggedColumnId) setColumnDropId(null) if (draggedId) setCardDrop(null) } return (

Kanban Board

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.

{!adding ? ( ) : (
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" />
)}
{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 (
handleColumnDragOver(e, col)} onDragLeave={handleColumnDragLeave} onDrop={e => handleColumnDrop(e, col)} >
{ 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`} > {editingId === col.id ? ( 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" /> ) : (

{col.label}

)}
{colBookmarks.length} { setEditingId(col.id); setEditLabel(col.label) }}>Rename moveColumn(idx, -1)}>Move left moveColumn(idx, 1)}>Move right
{PALETTE.map(c => (
deleteColumn(col)}>Delete column
{loading ? ( Array.from({ length: 3 }).map((_, i) =>
) ) : colBookmarks.length === 0 ? (
{draggedId ? 'Drop card here' : 'No cards yet'}
) : colBookmarks.map((b, index) => { const canMove = isOwned(b) const showLine = isCardTarget && cardDrop?.index === index const currentColumn = columnFor(b, columns) return ( {showLine &&
}
{ 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]', )} >
{b.favicon && { e.target.style.display = 'none' }} />}

{b.title ?? 'Untitled'}

{!canMove && Shared}
{(b.tags ?? []).slice(0, 2).map((bt: any) => ( {bt?.tag?.name} ))}
{b.dueDate && (

{new Date(b.dueDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })}

)}
{ e.preventDefault(); e.stopPropagation() }}> e.stopPropagation()} className="rounded p-0.5" aria-label="Open link"> setSelectedBookmark(b)}>Open details {canMove && columns.length > 1 && } {canMove && columns.filter(c => c.id !== currentColumn).map(c => ( moveCardTo(b.id, c.id, cardsByColumn[c.id]?.filter(x => x.id !== b.id && isOwned(x)).length ?? 0)}> Move to {c.label} ))}
) })} {!loading && isCardTarget && cardDrop?.index === colBookmarks.length && colBookmarks.length > 0 && (
)}
) })}
setSelectedBookmark(null)} onUpdate={load} />
) }