
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
Full kanban task board surface: 4 status columns, drag-and-drop card movement with insertion indicator, board/list view toggle, search + priority + assignee filters, collapsible columns, click-to-open task detail Sheet (comments, subtasks, files), full-form Add Task Dialog with priority/assignee/due-date/tags/subtasks. Mega-block: ships 14 React files (KanbanBoard + 6 kanban-* views + 7 small badges/lists). Bind with columns/onColumnsChange; consumer owns the columns array so swapping the in-memory store for Drizzle/Postgres is a one-line change. Auto-pulls the use-kanban hook (types + configs + helpers) and kanban-data lib (seed columns).
Also available for Vue ->$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/kanban-task-board.json$npx shadcn@latest add https://uipkge.dev/r/react/kanban-task-board.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/kanban-task-board.json$bunx shadcn@latest add https://uipkge.dev/r/react/kanban-task-board.jsonnpx shadcn@latest add @uipkge-react/kanban-task-boardInstalls to:components/blocks/kanban-task-board/| Name | Type / Values | Default | Required |
|---|---|---|---|
columns | KanbanColumnType[] | — | required |
onColumnsChange | (value: KanbanColumnType[]) => void | — | optional |
title | string | null | — | optional |
description | string | null | — | optional |
defaultColumnId | string | — | optional |
hideHeader | boolean | — | optional |
hideToolbar | boolean | — | optional |
lockParentScroll | boolean | — | optional |
Type aliases exported from this item's source. Use these to shape the data you pass in.
SimpleKanbanIteminterface SimpleKanbanItem {
id: string
title: string
description?: string
date?: string
priority?: 'low' | 'medium' | 'high' | 'urgent'
tag?: string
tagColor?: string
assignee?: {
name: string
initials?: string
avatar?: string
}
}SimpleKanbanColumninterface SimpleKanbanColumn {
id: string
title: string
color?: string
dotColor?: string
items: SimpleKanbanItem[]
}FilteredColumninterface FilteredColumn {
id: string
title: string
color: string
dotColor: string
tasks: KanbanTask[]
}FlatTaskinterface FlatTask {
task: KanbanTask
columnId: string
columnTitle: string
dotColor: string
}AddTaskForminterface AddTaskForm {
title: string
description: string
priority: KanbanTask['priority']
assigneeKey: keyof typeof assignees
tagKeys: (keyof typeof tagPresets)[]
subtaskTexts: string[]
}CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.
--border'use client'
import * as React from 'react'
import { Plus } from 'lucide-react'
import { type KanbanColumn as KanbanColumnType, type KanbanTask, findTaskById } from '@/lib/use-kanban'
import { PageHeader, PageHeaderHeading } from '@/components/ui/page'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { KanbanToolbar } from './KanbanToolbar'
import { KanbanColumn, type FilteredColumn } from './KanbanColumn'
import { KanbanListView } from './KanbanListView'
import { KanbanTaskSheet } from './KanbanTaskSheet'
import { KanbanAddTaskDialog } from './KanbanAddTaskDialog'
export { SimpleKanban, type SimpleKanbanProps, type SimpleKanbanColumn, type SimpleKanbanItem } from './SimpleKanban'
export interface KanbanBoardProps {
columns: KanbanColumnType[]
onColumnsChange?: (value: KanbanColumnType[]) => void
title?: string | null
description?: string | null
defaultColumnId?: string
hideHeader?: boolean
hideToolbar?: boolean
lockParentScroll?: boolean
}
export function KanbanBoard({
columns,
onColumnsChange,
title = 'Kanban Board',
description = 'Drag tasks across columns to update their status.',
defaultColumnId = 'backlog',
hideHeader = false,
hideToolbar = false,
lockParentScroll = true,
}: KanbanBoardProps) {
const kanbanEl = React.useRef<HTMLDivElement | null>(null)
React.useEffect(() => {
if (!lockParentScroll) return
const parentMain = kanbanEl.current?.closest('main[data-slot="sidebar-inset"]') as HTMLElement | null
if (parentMain) parentMain.style.overflow = 'hidden'
document.documentElement.style.overflow = 'hidden'
return () => {
if (parentMain) parentMain.style.overflow = ''
document.documentElement.style.overflow = ''
}
}, [lockParentScroll])
const [searchQuery, setSearchQuery] = React.useState('')
const [selectedPriority, setSelectedPriority] = React.useState<string | null>(null)
const [selectedAssignee, setSelectedAssignee] = React.useState<string | null>(null)
const [viewMode, setViewMode] = React.useState<'board' | 'list'>('board')
const [collapsedColumns, setCollapsedColumns] = React.useState<Set<string>>(new Set())
const [detailOpen, setDetailOpen] = React.useState(false)
const [detailTask, setDetailTask] = React.useState<KanbanTask | null>(null)
const [addTaskOpen, setAddTaskOpen] = React.useState(false)
const [addTaskColumnId, setAddTaskColumnId] = React.useState(defaultColumnId)
const draggedTaskRef = React.useRef<string | null>(null)
const dragOverColumnRef = React.useRef<string | null>(null)
const dropTargetIndexRef = React.useRef<number>(-1)
const lastDragEndRef = React.useRef(0)
const [draggedTask, setDraggedTask] = React.useState<string | null>(null)
const [dragOverColumn, setDragOverColumn] = React.useState<string | null>(null)
const [dropTargetIndex, setDropTargetIndex] = React.useState<number>(-1)
// Mutating helper: the consumer owns `columns`, so we clone, mutate the
// clone, and emit it back via onColumnsChange — mirroring the Vue v-model.
function commitColumns(mutator: (cols: KanbanColumnType[]) => void) {
const next = columns.map((c) => ({ ...c, tasks: [...c.tasks] }))
mutator(next)
onColumnsChange?.(next)
}
const filteredColumns = React.useMemo<FilteredColumn[]>(() => {
return columns.map((col) => ({
...col,
tasks: col.tasks.filter((task) => {
const q = searchQuery.toLowerCase()
const matchesSearch = !q || task.title.toLowerCase().includes(q) || task.id.toLowerCase().includes(q)
const matchesPriority = !selectedPriority || task.priority === selectedPriority
const matchesAssignee = !selectedAssignee || task.assignee.name === selectedAssignee
return matchesSearch && matchesPriority && matchesAssignee
}),
}))
}, [columns, searchQuery, selectedPriority, selectedAssignee])
const totalTasks = React.useMemo(() => columns.reduce((sum, col) => sum + col.tasks.length, 0), [columns])
function toggleCollapse(columnId: string) {
setCollapsedColumns((prev) => {
const next = new Set(prev)
if (next.has(columnId)) next.delete(columnId)
else next.add(columnId)
return next
})
}
function addComment(task: KanbanTask, text: string) {
commitColumns((cols) => {
const target = findTaskById(cols, task.id)
if (target) {
target.commentItems = [
...target.commentItems,
{
id: `c${Date.now()}`,
author: 'Admin User',
authorColor: 'bg-chart-1/15 text-chart-1',
text,
time: 'Just now',
},
]
}
})
}
function moveTask(task: KanbanTask, targetColumnId: string) {
commitColumns((cols) => {
const sourceCol = cols.find((c) => c.tasks.some((t) => t.id === task.id))
const targetCol = cols.find((c) => c.id === targetColumnId)
if (!sourceCol || !targetCol || sourceCol.id === targetColumnId) return
const taskIndex = sourceCol.tasks.findIndex((t) => t.id === task.id)
if (taskIndex === -1) return
const removed = sourceCol.tasks.splice(taskIndex, 1)
if (removed[0]) targetCol.tasks.push(removed[0])
})
}
function openTaskDetail(task: KanbanTask) {
setDetailTask(task)
setDetailOpen(true)
}
function onDragStart(event: React.DragEvent, taskId: string) {
draggedTaskRef.current = taskId
setDraggedTask(taskId)
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', taskId)
}
}
function resetDrag() {
draggedTaskRef.current = null
dragOverColumnRef.current = null
dropTargetIndexRef.current = -1
setDraggedTask(null)
setDragOverColumn(null)
setDropTargetIndex(-1)
lastDragEndRef.current = Date.now()
}
function onDrop() {
const dragged = draggedTaskRef.current
const overColumn = dragOverColumnRef.current
if (!dragged || !overColumn) {
resetDrag()
return
}
commitColumns((cols) => {
let sourceColIdx = -1
let taskIdx = -1
for (let c = 0; c < cols.length; c++) {
const col = cols[c]
if (!col) continue
const tIdx = col.tasks.findIndex((t) => t.id === dragged)
if (tIdx !== -1) {
sourceColIdx = c
taskIdx = tIdx
break
}
}
const targetColIdx = cols.findIndex((c) => c.id === overColumn)
if (sourceColIdx === -1 || targetColIdx === -1) return
const sourceCol = cols[sourceColIdx]
const targetCol = cols[targetColIdx]
if (!sourceCol || !targetCol) return
const [task] = sourceCol.tasks.splice(taskIdx, 1)
if (!task) return
let insertAt = dropTargetIndexRef.current
if (insertAt < 0) insertAt = targetCol.tasks.length
if (sourceColIdx === targetColIdx && taskIdx < insertAt) insertAt--
targetCol.tasks.splice(insertAt, 0, task)
})
resetDrag()
}
function onCardClick(task: KanbanTask) {
if (Date.now() - lastDragEndRef.current < 200) return
openTaskDetail(task)
}
function onCardDragOver(event: React.DragEvent, columnId: string, taskIndex: number) {
dragOverColumnRef.current = columnId
setDragOverColumn(columnId)
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const midY = rect.top + rect.height / 2
const idx = event.clientY < midY ? taskIndex : taskIndex + 1
dropTargetIndexRef.current = idx
setDropTargetIndex(idx)
}
function onLaneDragOver(_event: React.DragEvent, columnId: string, taskCount: number) {
dragOverColumnRef.current = columnId
setDragOverColumn(columnId)
dropTargetIndexRef.current = taskCount
setDropTargetIndex(taskCount)
}
function openAddTask(columnId: string) {
setAddTaskColumnId(columnId)
setAddTaskOpen(true)
}
function onCreateTask(columnId: string, tasks: KanbanTask[]) {
commitColumns((cols) => {
const col = cols.find((c) => c.id === columnId)
if (col) col.tasks.push(...tasks)
})
}
return (
<div
ref={kanbanEl}
data-slot="kanban-board"
className="kanban-page flex h-[calc(100dvh-3.5rem-3rem)] flex-col overflow-hidden lg:h-[calc(100dvh-3.5rem-3rem)]"
>
{!hideHeader && (
<div className="mb-3 shrink-0">
<PageHeader>
<div className="flex items-start justify-between gap-4">
<PageHeaderHeading title={title ?? ''} description={description ?? ''} />
<div className="flex shrink-0 items-center gap-2">
<Badge variant="secondary" className="font-mono text-xs tabular-nums">
{totalTasks} tasks
</Badge>
<Button size="sm" onClick={() => openAddTask(defaultColumnId)}>
<Plus className="size-4" />
Add Task
</Button>
</div>
</div>
</PageHeader>
</div>
)}
{!hideToolbar && (
<KanbanToolbar
searchQuery={searchQuery}
selectedPriority={selectedPriority}
selectedAssignee={selectedAssignee}
viewMode={viewMode}
onSearchQueryChange={setSearchQuery}
onSelectedPriorityChange={setSelectedPriority}
onSelectedAssigneeChange={setSelectedAssignee}
onViewModeChange={setViewMode}
/>
)}
{viewMode === 'board' ? (
<div className="kanban-board relative flex min-h-0 flex-1 items-start gap-3 overflow-auto pb-3">
{filteredColumns.map((column) => (
<KanbanColumn
key={column.id}
column={column}
collapsed={collapsedColumns.has(column.id)}
draggedTask={draggedTask}
dragOverColumn={dragOverColumn}
dropTargetIndex={dropTargetIndex}
allColumns={columns}
onToggleCollapse={toggleCollapse}
onAddTask={openAddTask}
onCardClick={onCardClick}
onCardQuickView={openTaskDetail}
onDragStart={onDragStart}
onDragEnd={resetDrag}
onCardDragOver={onCardDragOver}
onLaneDragOver={onLaneDragOver}
onDrop={onDrop}
/>
))}
</div>
) : (
<KanbanListView
columns={filteredColumns}
allColumns={columns}
onTaskClick={openTaskDetail}
onMoveTask={moveTask}
/>
)}
<KanbanTaskSheet
open={detailOpen}
task={detailTask}
columns={columns}
onOpenChange={setDetailOpen}
onMoveTask={moveTask}
onAddComment={addComment}
/>
<KanbanAddTaskDialog
open={addTaskOpen}
columns={columns}
initialColumnId={addTaskColumnId}
onOpenChange={setAddTaskOpen}
onCreate={onCreateTask}
/>
<style>{`
.kanban-board {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
.kanban-board::-webkit-scrollbar {
height: 6px;
width: 6px;
}
.kanban-board::-webkit-scrollbar-thumb {
background-color: hsl(var(--border));
border-radius: 3px;
}
.kanban-board::-webkit-scrollbar-corner {
background: transparent;
}
.kanban-card {
animation: card-in 0.25s ease-out both;
}
@keyframes card-in {
from {
opacity: 0;
transform: translateY(6px);
}
}
.kanban-card:hover .kanban-accent {
box-shadow: 0 0 3px currentColor;
}
.kanban-lane {
min-height: 60px;
}
.kanban-list {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
.kanban-list::-webkit-scrollbar {
height: 6px;
width: 6px;
}
.kanban-list::-webkit-scrollbar-thumb {
background-color: hsl(var(--border));
border-radius: 3px;
}
`}</style>
</div>
)
}
'use client'
import * as React from 'react'
import { Plus, Calendar, GripVertical, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
export interface SimpleKanbanItem {
id: string
title: string
description?: string
date?: string
priority?: 'low' | 'medium' | 'high' | 'urgent'
tag?: string
tagColor?: string
assignee?: {
name: string
initials?: string
avatar?: string
}
}
export interface SimpleKanbanColumn {
id: string
title: string
color?: string
dotColor?: string
items: SimpleKanbanItem[]
}
export interface SimpleKanbanProps {
columns: SimpleKanbanColumn[]
onColumnsChange?: (columns: SimpleKanbanColumn[]) => void
onCardClick?: (item: SimpleKanbanItem, columnId: string) => void
onCardMove?: (item: SimpleKanbanItem, fromColumnId: string, toColumnId: string, newIndex: number) => void
allowAdd?: boolean
onAddItem?: (columnId: string, title: string) => void
className?: string
cardClassName?: string
}
const DEFAULT_DOT_COLORS: Record<string, string> = {
backlog: 'bg-muted-foreground/60',
todo: 'bg-info',
'in-progress': 'bg-warning',
review: 'bg-chart-1',
done: 'bg-success',
}
const PRIORITY_STYLES: Record<string, { label: string; class: string }> = {
low: { label: 'Low', class: 'bg-muted text-muted-foreground' },
medium: { label: 'Medium', class: 'bg-info/10 text-info' },
high: { label: 'High', class: 'bg-warning/10 text-warning' },
urgent: { label: 'Urgent', class: 'bg-destructive/10 text-destructive font-medium' },
}
export function SimpleKanban({
columns,
onColumnsChange,
onCardClick,
onCardMove,
allowAdd = true,
onAddItem,
className,
cardClassName,
}: SimpleKanbanProps) {
const [draggedId, setDraggedId] = React.useState<string | null>(null)
const [dragOverColumnId, setDragOverColumnId] = React.useState<string | null>(null)
const [dropTargetIndex, setDropTargetIndex] = React.useState<number>(-1)
const [addingColumnId, setAddingColumnId] = React.useState<string | null>(null)
const [newTitle, setNewTitle] = React.useState('')
const lastDragTimeRef = React.useRef(0)
// Commit mutation helper
const commit = React.useCallback(
(mutator: (cols: SimpleKanbanColumn[]) => void) => {
const next = columns.map((col) => ({
...col,
items: [...col.items],
}))
mutator(next)
onColumnsChange?.(next)
},
[columns, onColumnsChange],
)
function handleDragStart(e: React.DragEvent, id: string) {
setDraggedId(id)
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', id)
}
}
function handleDragEnd() {
setDraggedId(null)
setDragOverColumnId(null)
setDropTargetIndex(-1)
lastDragTimeRef.current = Date.now()
}
function handleDragOverColumn(e: React.DragEvent, columnId: string, itemsCount: number) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverColumnId(columnId)
setDropTargetIndex(itemsCount)
}
function handleCardDragOver(e: React.DragEvent, columnId: string, index: number) {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
setDragOverColumnId(columnId)
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const midY = rect.top + rect.height / 2
const targetIdx = e.clientY < midY ? index : index + 1
setDropTargetIndex(targetIdx)
}
function handleDrop(e: React.DragEvent, targetColumnId: string) {
e.preventDefault()
if (!draggedId || !targetColumnId) {
handleDragEnd()
return
}
let sourceColIdx = -1
let itemIdx = -1
for (let c = 0; c < columns.length; c++) {
const col = columns[c]
if (!col) continue
const idx = col.items.findIndex((item) => item.id === draggedId)
if (idx !== -1) {
sourceColIdx = c
itemIdx = idx
break
}
}
const targetColIdx = columns.findIndex((c) => c.id === targetColumnId)
if (sourceColIdx === -1 || targetColIdx === -1) {
handleDragEnd()
return
}
const sourceCol = columns[sourceColIdx]
const targetCol = columns[targetColIdx]
if (!sourceCol || !targetCol) {
handleDragEnd()
return
}
const item = sourceCol.items[itemIdx]
if (!item) {
handleDragEnd()
return
}
commit((cols) => {
const src = cols[sourceColIdx]
const tgt = cols[targetColIdx]
if (!src || !tgt) return
const [removed] = src.items.splice(itemIdx, 1)
if (!removed) return
let at = dropTargetIndex
if (at < 0) at = tgt.items.length
if (sourceColIdx === targetColIdx && itemIdx < at) at--
tgt.items.splice(at, 0, removed)
onCardMove?.(removed, sourceCol.id, targetColumnId, at)
})
handleDragEnd()
}
function handleInlineAdd(columnId: string) {
if (!newTitle.trim()) {
setAddingColumnId(null)
return
}
if (onAddItem) {
onAddItem(columnId, newTitle.trim())
} else {
const newItem: SimpleKanbanItem = {
id: `item-${Date.now()}`,
title: newTitle.trim(),
}
commit((cols) => {
const col = cols.find((c) => c.id === columnId)
if (col) col.items.push(newItem)
})
}
setNewTitle('')
setAddingColumnId(null)
}
return (
<div
data-slot="simple-kanban"
className={cn(
'flex min-h-[380px] w-full [scrollbar-width:thin] items-start gap-4 overflow-x-auto pb-4',
className,
)}
>
{columns.map((column) => {
const isColumnOver = dragOverColumnId === column.id
const dot = column.dotColor || column.color || DEFAULT_DOT_COLORS[column.id] || 'bg-primary'
return (
<div
key={column.id}
data-slot="kanban-column"
className={cn(
'bg-muted/40 border-border/80 flex w-72 shrink-0 flex-col rounded-xl border p-3 transition-colors',
isColumnOver && 'border-primary/50 bg-muted/60 ring-primary/10 ring-2',
)}
onDragOver={(e) => handleDragOverColumn(e, column.id, column.items.length)}
onDrop={(e) => handleDrop(e, column.id)}
>
{/* Column Header */}
<div className="mb-3 flex items-center justify-between gap-2 px-1">
<div className="flex items-center gap-2">
<span className={cn('size-2 rounded-full', dot)} />
<h3 className="text-foreground text-sm font-semibold tracking-tight">{column.title}</h3>
<span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums">
{column.items.length}
</span>
</div>
{allowAdd && (
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground size-7 rounded-lg"
aria-label={`Add item to ${column.title}`}
onClick={() => {
setAddingColumnId(column.id)
setNewTitle('')
}}
>
<Plus className="size-3.5" />
</Button>
)}
</div>
{/* Column Cards Container */}
<div className="flex min-h-[120px] flex-1 flex-col gap-2.5">
{/* Inline quick add input */}
{addingColumnId === column.id && (
<div className="bg-card border-border animate-in fade-in zoom-in-95 rounded-lg border p-2.5 shadow-xs duration-150">
<input
type="text"
autoFocus
placeholder="Item title..."
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleInlineAdd(column.id)
if (e.key === 'Escape') setAddingColumnId(null)
}}
className="placeholder:text-muted-foreground/60 w-full bg-transparent text-sm font-medium outline-none"
/>
<div className="mt-2.5 flex items-center justify-end gap-1.5">
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
onClick={() => setAddingColumnId(null)}
>
Cancel
</Button>
<Button size="sm" className="h-7 gap-1 px-2.5 text-xs" onClick={() => handleInlineAdd(column.id)}>
<Check className="size-3" />
Add
</Button>
</div>
</div>
)}
{/* Cards list */}
{column.items.map((item, index) => {
const isDragging = draggedId === item.id
const isDropTarget = isColumnOver && dropTargetIndex === index
return (
<React.Fragment key={item.id}>
{isDropTarget && !isDragging && <div className="bg-primary/20 h-1.5 w-full rounded-full" />}
<div
role="button"
tabIndex={0}
draggable
data-slot="kanban-card"
onDragStart={(e) => handleDragStart(e, item.id)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleCardDragOver(e, column.id, index)}
onClick={() => {
if (Date.now() - lastDragTimeRef.current < 150) return
onCardClick?.(item, column.id)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onCardClick?.(item, column.id)
}
}}
className={cn(
'group bg-card text-card-foreground border-border/80 hover:border-border relative flex cursor-grab flex-col gap-2 rounded-lg border p-3 shadow-xs transition-colors hover:shadow-sm active:cursor-grabbing',
isDragging && 'ring-primary/40 opacity-40 shadow-md ring-2',
cardClassName,
)}
>
{/* Top row: Title + Grip handle */}
<div className="flex items-start justify-between gap-2">
<p className="text-foreground line-clamp-2 text-sm leading-snug font-medium">{item.title}</p>
<GripVertical className="text-muted-foreground/30 size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
</div>
{/* Optional description */}
{item.description && (
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">{item.description}</p>
)}
{/* Metadata row: Date, Tag, Priority, Assignee */}
{(item.date || item.tag || item.priority || item.assignee) && (
<div className="border-border/40 mt-1 flex flex-wrap items-center justify-between gap-1.5 border-t pt-1 text-xs">
<div className="flex flex-wrap items-center gap-1.5">
{item.date && (
<div className="text-muted-foreground flex items-center gap-1 text-xs">
<Calendar className="text-muted-foreground/70 size-3" />
<span>{item.date}</span>
</div>
)}
{item.tag && (
<span
className={cn(
'rounded-md px-1.5 py-0.5 text-xs font-medium',
item.tagColor || 'bg-secondary text-secondary-foreground',
)}
>
{item.tag}
</span>
)}
{item.priority && PRIORITY_STYLES[item.priority] && (
<span
className={cn('rounded-md px-1.5 py-0.5 text-xs', PRIORITY_STYLES[item.priority].class)}
>
{PRIORITY_STYLES[item.priority].label}
</span>
)}
</div>
{item.assignee && (
<Avatar className="border-background size-5 shrink-0 border">
<AvatarFallback className="text-xs font-medium">
{item.assignee.initials || item.assignee.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
)}
</div>
)}
</div>
</React.Fragment>
)
})}
{/* Trailing drop indicator */}
{isColumnOver && dropTargetIndex >= column.items.length && draggedId && (
<div className="bg-primary/20 h-1.5 w-full rounded-full" />
)}
{/* Empty column placeholder */}
{column.items.length === 0 && addingColumnId !== column.id && (
<div className="border-border/60 text-muted-foreground/60 flex flex-1 items-center justify-center rounded-lg border border-dashed py-8 text-center text-xs">
Drop items here
</div>
)}
</div>
</div>
)
})}
</div>
)
}
'use client'
import * as React from 'react'
import { MoreHorizontal, MessageSquare, Paperclip, ExternalLink } from 'lucide-react'
import { cn } from '@/lib/utils'
import { type KanbanColumn as KanbanColumnType, type KanbanTask, priorityConfig, getTaskColumn } from '@/lib/use-kanban'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { TagBadge } from './TagBadge'
import { SubtaskProgress } from './SubtaskProgress'
import { DueDateBadge } from './DueDateBadge'
import { UserAvatar } from './UserAvatar'
export function KanbanCard({
task,
isDone,
columns,
onClick,
onQuickView,
}: {
task: KanbanTask
isDone: boolean
columns: KanbanColumnType[]
onClick?: (task: KanbanTask) => void
onQuickView?: (task: KanbanTask) => void
}) {
const subtasksDone = React.useMemo(() => {
if (!columns || !task.subtaskIds.length) return 0
return task.subtaskIds.filter((id) => getTaskColumn(columns, id)?.id === 'done').length
}, [columns, task.subtaskIds])
return (
<div
data-slot="kanban-board"
className={cn(
'kanban-card group/card bg-card relative cursor-grab rounded-lg border p-3 transition-colors duration-150',
'hover:border-border hover:shadow-md active:scale-[0.97] active:cursor-grabbing',
isDone ? 'opacity-75 hover:opacity-100' : '',
)}
onClick={() => onClick?.(task)}
>
<div
className={cn(
'kanban-accent absolute top-3 bottom-3 left-0 w-[1.5px] rounded-full transition-colors duration-150',
priorityConfig[task.priority].bg,
task.priority === 'low' ? 'opacity-40' : task.priority === 'medium' ? 'opacity-60' : 'opacity-90',
)}
/>
<div className="mb-1 flex items-center justify-between pl-2">
<span className="text-muted-foreground/70 font-mono text-xs">{task.id}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Card options"
variant="ghost"
size="icon"
className="text-muted-foreground -mr-1 size-6 opacity-0 group-hover/card:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onQuickView?.(task)
}}
>
Quick view
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={`/dashboard/kanban/${task.id}`} className="gap-2">
<ExternalLink className="size-3.5" />
Open detail
</a>
</DropdownMenuItem>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Move to...</DropdownMenuItem>
<DropdownMenuItem>Assign to...</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<p
className={cn(
'mb-2 pl-2 text-sm leading-snug font-medium',
isDone ? 'decoration-muted-foreground/40 line-through' : '',
)}
>
{task.title}
</p>
{task.tags.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1 pl-2">
{task.tags.map((tag) => (
<TagBadge key={tag.label} label={tag.label} color={tag.color} />
))}
</div>
)}
{task.subtaskIds.length > 0 && (
<div className="mb-2 pl-2">
<SubtaskProgress done={subtasksDone} total={task.subtaskIds.length} />
</div>
)}
<div className="flex items-center gap-2 pl-2">
{task.dueDate && <DueDateBadge dueDate={task.dueDate} variant="chip" />}
{task.commentItems.length > 0 && (
<div className="text-muted-foreground/70 flex items-center gap-1 text-xs">
<MessageSquare className="size-3" />
{task.commentItems.length}
</div>
)}
{task.fileItems.length > 0 && (
<div className="text-muted-foreground/70 flex items-center gap-1 text-xs">
<Paperclip className="size-3" />
{task.fileItems.length}
</div>
)}
<div className="ml-auto">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<span>
<UserAvatar name={task.assignee.name} color={task.assignee.color} size="xs" />
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{task.assignee.name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
)
}
'use client'
import * as React from 'react'
import { Plus, MoreHorizontal, ChevronsLeft, ChevronsRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { type KanbanColumn as KanbanColumnType, type KanbanTask } from '@/lib/use-kanban'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { KanbanCard } from './KanbanCard'
export interface FilteredColumn {
id: string
title: string
color: string
dotColor: string
tasks: KanbanTask[]
}
export function KanbanColumn({
column,
collapsed,
draggedTask,
dragOverColumn,
dropTargetIndex,
allColumns,
onToggleCollapse,
onAddTask,
onCardClick,
onCardQuickView,
onDragStart,
onDragEnd,
onCardDragOver,
onLaneDragOver,
onDrop,
}: {
column: FilteredColumn
collapsed: boolean
draggedTask: string | null
dragOverColumn: string | null
dropTargetIndex: number
allColumns: KanbanColumnType[]
onToggleCollapse: (columnId: string) => void
onAddTask: (columnId: string) => void
onCardClick: (task: KanbanTask) => void
onCardQuickView: (task: KanbanTask) => void
onDragStart: (event: React.DragEvent, taskId: string) => void
onDragEnd: () => void
onCardDragOver: (event: React.DragEvent, columnId: string, taskIndex: number) => void
onLaneDragOver: (event: React.DragEvent, columnId: string, taskCount: number) => void
onDrop: () => void
}) {
return (
<div
data-slot="kanban-board"
className={cn(
'group/col flex shrink-0 flex-col transition-[width] duration-200',
collapsed ? 'w-12' : 'w-[300px]',
)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDrop()}
>
{collapsed ? (
<button
className="bg-muted/40 hover:bg-muted/60 flex h-full flex-col items-center gap-2 rounded-xl px-1 pt-3 pb-4 transition-colors"
onClick={() => onToggleCollapse(column.id)}
>
<span className={cn('size-2 shrink-0 rounded-full', column.dotColor)} />
<span
className={cn(
'text-xs font-semibold tracking-tight',
column.color,
'rotate-180 [writing-mode:vertical-lr]',
)}
>
{column.title}
</span>
<Badge variant="secondary" className="mt-1 h-5 min-w-5 justify-center rounded-md px-1 text-xs">
{column.tasks.length}
</Badge>
<ChevronsRight className="text-muted-foreground mt-auto size-3.5" />
</button>
) : (
<>
<div className="bg-background/95 sticky top-0 z-10 mb-2 flex items-center gap-2 px-2 py-1.5 backdrop-blur-sm">
<button
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors"
title="Collapse column"
onClick={() => onToggleCollapse(column.id)}
>
<ChevronsLeft className="size-3.5" />
</button>
<span className={cn('size-2 shrink-0 rounded-full', column.dotColor)} />
<h3 className={cn('text-sm font-semibold tracking-tight', column.color)}>{column.title}</h3>
<span className="text-muted-foreground bg-muted rounded-md px-1.5 py-0.5 text-xs font-medium tabular-nums">
{column.tasks.length}
</span>
<div className="ml-auto flex items-center">
<Button
aria-label="Column options"
variant="ghost"
size="icon"
className="text-muted-foreground size-6 opacity-0 group-hover/col:opacity-100"
>
<MoreHorizontal className="size-3.5" />
</Button>
<Button
aria-label="Add task"
variant="ghost"
size="icon"
className="text-muted-foreground size-6"
onClick={() => onAddTask(column.id)}
>
<Plus className="size-3.5" />
</Button>
</div>
</div>
<div
className={cn(
'kanban-lane flex flex-col rounded-xl p-2 transition-colors duration-200',
dragOverColumn === column.id && draggedTask
? 'bg-primary/[0.06] ring-primary/25 ring-1 ring-inset'
: 'bg-muted/40',
)}
onDragOver={(e) => {
e.preventDefault()
onLaneDragOver(e, column.id, column.tasks.length)
}}
>
{column.tasks.map((task, taskIndex) => (
<React.Fragment key={task.id}>
<div
className={cn(
'drop-indicator mx-1 transition-colors duration-150',
dragOverColumn === column.id &&
dropTargetIndex === taskIndex &&
draggedTask &&
draggedTask !== task.id
? 'bg-primary h-0.5 rounded-full'
: 'h-0',
)}
/>
<div
data-task-id={task.id}
draggable="true"
className={cn(
'mt-2 first:mt-0',
draggedTask === task.id ? 'scale-95 rotate-1 opacity-30' : 'opacity-100',
)}
onDragStart={(e) => onDragStart(e, task.id)}
onDragEnd={() => onDragEnd()}
onDragOver={(e) => {
e.stopPropagation()
e.preventDefault()
onCardDragOver(e, column.id, taskIndex)
}}
>
<KanbanCard
task={task}
isDone={column.id === 'done'}
columns={allColumns}
onClick={onCardClick}
onQuickView={onCardQuickView}
/>
</div>
</React.Fragment>
))}
{column.tasks.length > 0 && (
<div
className={cn(
'drop-indicator mx-1 transition-colors duration-150',
dragOverColumn === column.id && dropTargetIndex === column.tasks.length && draggedTask
? 'bg-primary mt-2 h-0.5 rounded-full'
: 'h-0',
)}
/>
)}
{column.tasks.length === 0 && (
<button
aria-label="Add task"
className="text-muted-foreground/50 hover:text-muted-foreground hover:border-muted-foreground/30 flex flex-1 flex-col items-center justify-center rounded-lg border border-dashed py-10 transition-colors"
onClick={() => onAddTask(column.id)}
>
<Plus className="mb-1 size-4" />
<p className="text-xs">No tasks</p>
</button>
)}
</div>
<button
aria-label="Add task"
className="text-muted-foreground hover:text-foreground hover:bg-muted/60 mt-2 flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed py-2 text-xs transition-colors"
onClick={() => onAddTask(column.id)}
>
<Plus className="size-3.5" />
Add task
</button>
</>
)}
</div>
)
}
'use client'
import * as React from 'react'
import { ChevronDown, ChevronRight, ExternalLink, MessageSquare, Paperclip, ArrowUpDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { type KanbanColumn as KanbanColumnType, type KanbanTask, getTaskColumn } from '@/lib/use-kanban'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { type FilteredColumn } from './KanbanColumn'
import { TagBadge } from './TagBadge'
import { SubtaskProgress } from './SubtaskProgress'
import { PriorityBadge } from './PriorityBadge'
import { DueDateBadge } from './DueDateBadge'
import { UserAvatar } from './UserAvatar'
type SortField = 'id' | 'title' | 'priority' | 'assignee' | 'dueDate' | 'status'
type SortDir = 'asc' | 'desc'
interface FlatTask {
task: KanbanTask
columnId: string
columnTitle: string
dotColor: string
}
const priorityOrder: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3 }
export function KanbanListView({
columns,
allColumns,
onTaskClick,
onMoveTask,
}: {
columns: FilteredColumn[]
allColumns: KanbanColumnType[]
onTaskClick: (task: KanbanTask) => void
onMoveTask: (task: KanbanTask, targetColumnId: string) => void
}) {
const [sortField, setSortField] = React.useState<SortField>('status')
const [sortDir, setSortDir] = React.useState<SortDir>('asc')
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(new Set())
function toggleSort(field: SortField) {
if (sortField === field) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortField(field)
setSortDir('asc')
}
}
function toggleGroup(columnId: string) {
setCollapsedGroups((prev) => {
const next = new Set(prev)
if (next.has(columnId)) next.delete(columnId)
else next.add(columnId)
return next
})
}
const flatTasks = React.useMemo<FlatTask[]>(() => {
const items: FlatTask[] = []
for (const col of columns) {
for (const task of col.tasks) {
items.push({ task, columnId: col.id, columnTitle: col.title, dotColor: col.dotColor })
}
}
return [...items].sort((a, b) => {
let cmp = 0
switch (sortField) {
case 'id': {
const numA = parseInt(a.task.id.replace(/^[A-Z]+-/, ''), 10)
const numB = parseInt(b.task.id.replace(/^[A-Z]+-/, ''), 10)
cmp = numA - numB
break
}
case 'title':
cmp = a.task.title.localeCompare(b.task.title)
break
case 'priority':
cmp = (priorityOrder[a.task.priority] ?? 99) - (priorityOrder[b.task.priority] ?? 99)
break
case 'assignee':
cmp = a.task.assignee.name.localeCompare(b.task.assignee.name)
break
case 'dueDate':
cmp = (a.task.dueDate ?? '9999').localeCompare(b.task.dueDate ?? '9999')
break
case 'status': {
const colOrder = allColumns.map((c) => c.id)
cmp = colOrder.indexOf(a.columnId) - colOrder.indexOf(b.columnId)
break
}
}
return sortDir === 'desc' ? -cmp : cmp
})
}, [columns, allColumns, sortField, sortDir])
const groupedTasks = React.useMemo(() => {
const groups: { column: KanbanColumnType; tasks: FlatTask[] }[] = []
for (const col of allColumns) {
const tasks = flatTasks.filter((t) => t.columnId === col.id)
groups.push({ column: col, tasks })
}
return groups
}, [allColumns, flatTasks])
function subtasksDone(task: KanbanTask): number {
if (!task.subtaskIds.length) return 0
return task.subtaskIds.filter((id) => getTaskColumn(allColumns, id)?.id === 'done').length
}
const headerCols: { field: SortField; label: string }[] = [
{ field: 'id', label: 'ID' },
{ field: 'title', label: 'Task' },
{ field: 'status', label: 'Status' },
{ field: 'priority', label: 'Priority' },
{ field: 'assignee', label: 'Assignee' },
{ field: 'dueDate', label: 'Due' },
]
return (
<div data-slot="kanban-board" className="kanban-list flex min-h-0 flex-1 flex-col overflow-auto pb-3">
<div className="bg-muted/50 sticky top-0 z-10 grid grid-cols-[60px_1fr_100px_110px_130px_100px_80px] items-center gap-2 rounded-t-lg border px-3 py-2 text-xs font-semibold tracking-wider uppercase">
{headerCols.map((h) => (
<button key={h.field} className="flex items-center gap-1 text-left" onClick={() => toggleSort(h.field)}>
{h.label}
<ArrowUpDown
className={cn('size-3', sortField === h.field ? 'text-foreground' : 'text-muted-foreground/50')}
/>
</button>
))}
<span className="text-center">Info</span>
</div>
{groupedTasks.map((group) => (
<React.Fragment key={group.column.id}>
<button
className="bg-muted/30 hover:bg-muted/50 flex items-center gap-2 border-x border-b px-3 py-1.5 text-left transition-colors"
onClick={() => toggleGroup(group.column.id)}
>
{collapsedGroups.has(group.column.id) ? (
<ChevronRight className="text-muted-foreground size-3.5" />
) : (
<ChevronDown className="text-muted-foreground size-3.5" />
)}
<span className={cn('size-2 rounded-full', group.column.dotColor)} />
<span className="text-sm font-medium">{group.column.title}</span>
<Badge variant="secondary" className="ml-1 h-4 px-1.5 text-xs tabular-nums">
{group.tasks.length}
</Badge>
</button>
{!collapsedGroups.has(group.column.id) &&
group.tasks.map((item) => (
<div
key={item.task.id}
className="hover:bg-muted/30 grid cursor-pointer grid-cols-[60px_1fr_100px_110px_130px_100px_80px] items-center gap-2 border-x border-b px-3 py-2 transition-colors"
onClick={() => onTaskClick(item.task)}
>
<span className="text-muted-foreground font-mono text-xs">{item.task.id}</span>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span
className={cn(
'truncate text-sm font-medium',
item.columnId === 'done' ? 'text-muted-foreground line-through' : '',
)}
>
{item.task.title}
</span>
<a
href={`/dashboard/kanban/${item.task.id}`}
className="text-muted-foreground hover:text-foreground shrink-0 opacity-0 transition-opacity group-hover/row:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="size-3" />
</a>
</div>
{(item.task.tags.length > 0 || item.task.subtaskIds.length > 0) && (
<div className="mt-0.5 flex items-center gap-1.5">
{item.task.tags.map((tag) => (
<TagBadge
key={tag.label}
label={tag.label}
color={tag.color}
className="!px-1.5 !py-0 !text-xs"
/>
))}
{item.task.subtaskIds.length > 0 && (
<SubtaskProgress
done={subtasksDone(item.task)}
total={item.task.subtaskIds.length}
className="ml-1"
/>
)}
</div>
)}
</div>
<div>
<Select value={item.columnId} onValueChange={(val) => onMoveTask(item.task, String(val))}>
<SelectTrigger
className="hover:bg-muted h-6 w-auto gap-1 rounded-md border-none bg-transparent px-1.5 text-xs font-medium shadow-none"
onClick={(e) => e.stopPropagation()}
>
<span className="flex items-center gap-1.5">
<span className={cn('size-1.5 rounded-full', item.dotColor)} />
<SelectValue />
</span>
</SelectTrigger>
<SelectContent>
{allColumns.map((col) => (
<SelectItem key={col.id} value={col.id}>
<span className="flex items-center gap-1.5">
<span className={cn('size-1.5 rounded-full', col.dotColor)} />
{col.title}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<PriorityBadge priority={item.task.priority} />
<div className="flex items-center gap-2">
<UserAvatar name={item.task.assignee.name} color={item.task.assignee.color} size="xs" />
<span className="truncate text-xs">{item.task.assignee.name}</span>
</div>
<div>
{item.task.dueDate ? (
<DueDateBadge dueDate={item.task.dueDate} variant="chip" />
) : (
<span className="text-muted-foreground/50 text-xs">—</span>
)}
</div>
<div className="flex items-center justify-center gap-2">
<TooltipProvider delayDuration={200}>
{item.task.commentItems.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground/70 flex items-center gap-0.5 text-xs">
<MessageSquare className="size-3" />
{item.task.commentItems.length}
</span>
</TooltipTrigger>
<TooltipContent className="text-xs">{item.task.commentItems.length} comments</TooltipContent>
</Tooltip>
)}
{item.task.fileItems.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground/70 flex items-center gap-0.5 text-xs">
<Paperclip className="size-3" />
{item.task.fileItems.length}
</span>
</TooltipTrigger>
<TooltipContent className="text-xs">{item.task.fileItems.length} files</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
</div>
</div>
))}
</React.Fragment>
))}
{flatTasks.length === 0 && (
<div className="text-muted-foreground flex flex-1 items-center justify-center rounded-b-lg border-x border-b py-12 text-sm">
No tasks match your filters.
</div>
)}
</div>
)
}
'use client'
import { Clock, ExternalLink, Download } from 'lucide-react'
import { cn } from '@/lib/utils'
import { type KanbanColumn as KanbanColumnType, type KanbanTask, priorityConfig, fileIconMap } from '@/lib/use-kanban'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Sheet, SheetContent, SheetTitle, SheetDescription, SheetFooter, SheetClose } from '@/components/ui/sheet'
import { TagBadge } from './TagBadge'
import { PriorityBadge } from './PriorityBadge'
import { DueDateBadge } from './DueDateBadge'
import { UserAvatar } from './UserAvatar'
import { SubtaskList } from './SubtaskList'
import { CommentList } from './CommentList'
export function KanbanTaskSheet({
open,
task,
columns,
onOpenChange,
onMoveTask,
onAddComment,
}: {
open: boolean
task: KanbanTask | null
columns: KanbanColumnType[]
onOpenChange: (value: boolean) => void
onMoveTask: (task: KanbanTask, columnId: string) => void
onAddComment: (task: KanbanTask, text: string) => void
}) {
const columnIdForTask = task ? (columns.find((c) => c.tasks.some((t) => t.id === task.id))?.id ?? '') : ''
return (
<Sheet data-slot="kanban-board" open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex max-w-[420px] flex-col gap-0 overflow-hidden p-0 sm:max-w-full">
{task && (
<>
<div className={cn('h-1 w-full shrink-0', priorityConfig[task.priority]?.bg)} />
<div className="shrink-0 px-5 pt-4 pb-3">
<div className="mb-3 flex items-center gap-2">
<span className="text-muted-foreground font-mono text-xs tracking-tight">{task.id}</span>
<span className="text-muted-foreground/30">·</span>
<Select value={columnIdForTask} onValueChange={(val) => onMoveTask(task, String(val))}>
<SelectTrigger className="hover:bg-secondary h-5 w-auto gap-1 rounded-md border-none bg-transparent px-1.5 text-xs font-medium shadow-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.id} value={col.id}>
<span className="flex items-center gap-1.5">
<span className={cn('size-1.5 rounded-full', col.dotColor)} />
{col.title}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<PriorityBadge priority={task.priority} iconSize="size-3" className="ml-auto" />
</div>
<SheetTitle className="text-sm leading-snug font-semibold tracking-tight">{task.title}</SheetTitle>
<SheetDescription className="sr-only">Task details</SheetDescription>
{task.description ? (
<div
className="text-muted-foreground rich-text-content prose prose-sm dark:prose-invert mt-1.5 max-w-none text-sm leading-relaxed"
dangerouslySetInnerHTML={{ __html: task.description }}
/>
) : (
<p className="text-muted-foreground mt-1.5 text-sm leading-relaxed">No description provided.</p>
)}
<div className="mt-3 flex flex-wrap gap-1.5">
{task.tags.length ? (
task.tags.map((tag) => <TagBadge key={tag.label} label={tag.label} color={tag.color} />)
) : (
<span className="text-muted-foreground text-xs">No tags</span>
)}
</div>
</div>
<div className="bg-border mx-5 h-px" />
<div className="flex-1 overflow-y-auto">
<div className="space-y-4 px-5 py-3">
<div className="flex items-center gap-3">
<UserAvatar name={task.assignee.name} color={task.assignee.color} size="md" />
<div>
<p className="text-sm leading-tight font-medium">{task.assignee.name}</p>
<p className="text-muted-foreground text-xs">Assignee</p>
</div>
<div className="ml-auto text-right">
{task.dueDate ? (
<DueDateBadge dueDate={task.dueDate} />
) : (
<p className="text-muted-foreground flex items-center gap-1 text-sm leading-tight">
<Clock className="size-3" />
No due date
</p>
)}
</div>
</div>
{task.parentId && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">Parent:</span>
<a
href={`/dashboard/kanban/${task.parentId}`}
className="text-primary text-xs font-medium hover:underline"
onClick={() => onOpenChange(false)}
>
{task.parentId}
</a>
</div>
)}
<div>
<h4 className="mb-2 text-sm font-semibold">
Subtasks
{task.subtaskIds.length > 0 && (
<span className="text-muted-foreground font-normal"> ({task.subtaskIds.length})</span>
)}
</h4>
<SubtaskList subtaskIds={task.subtaskIds} columns={columns} compact />
</div>
<div className="bg-border h-px" />
<div>
<h4 className="mb-2 text-sm font-semibold">
Comments
{task.commentItems.length > 0 && (
<span className="text-muted-foreground font-normal"> ({task.commentItems.length})</span>
)}
</h4>
<CommentList comments={task.commentItems} compact onAdd={(text) => onAddComment(task, text)} />
</div>
<div className="bg-border h-px" />
<div>
<h4 className="mb-2 text-sm font-semibold">
Files
{task.fileItems.length > 0 && (
<span className="text-muted-foreground font-normal"> ({task.fileItems.length})</span>
)}
</h4>
{task.fileItems.length ? (
<div className="space-y-1">
{task.fileItems.map((file) => {
const FileIcon = fileIconMap[file.type]
return (
<div
key={file.id}
className="hover:bg-muted/50 group/file flex items-center gap-2.5 rounded-md px-1.5 py-1.5 transition-colors"
>
<div className="bg-muted flex size-8 shrink-0 items-center justify-center rounded-md">
<FileIcon className="text-muted-foreground size-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{file.name}</p>
<p className="text-muted-foreground text-xs">{file.size}</p>
</div>
<Button
aria-label="Download attachment"
variant="ghost"
size="icon"
className="size-7 shrink-0 opacity-0 group-hover/file:opacity-100"
>
<Download className="size-3.5" />
</Button>
</div>
)
})}
</div>
) : (
<p className="text-muted-foreground text-xs">No files attached.</p>
)}
</div>
</div>
</div>
<SheetFooter className="shrink-0 border-t px-5 py-3">
<div className="flex w-full items-center gap-2">
<a href={`/dashboard/kanban/${task.id}`} className="flex-1" onClick={() => onOpenChange(false)}>
<Button variant="outline" size="sm" className="w-full gap-1.5">
<ExternalLink className="size-3.5" />
View full detail
</Button>
</a>
<SheetClose asChild>
<Button variant="ghost" size="sm">
Close
</Button>
</SheetClose>
</div>
</SheetFooter>
</>
)}
</SheetContent>
</Sheet>
)
}
'use client'
import * as React from 'react'
import { Plus, X, CheckCircle2, Calendar as CalendarIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import {
type KanbanColumn as KanbanColumnType,
type KanbanTask,
priorityConfig,
assignees,
tagPresets,
} from '@/lib/use-kanban'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Dialog,
DialogScrollContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Calendar } from '@/components/ui/calendar'
import { RichTextEditor } from '@/components/ui/rich-text-editor'
interface AddTaskForm {
title: string
description: string
priority: KanbanTask['priority']
assigneeKey: keyof typeof assignees
tagKeys: (keyof typeof tagPresets)[]
subtaskTexts: string[]
}
const emptyForm = (): AddTaskForm => ({
title: '',
description: '',
priority: 'medium',
assigneeKey: 'alice',
tagKeys: [],
subtaskTexts: [],
})
export function KanbanAddTaskDialog({
open,
columns,
initialColumnId,
onOpenChange,
onCreate,
}: {
open: boolean
columns: KanbanColumnType[]
initialColumnId: string
onOpenChange: (value: boolean) => void
onCreate: (columnId: string, tasks: KanbanTask[]) => void
}) {
const [columnId, setColumnId] = React.useState(initialColumnId)
const [dueDate, setDueDate] = React.useState<Date | undefined>(undefined)
const [form, setForm] = React.useState<AddTaskForm>(emptyForm)
const [newSubtaskText, setNewSubtaskText] = React.useState('')
React.useEffect(() => {
setColumnId(initialColumnId)
}, [initialColumnId])
React.useEffect(() => {
if (open) {
setColumnId(initialColumnId)
setForm(emptyForm())
setDueDate(undefined)
setNewSubtaskText('')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
function addSubtask() {
const text = newSubtaskText.trim()
if (!text) return
setForm((f) => ({ ...f, subtaskTexts: [...f.subtaskTexts, text] }))
setNewSubtaskText('')
}
function removeSubtask(index: number) {
setForm((f) => ({ ...f, subtaskTexts: f.subtaskTexts.filter((_, i) => i !== index) }))
}
function toggleTag(key: keyof typeof tagPresets) {
setForm((f) => {
const idx = f.tagKeys.indexOf(key)
const tagKeys = idx >= 0 ? f.tagKeys.filter((k) => k !== key) : [...f.tagKeys, key]
return { ...f, tagKeys }
})
}
function submit() {
if (!form.title.trim()) return
const maxId = columns
.flatMap((c) => c.tasks)
.reduce((max, t) => {
const num = parseInt(t.id.replace(/^[A-Z]+-/, ''), 10)
return num > max ? num : max
}, 0)
const idPrefix = columns.flatMap((c) => c.tasks)[0]?.id.split('-')[0] ?? 'TASK'
const parentId = `${idPrefix}-${maxId + 1}`
const subtaskTasks: KanbanTask[] = form.subtaskTexts.map((text, i) => ({
id: `${idPrefix}-${maxId + 2 + i}`,
title: text,
priority: 'medium' as const,
assignee: assignees[form.assigneeKey],
tags: [],
parentId,
subtaskIds: [],
commentItems: [],
fileItems: [],
}))
const newTask: KanbanTask = {
id: parentId,
title: form.title.trim(),
description: form.description.trim() || undefined,
priority: form.priority,
assignee: assignees[form.assigneeKey],
tags: form.tagKeys.map((k) => tagPresets[k]),
dueDate: dueDate ? dueDate.toISOString().slice(0, 10) : undefined,
subtaskIds: subtaskTasks.map((t) => t.id),
commentItems: [],
fileItems: [],
}
onCreate(columnId, [newTask, ...subtaskTasks])
onOpenChange(false)
}
return (
<Dialog data-slot="kanban-board" open={open} onOpenChange={onOpenChange}>
<DialogScrollContent className="max-w-[680px] sm:max-w-full">
<DialogHeader>
<DialogTitle>New Task</DialogTitle>
<DialogDescription>
Adding task to {columns.find((c) => c.id === columnId)?.title ?? 'column'}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-1.5">
<Label htmlFor="task-title">Title</Label>
<Input
id="task-title"
value={form.title}
placeholder="Enter task title"
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label>
Description
<span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<RichTextEditor
value={form.description}
onValueChange={(val) => setForm((f) => ({ ...f, description: val }))}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="grid gap-1.5">
<Label htmlFor="task-priority">Priority</Label>
<Select
value={form.priority}
onValueChange={(val) => setForm((f) => ({ ...f, priority: val as KanbanTask['priority'] }))}
>
<SelectTrigger id="task-priority">
<SelectValue placeholder="Priority" />
</SelectTrigger>
<SelectContent>
{Object.entries(priorityConfig).map(([key, config]) => (
<SelectItem key={key} value={key}>
{config.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="task-assignee">Assignee</Label>
<Select
value={form.assigneeKey}
onValueChange={(val) => setForm((f) => ({ ...f, assigneeKey: val as keyof typeof assignees }))}
>
<SelectTrigger id="task-assignee">
<SelectValue placeholder="Assignee" />
</SelectTrigger>
<SelectContent>
{Object.entries(assignees).map(([key, person]) => (
<SelectItem key={key} value={key}>
{person.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="task-column">Column</Label>
<Select value={columnId} onValueChange={setColumnId}>
<SelectTrigger id="task-column">
<SelectValue placeholder="Column" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.id} value={col.id}>
{col.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-1.5">
<Label>
Due Date
<span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn('w-full justify-start text-left font-normal', !dueDate && 'text-muted-foreground')}
>
<CalendarIcon className="mr-2 size-4" />
{dueDate ? dueDate.toLocaleDateString('en-US', { dateStyle: 'medium' }) : 'Pick a date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar mode="single" selected={dueDate} onSelect={setDueDate} />
</PopoverContent>
</Popover>
</div>
<div className="grid gap-1.5">
<Label>
Tags
<span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<div className="flex flex-wrap gap-2">
{Object.entries(tagPresets).map(([key, tag]) => {
const active = form.tagKeys.includes(key as keyof typeof tagPresets)
return (
<button
key={key}
type="button"
className={cn(
'inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-colors',
active
? 'bg-primary text-primary-foreground border-transparent'
: 'border-border bg-background text-foreground hover:bg-muted',
)}
onClick={() => toggleTag(key as keyof typeof tagPresets)}
>
{active && <CheckCircle2 className="size-3" />}
{tag.label}
</button>
)
})}
</div>
</div>
<div className="grid gap-1.5">
<Label>
Subtasks
<span className="text-muted-foreground text-xs">(optional)</span>
</Label>
{form.subtaskTexts.length > 0 && (
<div className="space-y-2">
{form.subtaskTexts.map((text, index) => (
<div key={index} className="flex items-center gap-2">
<span className="flex-1 text-sm">{text}</span>
<Button
variant="ghost"
size="icon"
className="size-6"
aria-label="Remove subtask"
onClick={() => removeSubtask(index)}
>
<X className="size-3" />
</Button>
</div>
))}
</div>
)}
<div className="flex items-center gap-2">
<Input
value={newSubtaskText}
placeholder="Add a subtask"
onChange={(e) => setNewSubtaskText(e.target.value)}
onKeyUp={(e) => e.key === 'Enter' && addSubtask()}
/>
<Button variant="outline" size="icon" aria-label="Add subtask" onClick={addSubtask}>
<Plus className="size-4" />
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button disabled={!form.title.trim()} onClick={submit}>
Create Task
</Button>
</DialogFooter>
</DialogScrollContent>
</Dialog>
)
}
'use client'
import { Search, Filter, ChevronDown, X, LayoutGrid, List } from 'lucide-react'
import { cn } from '@/lib/utils'
import { priorityConfig, assignees, getInitials } from '@/lib/use-kanban'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
export function KanbanToolbar({
searchQuery,
selectedPriority,
selectedAssignee,
viewMode,
onSearchQueryChange,
onSelectedPriorityChange,
onSelectedAssigneeChange,
onViewModeChange,
}: {
searchQuery: string
selectedPriority: string | null
selectedAssignee: string | null
viewMode: 'board' | 'list'
onSearchQueryChange: (value: string) => void
onSelectedPriorityChange: (value: string | null) => void
onSelectedAssigneeChange: (value: string | null) => void
onViewModeChange: (value: 'board' | 'list') => void
}) {
return (
<div data-slot="kanban-board" className="mb-3 flex shrink-0 items-center gap-2">
<div className="relative w-56">
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" />
<Input
value={searchQuery}
placeholder="Search tasks..."
className="h-8 pl-8 text-sm"
onChange={(e) => onSearchQueryChange(e.target.value)}
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 gap-1.5 text-xs">
<Filter className="size-3" />
Priority
{selectedPriority && (
<Badge variant="default" className="ml-0.5 h-4 min-w-4 justify-center rounded px-1 text-xs">
1
</Badge>
)}
<ChevronDown className="text-muted-foreground size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40">
{Object.entries(priorityConfig).map(([key, config]) => {
const Icon = config.icon
return (
<DropdownMenuItem
key={key}
className="gap-2"
onClick={() => onSelectedPriorityChange(selectedPriority === key ? null : key)}
>
<Icon className={cn('size-3.5', config.class)} />
{config.label}
{selectedPriority === key && <span className="bg-primary ml-auto size-1.5 rounded-full" />}
</DropdownMenuItem>
)
})}
{selectedPriority && <DropdownMenuSeparator />}
{selectedPriority && (
<DropdownMenuItem onClick={() => onSelectedPriorityChange(null)}>Clear filter</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{selectedAssignee && (
<Badge
variant="secondary"
className="h-7 cursor-pointer gap-1 pr-1.5 text-xs"
onClick={() => onSelectedAssigneeChange(null)}
>
{selectedAssignee}
<X className="size-3 opacity-50" />
</Badge>
)}
<ToggleGroup
type="single"
size="sm"
value={viewMode}
className="bg-muted ml-auto flex items-center gap-0.5 rounded-md p-0.5"
onValueChange={(v) => v && onViewModeChange(v as 'board' | 'list')}
>
<ToggleGroupItem
value="board"
className="data-[state=on]:bg-background size-7 rounded-sm p-0 data-[state=on]:shadow-sm"
title="Board view"
>
<LayoutGrid className="size-3.5" />
</ToggleGroupItem>
<ToggleGroupItem
value="list"
className="data-[state=on]:bg-background size-7 rounded-sm p-0 data-[state=on]:shadow-sm"
title="List view"
>
<List className="size-3.5" />
</ToggleGroupItem>
</ToggleGroup>
<div className="flex items-center">
<TooltipProvider delayDuration={300}>
{Object.entries(assignees).map(([key, a]) => (
<Tooltip key={key}>
<TooltipTrigger asChild>
<button
className={cn(
// rounded-full so the selected ring + focus ring trace the
// Avatar's circular outline instead of the rectangular button.
'ring-background focus-visible:ring-ring/50 relative -ml-1.5 rounded-full transition-colors outline-none first:ml-0 focus-visible:ring-1',
selectedAssignee === a.name
? 'ring-primary z-20 ring-1'
: selectedAssignee && selectedAssignee !== a.name
? 'opacity-40 hover:opacity-70'
: 'hover:z-10 hover:scale-110',
)}
onClick={() => onSelectedAssigneeChange(selectedAssignee === a.name ? null : a.name)}
>
<Avatar className="border-background size-7 border-2">
<AvatarFallback className={cn('text-xs font-semibold', a.color)}>
{getInitials(a.name)}
</AvatarFallback>
</Avatar>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{a.name}
{selectedAssignee === a.name && <span className="text-muted-foreground ml-1">(filtered)</span>}
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
</div>
)
}
'use client'
import { cn } from '@/lib/utils'
import { priorityConfig } from '@/lib/use-kanban'
export function PriorityBadge({
priority,
iconSize = 'size-3.5',
className,
}: {
priority: string
iconSize?: string
className?: string
}) {
const config = priorityConfig[priority]
if (!config) return null
const Icon = config.icon
return (
<div data-slot="kanban-board" className={cn('flex items-center gap-1', className)}>
<Icon className={cn(iconSize, config.class)} />
<span className={cn('text-xs font-semibold', config.class)}>{config.label}</span>
</div>
)
}
'use client'
import { Clock } from 'lucide-react'
import { cn } from '@/lib/utils'
import { getDueStatus, formatDueDate } from '@/lib/use-kanban'
export function DueDateBadge({
dueDate,
variant,
className,
}: {
dueDate: string
variant?: 'chip' | 'inline'
className?: string
}) {
const status = getDueStatus(dueDate)
const formatted = formatDueDate(dueDate)
const chipClasses =
status === 'overdue'
? 'bg-destructive/10 text-destructive'
: status === 'soon'
? 'bg-warning/10 text-warning'
: 'text-muted-foreground bg-muted'
const inlineClasses =
status === 'overdue' ? 'text-destructive' : status === 'soon' ? 'text-warning' : 'text-foreground'
if (variant === 'chip') {
return (
<div
data-slot="kanban-board"
className={cn('flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-medium', chipClasses, className)}
>
<Clock className="size-3" />
{formatted}
</div>
)
}
return (
<p className={cn('flex items-center gap-1 text-sm leading-tight font-medium', inlineClasses, className)}>
<Clock className="size-3" />
{formatted}
</p>
)
}
'use client'
import { cn } from '@/lib/utils'
export function TagBadge({ label, color, className }: { label: string; color: string; className?: string }) {
return (
<span
data-slot="kanban-board"
className={cn('rounded-md px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset', color, className)}
>
{label}
</span>
)
}
'use client'
import { cn } from '@/lib/utils'
import { getInitials } from '@/lib/use-kanban'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
const userAvatarSizeMap = {
xs: { avatar: 'size-6', text: 'text-xs' },
sm: { avatar: 'size-7', text: 'text-xs' },
md: { avatar: 'size-8', text: 'text-xs' },
}
export function UserAvatar({
name,
color,
size = 'sm',
className,
}: {
name: string
color: string
size?: 'xs' | 'sm' | 'md'
className?: string
}) {
return (
<Avatar data-slot="kanban-board" className={cn(userAvatarSizeMap[size].avatar, 'shrink-0', className)}>
<AvatarFallback className={cn(userAvatarSizeMap[size].text, 'font-bold', color)}>
{getInitials(name)}
</AvatarFallback>
</Avatar>
)
}
'use client'
import { CheckCircle2 } from 'lucide-react'
import { cn } from '@/lib/utils'
export function SubtaskProgress({ done, total, className }: { done: number; total: number; className?: string }) {
if (total <= 0) return null
const percent = total > 0 ? Math.round((done / total) * 100) : 0
const isComplete = done === total && total > 0
return (
<div data-slot="kanban-board" className={className}>
<div className="mb-1 flex items-center justify-between">
<span className="text-muted-foreground text-xs">
{isComplete && <CheckCircle2 className="text-success mr-0.5 inline size-3" />}
{done}/{total} subtasks
</span>
<span className="text-muted-foreground text-xs font-medium tabular-nums">{percent}%</span>
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div
className={cn(
'h-full rounded-full transition-[width,background-color] duration-500',
isComplete ? 'bg-success' : 'bg-primary',
)}
style={{ width: `${percent}%` }}
/>
</div>
</div>
)
}
'use client'
import { cn } from '@/lib/utils'
import { type KanbanColumn as KanbanColumnType, type KanbanTask, findTaskById, getTaskColumn } from '@/lib/use-kanban'
export function SubtaskList({
subtaskIds,
columns,
compact = false,
}: {
subtaskIds: string[]
columns: KanbanColumnType[]
compact?: boolean
}) {
const subtasks = subtaskIds
.map((id) => {
const task = findTaskById(columns, id)
const column = getTaskColumn(columns, id)
return task ? { task, column } : null
})
.filter(Boolean) as { task: KanbanTask; column: KanbanColumnType | undefined }[]
const doneCount = subtasks.filter((s) => s.column?.id === 'done').length
const percent = subtasks.length > 0 ? Math.round((doneCount / subtasks.length) * 100) : 0
if (!subtasks.length) {
return (
<p data-slot="kanban-board" className="text-muted-foreground text-xs">
No subtasks yet.
</p>
)
}
return (
<div>
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-xs tabular-nums">
{doneCount}/{subtasks.length} done
</span>
<span className="text-muted-foreground text-xs font-medium tabular-nums">{percent}%</span>
</div>
<div className="bg-muted mb-3 h-1.5 overflow-hidden rounded-full">
<div
className={cn(
'h-full rounded-full transition-[width,background-color] duration-500',
doneCount === subtasks.length ? 'bg-success' : 'bg-primary',
)}
style={{ width: `${percent}%` }}
/>
</div>
<div className={compact ? 'space-y-0.5' : 'space-y-1'}>
{subtasks.map(({ task, column }) => (
<a
key={task.id}
href={`/dashboard/kanban/${task.id}`}
className={cn(
'group/subtask flex items-center gap-2 rounded-md transition-colors',
compact ? 'px-1 py-1' : 'px-1.5 py-1.5',
'hover:bg-muted/50',
)}
>
<span className={cn('size-1.5 shrink-0 rounded-full', column?.dotColor ?? 'bg-muted-foreground')} />
<span className={cn('text-muted-foreground/70 shrink-0 font-mono text-xs')}>{task.id}</span>
<span
className={cn(
'min-w-0 flex-1 truncate',
compact ? 'text-xs' : 'text-sm',
column?.id === 'done' ? 'text-muted-foreground line-through' : 'text-foreground',
)}
>
{task.title}
</span>
<span
className={cn(
'shrink-0 rounded-md px-1.5 py-0.5 text-xs font-medium',
column?.color ?? 'text-muted-foreground',
)}
>
{column?.title ?? 'Unknown'}
</span>
</a>
))}
</div>
</div>
)
}
'use client'
import * as React from 'react'
import { Send } from 'lucide-react'
import { cn } from '@/lib/utils'
import { type CommentItem } from '@/lib/use-kanban'
import { Button } from '@/components/ui/button'
import { RichTextEditor } from '@/components/ui/rich-text-editor'
import { UserAvatar } from './UserAvatar'
export function CommentList({
comments,
compact = false,
onAdd,
}: {
comments: CommentItem[]
compact?: boolean
onAdd?: (text: string) => void
}) {
const [newComment, setNewComment] = React.useState('')
function submit() {
const stripped = newComment.replace(/<[^>]*>/g, '').trim()
if (!stripped) return
onAdd?.(newComment)
setNewComment('')
}
return (
<>
{comments.length ? (
<div className={compact ? 'space-y-3' : 'space-y-4'}>
{comments.map((comment) => (
<div key={comment.id} className={compact ? 'flex gap-2.5' : 'flex gap-3'}>
<UserAvatar name={comment.author} color={comment.authorColor} size={compact ? 'xs' : 'sm'} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className={compact ? 'text-xs font-semibold' : 'text-sm font-semibold'}>
{compact ? comment.author.split(' ')[0] : comment.author}
</span>
<span className="text-muted-foreground text-xs">{comment.time}</span>
</div>
<div
className={cn(
'rich-text-content prose prose-sm dark:prose-invert text-muted-foreground mt-0.5 max-w-none text-xs leading-relaxed',
)}
dangerouslySetInnerHTML={{ __html: comment.text }}
/>
</div>
</div>
))}
</div>
) : (
<p className="text-muted-foreground text-xs">No comments yet.</p>
)}
<div className={compact ? 'mt-3 flex gap-2' : 'mt-4 flex gap-3 border-t pt-4'}>
<div className={compact ? 'mt-1' : 'mt-1.5'}>
<UserAvatar name="Admin User" color="bg-chart-1/15 text-chart-1" size={compact ? 'xs' : 'sm'} />
</div>
<div className="min-w-0 flex-1 space-y-2">
<RichTextEditor
value={newComment}
onValueChange={setNewComment}
placeholder="Write a comment..."
minHeight={compact ? '60px' : '80px'}
className="text-xs"
/>
<div className="flex justify-end">
<Button
size="sm"
className={compact ? 'h-7 gap-1 text-xs' : 'h-8 gap-1.5 text-xs'}
disabled={!newComment.replace(/<[^>]*>/g, '').trim()}
onClick={submit}
>
<Send className="size-3" />
Comment
</Button>
</div>
</div>
</div>
</>
)
}
Raw manifest:https://uipkge.dev/r/react/kanban-task-board.json