This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { KanbanClient } from './client'
|
||||
|
||||
export default function KanbanPage() {
|
||||
return <KanbanClient />
|
||||
}
|
||||
Reference in New Issue
Block a user