Kanban Task Board
blockproductivityFull 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 16 Svelte files (KanbanBoard + SimpleKanban + 6 kanban-* views + 7 small badges/lists + KanbanLink) plus link-context; types, configs and helpers come from the use-kanban hook. Bind with bind:columns; consumer owns the columns array so swapping the in-memory store for Drizzle/Postgres is a one-line change.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/kanban-task-board.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/kanban-task-board.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/kanban-task-board.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/kanban-task-board.jsonhttps://uipkge.dev/r/svelte/kanban-task-board.jsonInstalls to:src/lib/components/blocks/kanban-task-board/Variants
Theming
CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.
--borderFiles installed (17)
src/lib/components/blocks/kanban-task-board/KanbanBoard.svelte9.5 kB
<script lang="ts"> import { onMount } from 'svelte' import type { Component } from 'svelte' import { Plus } from '@lucide/svelte' import { PageHeader, PageHeaderHeading } from '$lib/components/ui/page' import { Button } from '$lib/components/ui/button' import { Badge } from '$lib/components/ui/badge' import KanbanToolbar from './KanbanToolbar.svelte' import KanbanColumn from './KanbanColumn.svelte' import KanbanListView from './KanbanListView.svelte' import KanbanTaskSheet from './KanbanTaskSheet.svelte' import KanbanAddTaskDialog from './KanbanAddTaskDialog.svelte' import type { KanbanColumn as KanbanColumnType, KanbanTask } from '$lib/composables/useKanban' import { setKanbanLinkContext, type KanbanLinkValue } from './link-context' interface KanbanBoardProps { columns?: KanbanColumnType[] title?: string | null description?: string | null defaultColumnId?: string hideHeader?: boolean hideToolbar?: boolean lockParentScroll?: boolean linkComponent?: string | Component<any> onColumnsChange?: (columns: KanbanColumnType[]) => void } let { columns = $bindable([]), title = 'Kanban Board', description = 'Drag tasks across columns to update their status.', defaultColumnId = 'backlog', hideHeader = false, hideToolbar = false, lockParentScroll = true, linkComponent = 'a', onColumnsChange, }: KanbanBoardProps = $props() // Provide the link component to all child views (KanbanCard, KanbanListView, // KanbanTaskSheet, SubtaskList) so they render SPA-aware links without // hard-coding a router. Defaults to a plain <a> — SvelteKit enhances // anchors with client-side routing automatically; pass a custom component // for anything fancier. setKanbanLinkContext({ get current(): KanbanLinkValue { return linkComponent }, }) // The board mutates the columns array in place (splice/push on nested task // lists, like the Vue twin). Re-assign afterwards so `bind:columns` // parents and downstream $deriveds observe the change. function commit() { columns = [...columns] onColumnsChange?.(columns) } let kanbanEl: HTMLElement | null = $state(null) let parentMain: HTMLElement | null = null onMount(() => { if (!lockParentScroll) return parentMain = kanbanEl?.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 = '' parentMain = null } document.documentElement.style.overflow = '' } }) let searchQuery = $state('') let selectedPriority = $state<string | null>(null) let selectedAssignee = $state<string | null>(null) let viewMode = $state<'board' | 'list'>('board') const filteredColumns = $derived( 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 }), })), ) const totalTasks = $derived(columns.reduce((sum, col) => sum + col.tasks.length, 0)) let collapsedColumns = $state<Set<string>>(new Set()) function toggleCollapse(columnId: string) { const next = new Set(collapsedColumns) if (next.has(columnId)) next.delete(columnId) else next.add(columnId) collapsedColumns = next } function addComment(task: KanbanTask, text: string) { task.commentItems.push({ id: `c${Date.now()}`, author: 'Admin User', authorColor: 'bg-chart-1/15 text-chart-1', text, time: 'Just now', }) commit() } function moveTask(task: KanbanTask, targetColumnId: string) { const sourceCol = columns.find((c) => c.tasks.some((t) => t.id === task.id)) const targetCol = columns.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]) commit() } let detailOpen = $state(false) let detailTask = $state<KanbanTask | null>(null) function openTaskDetail(task: KanbanTask) { detailTask = task detailOpen = true } let draggedTask = $state<string | null>(null) let dragOverColumn = $state<string | null>(null) let dropTargetIndex = $state(-1) let lastDragEnd = 0 function onDragStart(event: DragEvent, taskId: string) { draggedTask = taskId if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'move' event.dataTransfer.setData('text/plain', taskId) } } function resetDrag() { draggedTask = null dragOverColumn = null dropTargetIndex = -1 lastDragEnd = Date.now() } function onDrop() { if (!draggedTask || !dragOverColumn) { resetDrag() return } let sourceColIdx = -1 let taskIdx = -1 for (let c = 0; c < columns.length; c++) { const col = columns[c] if (!col) continue const tIdx = col.tasks.findIndex((t) => t.id === draggedTask) if (tIdx !== -1) { sourceColIdx = c taskIdx = tIdx break } } const targetColIdx = columns.findIndex((c) => c.id === dragOverColumn) if (sourceColIdx === -1 || targetColIdx === -1) { resetDrag() return } const sourceCol = columns[sourceColIdx] const targetCol = columns[targetColIdx] if (!sourceCol || !targetCol) { resetDrag() return } const [task] = sourceCol.tasks.splice(taskIdx, 1) if (!task) { resetDrag() return } let insertAt = dropTargetIndex if (insertAt < 0) insertAt = targetCol.tasks.length if (sourceColIdx === targetColIdx && taskIdx < insertAt) insertAt-- targetCol.tasks.splice(insertAt, 0, task) commit() resetDrag() } function onCardClick(task: KanbanTask) { if (Date.now() - lastDragEnd < 200) return openTaskDetail(task) } function onCardDragOver(event: DragEvent, columnId: string, taskIndex: number) { dragOverColumn = columnId const rect = (event.currentTarget as HTMLElement).getBoundingClientRect() const midY = rect.top + rect.height / 2 dropTargetIndex = event.clientY < midY ? taskIndex : taskIndex + 1 } function onLaneDragOver(_event: DragEvent, columnId: string, taskCount: number) { dragOverColumn = columnId dropTargetIndex = taskCount } let addTaskOpen = $state(false) let addTaskColumnId = $state(defaultColumnId) function openAddTask(columnId: string) { addTaskColumnId = columnId addTaskOpen = true } function onCreateTask(columnId: string, tasks: KanbanTask[]) { const col = columns.find((c) => c.id === columnId) if (col) col.tasks.push(...tasks) commit() } </script> <div bind:this={kanbanEl} data-slot="kanban-board" class="kanban-page flex h-[calc(100dvh-3.5rem-3rem)] flex-col overflow-hidden lg:h-[calc(100dvh-3.5rem-3rem)]" > {#if !hideHeader} <div class="mb-3 shrink-0"> <PageHeader> <PageHeaderHeading title={title ?? ''} description={description ?? ''} /> {#snippet actions()} <div class="flex shrink-0 items-center gap-2"> <Badge variant="secondary" class="font-mono text-xs tabular-nums">{totalTasks} tasks</Badge> <Button size="sm" onclick={() => openAddTask(defaultColumnId)}> <Plus class="size-4" /> Add Task </Button> </div> {/snippet} </PageHeader> </div> {/if} {#if !hideToolbar} <KanbanToolbar bind:searchQuery bind:selectedPriority bind:selectedAssignee bind:viewMode /> {/if} {#if viewMode === 'board'} <div class="kanban-board relative flex min-h-0 flex-1 items-start gap-3 overflow-auto pb-3"> {#each filteredColumns as column (column.id)} <KanbanColumn {column} columns={columns} collapsed={collapsedColumns.has(column.id)} {draggedTask} {dragOverColumn} {dropTargetIndex} onToggleCollapse={toggleCollapse} onAddTask={openAddTask} onCardClick={onCardClick} onCardQuickView={openTaskDetail} {onDragStart} onDragEnd={resetDrag} {onCardDragOver} {onLaneDragOver} onDrop={onDrop} /> {/each} </div> {:else} <KanbanListView columns={filteredColumns} allColumns={columns} onTaskClick={openTaskDetail} onMoveTask={moveTask} /> {/if} <KanbanTaskSheet bind:open={detailOpen} task={detailTask} columns={columns} onMoveTask={moveTask} onAddComment={addComment} /> <KanbanAddTaskDialog bind:open={addTaskOpen} columns={columns} initialColumnId={addTaskColumnId} onCreate={onCreateTask} /> </div> <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; } </style>src/lib/components/blocks/kanban-task-board/SimpleKanban.svelte12 kB
<script lang="ts" module> 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[] } </script> <script lang="ts"> import { Plus, Calendar, GripVertical, Check } from '@lucide/svelte' import { cn } from '$lib/utils' import { Button } from '$lib/components/ui/button' import { Avatar, AvatarFallback } from '$lib/components/ui/avatar' interface SimpleKanbanProps { columns?: SimpleKanbanColumn[] allowAdd?: boolean className?: string cardClassName?: string onColumnsChange?: (columns: SimpleKanbanColumn[]) => void onCardClick?: (item: SimpleKanbanItem, columnId: string) => void onCardMove?: (item: SimpleKanbanItem, fromColumnId: string, toColumnId: string, newIndex: number) => void onAddItem?: (columnId: string, title: string) => void } let { columns = $bindable([]), allowAdd = true, className, cardClassName, onColumnsChange, onCardClick, onCardMove, onAddItem, }: SimpleKanbanProps = $props() 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' }, } let draggedId = $state<string | null>(null) let dragOverColumnId = $state<string | null>(null) let dropTargetIndex = $state(-1) let addingColumnId = $state<string | null>(null) let newTitle = $state('') let lastDragTime = 0 function handleDragStart(e: DragEvent, id: string) { draggedId = id if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', id) } } function handleDragEnd() { draggedId = null dragOverColumnId = null dropTargetIndex = -1 lastDragTime = Date.now() } function handleDragOverColumn(e: DragEvent, columnId: string, itemsCount: number) { e.preventDefault() if (e.dataTransfer) e.dataTransfer.dropEffect = 'move' dragOverColumnId = columnId dropTargetIndex = itemsCount } function handleCardDragOver(e: DragEvent, columnId: string, index: number) { e.preventDefault() e.stopPropagation() if (e.dataTransfer) e.dataTransfer.dropEffect = 'move' dragOverColumnId = columnId const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() const midY = rect.top + rect.height / 2 dropTargetIndex = e.clientY < midY ? index : index + 1 } function commit(next: SimpleKanbanColumn[]) { columns = next onColumnsChange?.(next) } function handleDrop(e: DragEvent, targetColumnId: string) { e.preventDefault() if (!draggedId || !targetColumnId) { handleDragEnd() return } const next = columns.map((col) => ({ ...col, items: [...col.items], })) let sourceColIdx = -1 let itemIdx = -1 for (let c = 0; c < next.length; c++) { const col = next[c] if (!col) continue const idx = col.items.findIndex((item) => item.id === draggedId) if (idx !== -1) { sourceColIdx = c itemIdx = idx break } } const targetColIdx = next.findIndex((c) => c.id === targetColumnId) if (sourceColIdx === -1 || targetColIdx === -1) { handleDragEnd() return } const sourceCol = next[sourceColIdx] const targetCol = next[targetColIdx] if (!sourceCol || !targetCol) { handleDragEnd() return } const [removed] = sourceCol.items.splice(itemIdx, 1) if (!removed) { handleDragEnd() return } let at = dropTargetIndex if (at < 0) at = targetCol.items.length if (sourceColIdx === targetColIdx && itemIdx < at) at-- targetCol.items.splice(at, 0, removed) commit(next) onCardMove?.(removed, sourceCol.id, targetColumnId, at) handleDragEnd() } function handleInlineAdd(columnId: string) { if (!newTitle.trim()) { addingColumnId = null return } const text = newTitle.trim() onAddItem?.(columnId, text) const next = columns.map((col) => ({ ...col, items: [...col.items], })) const col = next.find((c) => c.id === columnId) if (col) { col.items.push({ id: `item-${Date.now()}`, title: text, }) commit(next) } newTitle = '' addingColumnId = null } function handleCardClick(item: SimpleKanbanItem, columnId: string) { if (Date.now() - lastDragTime < 150) return onCardClick?.(item, columnId) } </script> <div data-slot="simple-kanban" class={cn('flex min-h-[380px] w-full [scrollbar-width:thin] items-start gap-4 overflow-x-auto pb-4', className)} > {#each columns as column (column.id)} <div data-slot="kanban-column" class={cn( 'bg-muted/40 border-border/80 flex w-72 shrink-0 flex-col rounded-xl border p-3 transition-colors', dragOverColumnId === column.id && '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 class="mb-3 flex items-center justify-between gap-2 px-1"> <div class="flex items-center gap-2"> <span class={cn( 'size-2 rounded-full', column.dotColor || column.color || DEFAULT_DOT_COLORS[column.id] || 'bg-primary', )} ></span> <h3 class="text-foreground text-sm font-semibold tracking-tight">{column.title}</h3> <span class="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums"> {column.items.length} </span> </div> {#if allowAdd} <Button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-7 rounded-lg" aria-label="Add item to {column.title}" onclick={() => { addingColumnId = column.id newTitle = '' }} > <Plus class="size-3.5" /> </Button> {/if} </div> <!-- Column Cards Container --> <div class="flex min-h-[120px] flex-1 flex-col gap-2.5"> <!-- Inline quick add input --> {#if addingColumnId === column.id} <div class="bg-card border-border animate-in fade-in zoom-in-95 rounded-lg border p-2.5 shadow-xs duration-150" > <input bind:value={newTitle} type="text" autofocus placeholder="Item title..." class="placeholder:text-muted-foreground/60 w-full bg-transparent text-sm font-medium outline-none" onkeydown={(e) => { if (e.key === 'Enter') handleInlineAdd(column.id) if (e.key === 'Escape') addingColumnId = null }} /> <div class="mt-2.5 flex items-center justify-end gap-1.5"> <Button size="sm" variant="ghost" class="h-7 px-2 text-xs" onclick={() => (addingColumnId = null)}> Cancel </Button> <Button size="sm" class="h-7 gap-1 px-2.5 text-xs" onclick={() => handleInlineAdd(column.id)}> <Check class="size-3" /> Add </Button> </div> </div> {/if} <!-- Cards list --> {#each column.items as item, index (item.id)} {#if dragOverColumnId === column.id && dropTargetIndex === index && draggedId !== item.id} <div class="bg-primary/20 h-1.5 w-full rounded-full"></div> {/if} <div role="button" tabindex="0" draggable="true" data-slot="kanban-card" class={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', draggedId === item.id && 'ring-primary/40 opacity-40 shadow-md ring-2', cardClassName, )} ondragstart={(e) => handleDragStart(e, item.id)} ondragend={handleDragEnd} ondragover={(e) => handleCardDragOver(e, column.id, index)} onclick={() => handleCardClick(item, column.id)} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() handleCardClick(item, column.id) } }} > <!-- Top row: Title + Grip handle --> <div class="flex items-start justify-between gap-2"> <p class="text-foreground line-clamp-2 text-sm leading-snug font-medium"> {item.title} </p> <GripVertical class="text-muted-foreground/30 size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" /> </div> <!-- Optional description --> {#if item.description} <p class="text-muted-foreground line-clamp-2 text-xs leading-relaxed"> {item.description} </p> {/if} <!-- Metadata row: Date, Tag, Priority, Assignee --> {#if item.date || item.tag || item.priority || item.assignee} <div class="border-border/40 mt-1 flex flex-wrap items-center justify-between gap-1.5 border-t pt-1 text-xs"> <div class="flex flex-wrap items-center gap-1.5"> {#if item.date} <div class="text-muted-foreground flex items-center gap-1 text-xs"> <Calendar class="text-muted-foreground/70 size-3" /> <span>{item.date}</span> </div> {/if} {#if item.tag} <span class={cn('rounded-md px-1.5 py-0.5 text-xs font-medium', item.tagColor || 'bg-secondary text-secondary-foreground')}> {item.tag} </span> {/if} {#if item.priority && PRIORITY_STYLES[item.priority]} <span class={cn('rounded-md px-1.5 py-0.5 text-xs', PRIORITY_STYLES[item.priority].class)}> {PRIORITY_STYLES[item.priority].label} </span> {/if} </div> {#if item.assignee} <Avatar class="border-background size-5 shrink-0 border"> <AvatarFallback class="text-xs font-medium"> {item.assignee.initials || item.assignee.name.slice(0, 2).toUpperCase()} </AvatarFallback> </Avatar> {/if} </div> {/if} </div> {/each} <!-- Trailing drop indicator --> {#if dragOverColumnId === column.id && dropTargetIndex >= column.items.length && draggedId} <div class="bg-primary/20 h-1.5 w-full rounded-full"></div> {/if} <!-- Empty column placeholder --> {#if column.items.length === 0 && addingColumnId !== column.id} <div class="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> {/if} </div> </div> {/each} </div>src/lib/components/blocks/kanban-task-board/KanbanCard.svelte5.8 kB
<script lang="ts"> import { type KanbanTask, type KanbanColumn, priorityConfig, getTaskColumn } from '$lib/composables/useKanban' import TagBadge from './TagBadge.svelte' import SubtaskProgress from './SubtaskProgress.svelte' import DueDateBadge from './DueDateBadge.svelte' import UserAvatar from './UserAvatar.svelte' import KanbanLink from './KanbanLink.svelte' import { Button } from '$lib/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '$lib/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '$lib/components/ui/tooltip' import { MoreHorizontal, MessageSquare, Paperclip, ExternalLink } from '@lucide/svelte' interface KanbanCardProps { task: KanbanTask isDone: boolean columns: KanbanColumn[] onClick?: (task: KanbanTask) => void onQuickView?: (task: KanbanTask) => void } let { task, isDone, columns, onClick, onQuickView }: KanbanCardProps = $props() const subtasksDone = $derived( !columns || !task.subtaskIds.length ? 0 : task.subtaskIds.filter((id) => getTaskColumn(columns, id)?.id === 'done').length, ) // Screen-reader name: title first, then status + priority for context. const ariaLabel = $derived( [task.title, columns ? getTaskColumn(columns, task.id)?.title : undefined, `${priorityConfig[task.priority].label} priority`] .filter(Boolean) .join(', '), ) </script> <div data-slot="kanban-board" class={[ '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', 'focus-visible:border-ring focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px]', isDone ? 'opacity-75 hover:opacity-100 focus-visible:opacity-100' : '', ]} onclick={() => onClick?.(task)} onkeydown={(e) => { if (e.target !== e.currentTarget) return if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() onClick?.(task) } }} role="button" tabindex="0" aria-label={ariaLabel} > <div class={[ 'kanban-accent absolute top-3 bottom-3 left-0 w-[0.09375rem] rounded-full transition-colors duration-150', priorityConfig[task.priority]?.bg, task.priority === 'low' ? 'opacity-40' : task.priority === 'medium' ? 'opacity-60' : 'opacity-90', ]} ></div> <div class="mb-1 flex items-center justify-between pl-2"> <span class="text-muted-foreground/70 font-mono text-xs">{task.id}</span> <DropdownMenu> <DropdownMenuTrigger> {#snippet child({ props })} <Button aria-label="More actions for {task.id}" variant="ghost" size="icon" {...props} class="text-muted-foreground -mr-1 size-6 opacity-0 transition-opacity group-focus-within/card:opacity-100 group-hover/card:opacity-100 data-[state=open]:opacity-100" onclick={(e: MouseEvent) => { e.stopPropagation() ;(props.onclick as ((e: MouseEvent) => void) | undefined)?.(e) }} > <MoreHorizontal class="size-3.5" aria-hidden="true" /> </Button> {/snippet} </DropdownMenuTrigger> <DropdownMenuContent align="end" class="w-36"> <DropdownMenuItem onclick={(e) => { e.stopPropagation() onQuickView?.(task) }}>Quick view</DropdownMenuItem > <DropdownMenuItem> <KanbanLink href="/dashboard/kanban/{task.id}" to="/dashboard/kanban/{task.id}" class="flex items-center gap-2"> <ExternalLink class="size-3.5" /> Open detail </KanbanLink> </DropdownMenuItem> <DropdownMenuItem>Edit</DropdownMenuItem> <DropdownMenuItem>Move to...</DropdownMenuItem> <DropdownMenuItem>Assign to...</DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem class="text-destructive">Delete</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </div> <p class={['mb-2 pl-2 text-sm leading-snug font-medium', isDone ? 'decoration-muted-foreground/40 line-through' : '']}> {task.title} </p> {#if task.tags.length} <div class="mb-2 flex flex-wrap gap-1 pl-2"> {#each task.tags as tag (tag.label)} <TagBadge label={tag.label} color={tag.color} /> {/each} </div> {/if} {#if task.subtaskIds.length} <div class="mb-2 pl-2"> <SubtaskProgress done={subtasksDone} total={task.subtaskIds.length} /> </div> {/if} <div class="flex items-center gap-2 pl-2"> {#if task.dueDate} <DueDateBadge dueDate={task.dueDate} variant="chip" /> {/if} {#if task.commentItems.length} <div class="text-muted-foreground/70 flex items-center gap-1 text-xs"> <MessageSquare class="size-3" /> {task.commentItems.length} </div> {/if} {#if task.fileItems.length} <div class="text-muted-foreground/70 flex items-center gap-1 text-xs"> <Paperclip class="size-3" /> {task.fileItems.length} </div> {/if} <div class="ml-auto"> <TooltipProvider delayDuration={200}> <Tooltip> <TooltipTrigger> <UserAvatar name={task.assignee.name} color={task.assignee.color} size="xs" /> </TooltipTrigger> <TooltipContent side="bottom" class="text-xs">{task.assignee.name}</TooltipContent> </Tooltip> </TooltipProvider> </div> </div> </div> <style> .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; } </style>src/lib/components/blocks/kanban-task-board/KanbanColumn.svelte6.2 kB
<script lang="ts"> import { Plus, MoreHorizontal, ChevronsLeft, ChevronsRight } from '@lucide/svelte' import { Button } from '$lib/components/ui/button' import { Badge } from '$lib/components/ui/badge' import KanbanCard from './KanbanCard.svelte' import type { KanbanColumn as KanbanColumnType, KanbanTask } from '$lib/composables/useKanban' interface KanbanColumnProps { column: { id: string; title: string; color: string; dotColor: string; tasks: KanbanTask[] } columns: KanbanColumnType[] collapsed: boolean draggedTask: string | null dragOverColumn: string | null dropTargetIndex: number onToggleCollapse?: (columnId: string) => void onAddTask?: (columnId: string) => void onCardClick?: (task: KanbanTask) => void onCardQuickView?: (task: KanbanTask) => void onDragStart?: (event: DragEvent, taskId: string) => void onDragEnd?: () => void onCardDragOver?: (event: DragEvent, columnId: string, taskIndex: number) => void onLaneDragOver?: (event: DragEvent, columnId: string, taskCount: number) => void onDrop?: () => void } let { column, columns, collapsed, draggedTask, dragOverColumn, dropTargetIndex, onToggleCollapse, onAddTask, onCardClick, onCardQuickView, onDragStart, onDragEnd, onCardDragOver, onLaneDragOver, onDrop, }: KanbanColumnProps = $props() </script> <div data-slot="kanban-board" class={['group/col flex shrink-0 flex-col transition-[width] duration-200', collapsed ? 'w-12' : 'w-80']} ondragover={(e) => e.preventDefault()} ondrop={() => onDrop?.()} > {#if collapsed} <button class="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 class={['size-2 shrink-0 rounded-full', column.dotColor]}></span> <span class={['text-xs font-semibold tracking-tight', column.color, 'rotate-180 [writing-mode:vertical-lr]']}> {column.title} </span> <Badge variant="secondary" class="mt-1 h-5 min-w-5 justify-center rounded-md px-1 text-xs"> {column.tasks.length} </Badge> <ChevronsRight class="text-muted-foreground mt-auto size-3.5" /> </button> {:else} <div class="bg-background/95 sticky top-0 z-10 mb-2 flex items-center gap-2 px-2 py-1.5 backdrop-blur-sm"> <button class="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors" title="Collapse column" onclick={() => onToggleCollapse?.(column.id)} > <ChevronsLeft class="size-3.5" /> </button> <span class={['size-2 shrink-0 rounded-full', column.dotColor]}></span> <h3 class={['text-sm font-semibold tracking-tight', column.color]}>{column.title}</h3> <span class="text-muted-foreground bg-muted rounded-md px-1.5 py-0.5 text-xs font-medium tabular-nums"> {column.tasks.length} </span> <div class="ml-auto flex items-center"> <Button aria-label="Column options" variant="ghost" size="icon" class="text-muted-foreground size-6 opacity-0 group-hover/col:opacity-100" > <MoreHorizontal class="size-3.5" /> </Button> <Button aria-label="Add task" variant="ghost" size="icon" class="text-muted-foreground size-6" onclick={() => onAddTask?.(column.id)} > <Plus class="size-3.5" /> </Button> </div> </div> <div class={[ '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) }} > {#each column.tasks as task, taskIndex (task.id)} <div class={[ '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> <div data-task-id={task.id} draggable="true" class={['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} {columns} isDone={column.id === 'done'} onClick={(t) => onCardClick?.(t)} onQuickView={(t) => onCardQuickView?.(t)} /> </div> {/each} {#if column.tasks.length > 0} <div class={[ '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', ]} ></div> {/if} {#if column.tasks.length === 0} <button aria-label="Add task" class="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 class="mb-1 size-4" /> <p class="text-xs">No tasks</p> </button> {/if} </div> <button aria-label="Add task" class="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 class="size-3.5" /> Add task </button> {/if} </div> <style> .kanban-lane { min-height: 60px; } </style>src/lib/components/blocks/kanban-task-board/KanbanListView.svelte11.8 kB
<script lang="ts"> import type { KanbanTask, KanbanColumn } from '$lib/composables/useKanban' import { getTaskColumn } from '$lib/composables/useKanban' import PriorityBadge from './PriorityBadge.svelte' import TagBadge from './TagBadge.svelte' import DueDateBadge from './DueDateBadge.svelte' import UserAvatar from './UserAvatar.svelte' import SubtaskProgress from './SubtaskProgress.svelte' import KanbanLink from './KanbanLink.svelte' import { Badge } from '$lib/components/ui/badge' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '$lib/components/ui/select' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '$lib/components/ui/tooltip' import { MessageSquare, Paperclip, ExternalLink, ArrowUpDown, ChevronDown, ChevronRight } from '@lucide/svelte' interface KanbanListViewProps { columns: KanbanColumn[] allColumns: KanbanColumn[] onTaskClick?: (task: KanbanTask) => void onMoveTask?: (task: KanbanTask, targetColumnId: string) => void } let { columns, allColumns, onTaskClick, onMoveTask }: KanbanListViewProps = $props() type SortField = 'id' | 'title' | 'priority' | 'assignee' | 'dueDate' | 'status' type SortDir = 'asc' | 'desc' let sortField = $state<SortField>('status') let sortDir = $state<SortDir>('asc') const priorityOrder: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3 } function toggleSort(field: SortField) { if (sortField === field) { sortDir = sortDir === 'asc' ? 'desc' : 'asc' } else { sortField = field sortDir = 'asc' } } const groupByStatus = $state(true) let collapsedGroups = $state<Set<string>>(new Set()) function toggleGroup(columnId: string) { const next = new Set(collapsedGroups) if (next.has(columnId)) next.delete(columnId) else next.add(columnId) collapsedGroups = next } interface FlatTask { task: KanbanTask columnId: string columnTitle: string dotColor: string } function sortTasks(tasks: FlatTask[]): FlatTask[] { return [...tasks].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 }) } const flatTasks = $derived.by((): 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 sortTasks(items) }) const groupedTasks = $derived.by(() => { if (!groupByStatus) return null const groups: { column: KanbanColumn; tasks: FlatTask[] }[] = [] for (const col of allColumns) { const tasks = flatTasks.filter((t) => t.columnId === col.id) groups.push({ column: col, tasks }) } return groups }) function subtasksDone(task: KanbanTask): number { if (!task.subtaskIds.length) return 0 return task.subtaskIds.filter((id) => getTaskColumn(allColumns, id)?.id === 'done').length } </script> <div data-slot="kanban-board" class="kanban-list flex min-h-0 flex-1 flex-col overflow-auto pb-3"> <div class="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" > <button class="flex items-center gap-1 text-left" onclick={() => toggleSort('id')}> ID <ArrowUpDown class={['size-3', sortField === 'id' ? 'text-foreground' : 'text-muted-foreground/50']} /> </button> <button class="flex items-center gap-1 text-left" onclick={() => toggleSort('title')}> Task <ArrowUpDown class={['size-3', sortField === 'title' ? 'text-foreground' : 'text-muted-foreground/50']} /> </button> <button class="flex items-center gap-1 text-left" onclick={() => toggleSort('status')}> Status <ArrowUpDown class={['size-3', sortField === 'status' ? 'text-foreground' : 'text-muted-foreground/50']} /> </button> <button class="flex items-center gap-1 text-left" onclick={() => toggleSort('priority')}> Priority <ArrowUpDown class={['size-3', sortField === 'priority' ? 'text-foreground' : 'text-muted-foreground/50']} /> </button> <button class="flex items-center gap-1 text-left" onclick={() => toggleSort('assignee')}> Assignee <ArrowUpDown class={['size-3', sortField === 'assignee' ? 'text-foreground' : 'text-muted-foreground/50']} /> </button> <button class="flex items-center gap-1 text-left" onclick={() => toggleSort('dueDate')}> Due <ArrowUpDown class={['size-3', sortField === 'dueDate' ? 'text-foreground' : 'text-muted-foreground/50']} /> </button> <span class="text-center">Info</span> </div> {#if groupedTasks} {#each groupedTasks as group (group.column.id)} <button class="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)} > {#if collapsedGroups.has(group.column.id)} <ChevronRight class="text-muted-foreground size-3.5" /> {:else} <ChevronDown class="text-muted-foreground size-3.5" /> {/if} <span class={['size-2 rounded-full', group.column.dotColor]}></span> <span class="text-sm font-medium">{group.column.title}</span> <Badge variant="secondary" class="ml-1 h-4 px-1.5 text-xs tabular-nums"> {group.tasks.length} </Badge> </button> {#if !collapsedGroups.has(group.column.id)} {#each group.tasks as item (item.task.id)} <div class="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)} onkeydown={(e) => { if (e.key === 'Enter') onTaskClick?.(item.task) }} role="button" tabindex="0" > <span class="text-muted-foreground font-mono text-xs">{item.task.id}</span> <div class="min-w-0"> <div class="flex items-center gap-2"> <span class={[ 'truncate text-sm font-medium', item.columnId === 'done' ? 'text-muted-foreground line-through' : '', ]} > {item.task.title} </span> <KanbanLink href="/dashboard/kanban/{item.task.id}" to="/dashboard/kanban/{item.task.id}" class="text-muted-foreground hover:text-foreground shrink-0 opacity-0 transition-opacity group-hover/row:opacity-100" onclick={(e) => e.stopPropagation()} > <ExternalLink class="size-3" /> </KanbanLink> </div> {#if item.task.tags.length || item.task.subtaskIds.length} <div class="mt-0.5 flex items-center gap-1.5"> {#each item.task.tags as tag (tag.label)} <TagBadge label={tag.label} color={tag.color} class="!px-1.5 !py-0 !text-xs" /> {/each} {#if item.task.subtaskIds.length} <SubtaskProgress done={subtasksDone(item.task)} total={item.task.subtaskIds.length} class="ml-1" /> {/if} </div> {/if} </div> <div> <Select value={item.columnId} onValueChange={(val) => onMoveTask?.(item.task, String(val))}> <SelectTrigger class="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 class="flex items-center gap-1.5"> <span class={['size-1.5 rounded-full', item.dotColor]}></span> <SelectValue /> </span> </SelectTrigger> <SelectContent> {#each allColumns as col (col.id)} <SelectItem value={col.id}> <span class="flex items-center gap-1.5"> <span class={['size-1.5 rounded-full', col.dotColor]}></span> {col.title} </span> </SelectItem> {/each} </SelectContent> </Select> </div> <PriorityBadge priority={item.task.priority} /> <div class="flex items-center gap-2"> <UserAvatar name={item.task.assignee.name} color={item.task.assignee.color} size="xs" /> <span class="truncate text-xs">{item.task.assignee.name}</span> </div> <div> {#if item.task.dueDate} <DueDateBadge dueDate={item.task.dueDate} variant="chip" /> {:else} <span class="text-muted-foreground/50 text-xs">—</span> {/if} </div> <div class="flex items-center justify-center gap-2"> <TooltipProvider delayDuration={200}> {#if item.task.commentItems.length} <Tooltip> <TooltipTrigger> <span class="text-muted-foreground/70 flex items-center gap-0.5 text-xs"> <MessageSquare class="size-3" /> {item.task.commentItems.length} </span> </TooltipTrigger> <TooltipContent class="text-xs">{item.task.commentItems.length} comments</TooltipContent> </Tooltip> {/if} {#if item.task.fileItems.length} <Tooltip> <TooltipTrigger> <span class="text-muted-foreground/70 flex items-center gap-0.5 text-xs"> <Paperclip class="size-3" /> {item.task.fileItems.length} </span> </TooltipTrigger> <TooltipContent class="text-xs">{item.task.fileItems.length} files</TooltipContent> </Tooltip> {/if} </TooltipProvider> </div> </div> {/each} {/if} {/each} {/if} {#if flatTasks.length === 0} <div class="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> {/if} </div> <style> .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>src/lib/components/blocks/kanban-task-board/KanbanTaskSheet.svelte8.9 kB
<script lang="ts"> import { type KanbanTask, type KanbanColumn, priorityConfig, fileIconMap } from '$lib/composables/useKanban' import PriorityBadge from './PriorityBadge.svelte' import TagBadge from './TagBadge.svelte' import DueDateBadge from './DueDateBadge.svelte' import UserAvatar from './UserAvatar.svelte' import CommentList from './CommentList.svelte' import SubtaskList from './SubtaskList.svelte' import KanbanLink from './KanbanLink.svelte' import { Sheet, SheetContent, SheetTitle, SheetDescription, SheetFooter, SheetClose } from '$lib/components/ui/sheet' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '$lib/components/ui/select' import { Button } from '$lib/components/ui/button' import { Clock, Download, ExternalLink } from '@lucide/svelte' import { tick } from 'svelte' interface KanbanTaskSheetProps { open?: boolean task: KanbanTask | null columns: KanbanColumn[] onMoveTask?: (task: KanbanTask, columnId: string) => void onAddComment?: (task: KanbanTask, text: string) => void } let { open = $bindable(false), task, columns, onMoveTask, onAddComment, }: KanbanTaskSheetProps = $props() const columnIdForTask = $derived.by(() => { if (!task) return '' return columns.find((c) => c.tasks.some((t) => t.id === task.id))?.id ?? '' }) // A status change re-renders the card in its new column, so the element that // opened the sheet is gone by close time; send focus to the card's new node. // Runs after SheetContent's own focus restore (a microtask later). $effect(() => { if (!open) return return () => { const id = task?.id if (!id || typeof document === 'undefined') return tick().then(() => { document.querySelector<HTMLElement>(`[data-task-id="${id}"] > [role="button"]`)?.focus() }) } }) </script> <Sheet bind:open> <SheetContent data-slot="kanban-board" class="flex max-w-[420px] flex-col gap-0 overflow-hidden p-0 sm:max-w-full"> {#if task} <div class={['h-1 w-full shrink-0', priorityConfig[task.priority]?.bg]}></div> <div class="shrink-0 px-5 pt-4 pb-3"> <div class="mb-3 flex items-center gap-2"> <span class="text-muted-foreground font-mono text-xs tracking-tight">{task.id}</span> <span class="text-muted-foreground/30">·</span> <Select value={columnIdForTask} onValueChange={(val) => task && onMoveTask?.(task, String(val))}> <SelectTrigger aria-label="Status" class="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> {#each columns as col (col.id)} <SelectItem value={col.id}> <span class="flex items-center gap-1.5"> <span class={['size-1.5 rounded-full', col.dotColor]}></span> {col.title} </span> </SelectItem> {/each} </SelectContent> </Select> <PriorityBadge priority={task.priority} iconSize="size-3" class="ml-auto" /> </div> <SheetTitle class="text-sm leading-snug font-semibold tracking-tight"> {task.title} </SheetTitle> <SheetDescription class="sr-only">Task details</SheetDescription> {#if task.description} <div class="text-muted-foreground rich-text-content prose prose-sm dark:prose-invert mt-1.5 max-w-none text-sm leading-relaxed" > {@html task.description} </div> {:else} <p class="text-muted-foreground mt-1.5 text-sm leading-relaxed">No description provided.</p> {/if} <div class="mt-3 flex flex-wrap gap-1.5"> {#if task.tags.length} {#each task.tags as tag (tag.label)} <TagBadge label={tag.label} color={tag.color} /> {/each} {:else} <span class="text-muted-foreground text-xs">No tags</span> {/if} </div> </div> <div class="bg-border mx-5 h-px"></div> <div class="flex-1 overflow-y-auto"> <div class="space-y-4 px-5 py-3"> <div class="flex items-center gap-3"> <UserAvatar name={task.assignee.name} color={task.assignee.color} size="md" /> <div> <p class="text-sm leading-tight font-medium">{task.assignee.name}</p> <p class="text-muted-foreground text-xs">Assignee</p> </div> <div class="ml-auto text-right"> {#if task.dueDate} <DueDateBadge dueDate={task.dueDate} /> {:else} <p class="text-muted-foreground flex items-center gap-1 text-sm leading-tight"> <Clock class="size-3" /> No due date </p> {/if} </div> </div> {#if task.parentId} <div class="flex items-center gap-2"> <span class="text-muted-foreground text-xs">Parent:</span> <KanbanLink href="/dashboard/kanban/{task.parentId}" to="/dashboard/kanban/{task.parentId}" class="text-primary text-xs font-medium hover:underline" onclick={() => (open = false)} > {task.parentId} </KanbanLink> </div> {/if} <div> <h4 class="mb-2 text-sm font-semibold"> Subtasks {#if task.subtaskIds.length} <span class="text-muted-foreground font-normal"> ({task.subtaskIds.length}) </span> {/if} </h4> <SubtaskList subtaskIds={task.subtaskIds} {columns} compact /> </div> <div class="bg-border h-px"></div> <div> <h4 class="mb-2 text-sm font-semibold"> Comments {#if task.commentItems.length} <span class="text-muted-foreground font-normal"> ({task.commentItems.length}) </span> {/if} </h4> <CommentList comments={task.commentItems} compact onAdd={(text) => task && onAddComment?.(task, text)} /> </div> <div class="bg-border h-px"></div> <div> <h4 class="mb-2 text-sm font-semibold"> Files {#if task.fileItems.length} <span class="text-muted-foreground font-normal"> ({task.fileItems.length}) </span> {/if} </h4> {#if task.fileItems.length} <div class="space-y-1"> {#each task.fileItems as file (file.id)} {@const FileIcon = fileIconMap[file.type] ?? fileIconMap.other} <div class="hover:bg-muted/50 group/file flex items-center gap-2.5 rounded-md px-1.5 py-1.5 transition-colors" > <div class="bg-muted flex size-8 shrink-0 items-center justify-center rounded-md"> {#if FileIcon} <FileIcon class="text-muted-foreground size-4" /> {/if} </div> <div class="min-w-0 flex-1"> <p class="truncate text-xs font-medium">{file.name}</p> <p class="text-muted-foreground text-xs">{file.size}</p> </div> <Button aria-label="Download attachment" variant="ghost" size="icon" class="size-7 shrink-0 opacity-0 group-hover/file:opacity-100" > <Download class="size-3.5" /> </Button> </div> {/each} </div> {:else} <p class="text-muted-foreground text-xs">No files attached.</p> {/if} </div> </div> </div> <SheetFooter class="shrink-0 border-t px-5 py-3"> <div class="flex w-full items-center gap-2"> <KanbanLink href="/dashboard/kanban/{task.id}" to="/dashboard/kanban/{task.id}" class="flex-1" onclick={() => (open = false)} > <Button variant="outline" size="sm" class="w-full gap-1.5"> <ExternalLink class="size-3.5" /> View full detail </Button> </KanbanLink> <SheetClose> {#snippet child({ props })} <Button variant="ghost" size="sm" {...props}>Close</Button> {/snippet} </SheetClose> </div> </SheetFooter> {/if} </SheetContent> </Sheet>src/lib/components/blocks/kanban-task-board/KanbanAddTaskDialog.svelte9.5 kB
<script lang="ts"> import type { KanbanTask, KanbanColumn } from '$lib/composables/useKanban' import { priorityConfig, assignees, tagPresets } from '$lib/composables/useKanban' import { Dialog, DialogScrollContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '$lib/components/ui/dialog' import { Button } from '$lib/components/ui/button' import { Input } from '$lib/components/ui/input' import { Label } from '$lib/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '$lib/components/ui/select' import { Popover, PopoverContent, PopoverTrigger } from '$lib/components/ui/popover' import { Calendar } from '$lib/components/ui/calendar' import { RichTextEditor } from '$lib/components/ui/rich-text-editor' import { Plus, X, CheckCircle2, Calendar as CalendarIcon } from '@lucide/svelte' import { getLocalTimeZone, DateFormatter, type DateValue } from '@internationalized/date' interface KanbanAddTaskDialogProps { open?: boolean columns: KanbanColumn[] initialColumnId: string onCreate?: (columnId: string, tasks: KanbanTask[]) => void } let { open = $bindable(false), columns, initialColumnId, onCreate }: KanbanAddTaskDialogProps = $props() const df = new DateFormatter('en-US', { dateStyle: 'medium' }) let columnId = $state(initialColumnId) let dueDate = $state<DateValue | undefined>(undefined) let form = $state({ title: '', description: '', priority: 'medium' as KanbanTask['priority'], assigneeKey: 'alice' as keyof typeof assignees, tagKeys: [] as (keyof typeof tagPresets)[], subtaskTexts: [] as string[], }) let newSubtaskText = $state('') function resetForm() { columnId = initialColumnId form = { title: '', description: '', priority: 'medium', assigneeKey: 'alice', tagKeys: [], subtaskTexts: [], } dueDate = undefined newSubtaskText = '' } // Reset the draft whenever the dialog opens (and track late column changes). $effect(() => { if (open) resetForm() }) $effect(() => { columnId = initialColumnId }) function addSubtask() { const text = newSubtaskText.trim() if (!text) return form.subtaskTexts.push(text) newSubtaskText = '' } function removeSubtask(index: number) { form.subtaskTexts.splice(index, 1) } function toggleTag(key: keyof typeof tagPresets) { const idx = form.tagKeys.indexOf(key) if (idx >= 0) form.tagKeys.splice(idx, 1) else form.tagKeys.push(key) } 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.toString() : undefined, subtaskIds: subtaskTasks.map((t) => t.id), commentItems: [], fileItems: [], } onCreate?.(columnId, [newTask, ...subtaskTasks]) open = false } </script> <Dialog bind:open> <DialogScrollContent data-slot="kanban-board" class="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 class="grid gap-4 py-2"> <div class="grid gap-1.5"> <Label for="task-title">Title</Label> <Input id="task-title" bind:value={form.title} placeholder="Enter task title" /> </div> <div class="grid gap-1.5"> <Label> Description <span class="text-muted-foreground text-xs">(optional)</span> </Label> <RichTextEditor bind:value={form.description} /> </div> <div class="grid grid-cols-3 gap-4"> <div class="grid gap-1.5"> <Label for="task-priority">Priority</Label> <Select value={form.priority} onValueChange={(v) => (form.priority = v as KanbanTask['priority'])}> <SelectTrigger id="task-priority"> <SelectValue placeholder="Priority" /> </SelectTrigger> <SelectContent> {#each Object.entries(priorityConfig) as [key, config] (key)} <SelectItem value={key}> {config.label} </SelectItem> {/each} </SelectContent> </Select> </div> <div class="grid gap-1.5"> <Label for="task-assignee">Assignee</Label> <Select value={form.assigneeKey} onValueChange={(v) => (form.assigneeKey = v as keyof typeof assignees)} > <SelectTrigger id="task-assignee"> <SelectValue placeholder="Assignee" /> </SelectTrigger> <SelectContent> {#each Object.entries(assignees) as [key, person] (key)} <SelectItem value={key}> {person.name} </SelectItem> {/each} </SelectContent> </Select> </div> <div class="grid gap-1.5"> <Label for="task-column">Column</Label> <Select bind:value={columnId}> <SelectTrigger id="task-column"> <SelectValue placeholder="Column" /> </SelectTrigger> <SelectContent> {#each columns as col (col.id)} <SelectItem value={col.id}> {col.title} </SelectItem> {/each} </SelectContent> </Select> </div> </div> <div class="grid gap-1.5"> <Label> Due Date <span class="text-muted-foreground text-xs">(optional)</span> </Label> <Popover> <PopoverTrigger> {#snippet child({ props })} <Button variant="outline" {...props} class={['w-full justify-start text-left font-normal', !dueDate && 'text-muted-foreground']} > <CalendarIcon class="mr-2 size-4" /> {dueDate ? df.format(dueDate.toDate(getLocalTimeZone())) : 'Pick a date'} </Button> {/snippet} </PopoverTrigger> <PopoverContent class="w-auto p-0"> <Calendar bind:value={dueDate} /> </PopoverContent> </Popover> </div> <div class="grid gap-1.5"> <Label> Tags <span class="text-muted-foreground text-xs">(optional)</span> </Label> <div class="flex flex-wrap gap-2"> {#each Object.entries(tagPresets) as [key, tag] (key)} {@const tagKey = key as keyof typeof tagPresets} {@const selected = form.tagKeys.includes(tagKey)} <button type="button" class={[ 'inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-colors', selected ? 'bg-primary text-primary-foreground border-transparent' : 'border-border bg-background text-foreground hover:bg-muted', ]} onclick={() => toggleTag(tagKey)} > {#if selected} <CheckCircle2 class="size-3" /> {/if} {tag.label} </button> {/each} </div> </div> <div class="grid gap-1.5"> <Label> Subtasks <span class="text-muted-foreground text-xs">(optional)</span> </Label> {#if form.subtaskTexts.length} <div class="space-y-2"> {#each form.subtaskTexts as text, index (index)} <div class="flex items-center gap-2"> <span class="flex-1 text-sm">{text}</span> <Button variant="ghost" size="icon" class="size-6" aria-label="Remove subtask" onclick={() => removeSubtask(index)} > <X class="size-3" /> </Button> </div> {/each} </div> {/if} <div class="flex items-center gap-2"> <Input bind:value={newSubtaskText} placeholder="Add a subtask" onkeydown={(e) => { if (e.key === 'Enter') addSubtask() }} /> <Button variant="outline" size="icon" aria-label="Add subtask" onclick={addSubtask}> <Plus class="size-4" /> </Button> </div> </div> </div> <DialogFooter> <Button variant="outline" onclick={() => (open = false)}>Cancel</Button> <Button disabled={!form.title.trim()} onclick={submit}>Create Task</Button> </DialogFooter> </DialogScrollContent> </Dialog>src/lib/components/blocks/kanban-task-board/KanbanToolbar.svelte5.1 kB
<script lang="ts"> import { Search, Filter, ChevronDown, X, LayoutGrid, List } from '@lucide/svelte' import { Input } from '$lib/components/ui/input' import { Button } from '$lib/components/ui/button' import { Badge } from '$lib/components/ui/badge' import { ToggleGroup, ToggleGroupItem } from '$lib/components/ui/toggle-group' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '$lib/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '$lib/components/ui/tooltip' import { Avatar, AvatarFallback } from '$lib/components/ui/avatar' import { priorityConfig, assignees, getInitials } from '$lib/composables/useKanban' interface KanbanToolbarProps { searchQuery?: string selectedPriority?: string | null selectedAssignee?: string | null viewMode?: 'board' | 'list' } let { searchQuery = $bindable(''), selectedPriority = $bindable(null), selectedAssignee = $bindable(null), viewMode = $bindable('board'), }: KanbanToolbarProps = $props() </script> <div data-slot="kanban-board" class="mb-3 flex shrink-0 items-center gap-2"> <div class="relative w-56"> <Search class="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" /> <Input bind:value={searchQuery} placeholder="Search tasks..." class="h-8 pl-8 text-sm" /> </div> <DropdownMenu> <DropdownMenuTrigger> {#snippet child({ props })} <Button variant="outline" size="sm" {...props} class="h-8 gap-1.5 text-xs"> <Filter class="size-3" /> Priority {#if selectedPriority} <Badge variant="default" class="ml-0.5 h-4 min-w-4 justify-center rounded px-1 text-xs">1</Badge> {/if} <ChevronDown class="text-muted-foreground size-3" /> </Button> {/snippet} </DropdownMenuTrigger> <DropdownMenuContent align="start" class="w-40"> {#each Object.entries(priorityConfig) as [key, config] (key)} {@const Icon = config.icon} <DropdownMenuItem class="gap-2" onclick={() => (selectedPriority = selectedPriority === key ? null : key)} > <Icon class={['size-3.5', config.class]} /> {config.label} {#if selectedPriority === key} <span class="bg-primary ml-auto size-1.5 rounded-full"></span> {/if} </DropdownMenuItem> {/each} {#if selectedPriority} <DropdownMenuSeparator /> <DropdownMenuItem onclick={() => (selectedPriority = null)}>Clear filter</DropdownMenuItem> {/if} </DropdownMenuContent> </DropdownMenu> {#if selectedAssignee} <Badge variant="secondary" class="h-7 cursor-pointer gap-1 pr-1.5 text-xs" onclick={() => (selectedAssignee = null)} > {selectedAssignee} <X class="size-3 opacity-50" /> </Badge> {/if} <ToggleGroup type="single" size="sm" animated={false} value={viewMode} onValueChange={(v) => { if (v) viewMode = v as 'board' | 'list' }} class="bg-muted ml-auto flex items-center gap-0.5 rounded-md p-0.5" > <ToggleGroupItem value="board" class="data-[state=on]:bg-background size-7 rounded-sm p-0 data-[state=on]:shadow-sm" title="Board view" > <LayoutGrid class="size-3.5" /> </ToggleGroupItem> <ToggleGroupItem value="list" class="data-[state=on]:bg-background size-7 rounded-sm p-0 data-[state=on]:shadow-sm" title="List view" > <List class="size-3.5" /> </ToggleGroupItem> </ToggleGroup> <div class="flex items-center"> <TooltipProvider delayDuration={300}> {#each Object.entries(assignees) as [key, a] (key)} <Tooltip> <TooltipTrigger> <button class={[ // 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={() => (selectedAssignee = selectedAssignee === a.name ? null : a.name)} > <Avatar class="border-background size-7 border-2"> <AvatarFallback class={['text-xs font-semibold', a.color]}> {getInitials(a.name)} </AvatarFallback> </Avatar> </button> </TooltipTrigger> <TooltipContent side="bottom" class="text-xs"> {a.name} {#if selectedAssignee === a.name} <span class="text-muted-foreground ml-1">(filtered)</span> {/if} </TooltipContent> </Tooltip> {/each} </TooltipProvider> </div> </div>src/lib/components/blocks/kanban-task-board/KanbanLink.svelte0.7 kB
<script lang="ts"> import type { Snippet } from 'svelte' import { getKanbanLinkContext } from './link-context' interface KanbanLinkProps { href: string /** Router-style destination (passed to custom link components; defaults to `href`). */ to?: string class?: string onclick?: (e: MouseEvent) => void children?: Snippet } let { href, to, class: className, onclick, children }: KanbanLinkProps = $props() const linkCtx = getKanbanLinkContext() </script> {#if typeof linkCtx.current === 'string'} <a {href} class={className} {onclick}>{@render children?.()}</a> {:else} {@const Link = linkCtx.current} <Link {href} to={to ?? href} class={className} {onclick}>{@render children?.()}</Link> {/if}src/lib/components/blocks/kanban-task-board/PriorityBadge.svelte0.6 kB
<script lang="ts"> import { priorityConfig } from '$lib/composables/useKanban' import { cn } from '$lib/utils' interface PriorityBadgeProps { priority: string iconSize?: string class?: string } let { priority, iconSize = 'size-3.5', class: className }: PriorityBadgeProps = $props() const config = $derived(priorityConfig[priority]) </script> {#if config} {@const Icon = config.icon} <div data-slot="kanban-board" class={cn('flex items-center gap-1', className)}> <Icon class={[iconSize, config.class]} /> <span class={['text-xs font-semibold', config.class]}>{config.label}</span> </div> {/if}src/lib/components/blocks/kanban-task-board/DueDateBadge.svelte1.2 kB
<script lang="ts"> import { Clock } from '@lucide/svelte' import { getDueStatus, formatDueDate } from '$lib/composables/useKanban' import { cn } from '$lib/utils' interface DueDateBadgeProps { dueDate: string variant?: 'chip' | 'inline' class?: string } let { dueDate, variant, class: className }: DueDateBadgeProps = $props() const status = $derived(getDueStatus(dueDate)) const formatted = $derived(formatDueDate(dueDate)) const chipClasses = $derived( status === 'overdue' ? 'bg-destructive/10 text-destructive' : status === 'soon' ? 'bg-warning/10 text-warning' : 'text-muted-foreground bg-muted', ) const inlineClasses = $derived( status === 'overdue' ? 'text-destructive' : status === 'soon' ? 'text-warning' : 'text-foreground', ) </script> {#if variant === 'chip'} <div data-slot="kanban-board" class={cn('flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-medium', chipClasses, className)} > <Clock class="size-3" /> {formatted} </div> {:else} <p data-slot="kanban-board" class={cn('flex items-center gap-1 text-sm leading-tight font-medium', inlineClasses, className)}> <Clock class="size-3" /> {formatted} </p> {/if}src/lib/components/blocks/kanban-task-board/TagBadge.svelte0.4 kB
<script lang="ts"> import { cn } from '$lib/utils' interface TagBadgeProps { label: string color: string class?: string } let { label, color, class: className }: TagBadgeProps = $props() </script> <span data-slot="kanban-board" class={cn('rounded-md px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset', color, className)} > {label} </span>src/lib/components/blocks/kanban-task-board/UserAvatar.svelte0.8 kB
<script lang="ts"> import { Avatar, AvatarFallback } from '$lib/components/ui/avatar' import { getInitials } from '$lib/composables/useKanban' import { cn } from '$lib/utils' interface UserAvatarProps { name: string color: string size?: 'xs' | 'sm' | 'md' class?: string } let { name, color, size = 'sm', class: className }: UserAvatarProps = $props() const sizeMap = { xs: { avatar: 'size-6', text: 'text-xs' }, sm: { avatar: 'size-7', text: 'text-xs' }, md: { avatar: 'size-8', text: 'text-xs' }, } </script> <Avatar data-slot="kanban-board" class={cn(sizeMap[size].avatar, 'shrink-0', className)}> <AvatarFallback class={[sizeMap[size].text, 'font-bold', color]}> {getInitials(name)} </AvatarFallback> </Avatar>src/lib/components/blocks/kanban-task-board/SubtaskProgress.svelte1.1 kB
<script lang="ts"> import { CheckCircle2 } from '@lucide/svelte' interface SubtaskProgressProps { done: number total: number barHeight?: string class?: string } let { done, total, barHeight = 'h-1.5', class: className }: SubtaskProgressProps = $props() const percent = $derived(total > 0 ? Math.round((done / total) * 100) : 0) const isComplete = $derived(done === total && total > 0) </script> {#if total > 0} <div data-slot="kanban-board" class={className}> <div class="mb-1 flex items-center justify-between"> <span class="text-muted-foreground text-xs"> {#if isComplete} <CheckCircle2 class="text-success mr-0.5 inline size-3" /> {/if} {done}/{total} subtasks </span> <span class="text-muted-foreground text-xs font-medium tabular-nums">{percent}%</span> </div> <div class={['bg-muted overflow-hidden rounded-full', barHeight]}> <div class={['h-full rounded-full transition-[width,background-color] duration-500', isComplete ? 'bg-success' : 'bg-primary']} style="width: {percent}%" ></div> </div> </div> {/if}src/lib/components/blocks/kanban-task-board/SubtaskList.svelte2.8 kB
<script lang="ts"> import type { KanbanColumn } from '$lib/composables/useKanban' import { findTaskById, getTaskColumn } from '$lib/composables/useKanban' import KanbanLink from './KanbanLink.svelte' interface SubtaskListProps { subtaskIds: string[] columns: KanbanColumn[] compact?: boolean } let { subtaskIds, columns, compact = false }: SubtaskListProps = $props() const subtasks = $derived( subtaskIds .map((id) => { const task = findTaskById(columns, id) const column = getTaskColumn(columns, id) return task ? { task, column } : null }) .filter((s): s is NonNullable<typeof s> => s !== null), ) const doneCount = $derived(subtasks.filter((s) => s.column?.id === 'done').length) const percent = $derived(subtasks.length > 0 ? Math.round((doneCount / subtasks.length) * 100) : 0) </script> {#if subtasks.length} <div data-slot="kanban-board"> <div class="mb-2 flex items-center justify-between"> <span class="text-muted-foreground text-xs tabular-nums">{doneCount}/{subtasks.length} done</span> <span class="text-muted-foreground text-xs font-medium tabular-nums">{percent}%</span> </div> <div class="bg-muted mb-3 h-1.5 overflow-hidden rounded-full"> <div class={[ 'h-full rounded-full transition-[width,background-color] duration-500', doneCount === subtasks.length ? 'bg-success' : 'bg-primary', ]} style="width: {percent}%" ></div> </div> <div class={compact ? 'space-y-0.5' : 'space-y-1'}> {#each subtasks as { task, column } (task.id)} <KanbanLink href="/dashboard/kanban/{task.id}" to="/dashboard/kanban/{task.id}" class={[ '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', ].join(' ')} > <span class={['size-1.5 shrink-0 rounded-full', column?.dotColor ?? 'bg-muted-foreground']}></span> <span class="text-muted-foreground/70 shrink-0 font-mono text-xs"> {task.id} </span> <span class={[ '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 class={['shrink-0 rounded-md px-1.5 py-0.5 text-xs font-medium', column?.color ?? 'text-muted-foreground']}> {column?.title ?? 'Unknown'} </span> </KanbanLink> {/each} </div> </div> {:else} <p class={compact ? 'text-muted-foreground text-xs' : 'text-muted-foreground text-sm'}>No subtasks yet.</p> {/if}src/lib/components/blocks/kanban-task-board/CommentList.svelte2.5 kB
<script lang="ts"> import { Send } from '@lucide/svelte' import { Button } from '$lib/components/ui/button' import { RichTextEditor } from '$lib/components/ui/rich-text-editor' import UserAvatar from './UserAvatar.svelte' import type { CommentItem } from '$lib/composables/useKanban' interface CommentListProps { comments: CommentItem[] compact?: boolean onAdd?: (text: string) => void } let { comments, compact = false, onAdd }: CommentListProps = $props() let newComment = $state('') const stripped = $derived(newComment.replace(/<[^>]*>/g, '').trim()) function submit() { if (!stripped) return onAdd?.(newComment) newComment = '' } </script> {#if comments.length} <div data-slot="kanban-board" class={compact ? 'space-y-3' : 'space-y-4'}> {#each comments as comment (comment.id)} <div class={compact ? 'flex gap-2.5' : 'flex gap-3'}> <UserAvatar name={comment.author} color={comment.authorColor} size={compact ? 'xs' : 'sm'} /> <div class="min-w-0 flex-1"> <div class="flex items-baseline gap-2"> <span class={compact ? 'text-xs font-semibold' : 'text-sm font-semibold'}> {compact ? comment.author.split(' ')[0] : comment.author} </span> <span class="text-muted-foreground text-xs"> {comment.time} </span> </div> <div class={[ 'rich-text-content prose prose-sm dark:prose-invert mt-0.5 max-w-none', compact ? 'text-muted-foreground text-xs leading-relaxed' : 'text-muted-foreground text-sm leading-relaxed', ]} > {@html comment.text} </div> </div> </div> {/each} </div> {:else} <p class={compact ? 'text-muted-foreground text-xs' : 'text-muted-foreground text-sm'}>No comments yet.</p> {/if} <div class={compact ? 'mt-3 flex gap-2' : 'mt-4 flex gap-3 border-t pt-4'}> <div class={compact ? 'mt-1' : 'mt-1.5'}> <UserAvatar name="Admin User" color="bg-chart-1/15 text-chart-1" size={compact ? 'xs' : 'sm'} /> </div> <div class="min-w-0 flex-1 space-y-2"> <RichTextEditor bind:value={newComment} placeholder="Write a comment..." minHeight={compact ? '60px' : '80px'} class={compact ? 'text-xs' : 'text-sm'} /> <div class="flex justify-end"> <Button size="sm" class={compact ? 'h-7 gap-1 text-xs' : 'h-8 gap-1.5 text-xs'} disabled={!stripped} onclick={submit}> <Send class="size-3" /> Comment </Button> </div> </div> </div>src/lib/components/blocks/kanban-task-board/link-context.ts0.8 kB
import { getContext, setContext } from 'svelte' import type { Component } from 'svelte' // Shared context for the kanban-board link component. // KanbanBoard provides it; KanbanCard, KanbanListView, KanbanTaskSheet, // and SubtaskList consume it (through KanbanLink) so task links render // with the consumer's router component or a plain <a> by default. // A getter keeps consumers reactive if the prop changes post-mount. export type KanbanLinkValue = string | Component<any> const KEY = Symbol('kanbanLink') export function setKanbanLinkContext(link: { readonly current: KanbanLinkValue }) { setContext(KEY, link) } export function getKanbanLinkContext(): { readonly current: KanbanLinkValue } { return getContext<{ readonly current: KanbanLinkValue }>(KEY) ?? { current: 'a' } }
Raw manifest:https://uipkge.dev/r/svelte/kanban-task-board.json