
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 Vue files (KanbanBoard + 6 kanban-* views + 7 small badges/lists). Bind with v-model:columns; 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 React ->$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/kanban-task-board.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/kanban-task-board.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/kanban-task-board.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/kanban-task-board.jsonnpx shadcn-vue@latest add @uipkge/kanban-task-boardInstalls to:app/components/blocks/kanban-task-board/| Name | Type / Values | Default | Required |
|---|---|---|---|
columns | KanbanColumnType[] | — | required |
title | string | null | 'Kanban Board' | optional |
description | string | null | 'Drag tasks across columns to update their status.' | optional |
defaultColumnId | string | 'backlog' | optional |
hideHeader | boolean | false | optional |
hideToolbar | boolean | false | optional |
lockParentScroll | boolean | true | optional |
linkComponent | string | Component | 'a' | 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[]
}FlatTaskinterface FlatTask {
task: KanbanTask
columnId: string
columnTitle: string
dotColor: string
}CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.
--border<script setup lang="ts">
import { computed, onMounted, onUnmounted, provide, ref } from 'vue'
import type { Component } from 'vue'
import { Plus } from 'lucide-vue-next'
import { PageHeader, PageHeaderHeading } from '@/components/ui/page'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import KanbanToolbar from './KanbanToolbar.vue'
import KanbanColumn from './KanbanColumn.vue'
import KanbanListView from './KanbanListView.vue'
import KanbanTaskSheet from './KanbanTaskSheet.vue'
import KanbanAddTaskDialog from './KanbanAddTaskDialog.vue'
import type { KanbanColumn as KanbanColumnType, KanbanTask } from '@/composables/useKanban'
import { kanbanLinkKey } from './link-injection'
interface Props {
columns: KanbanColumnType[]
title?: string | null
description?: string | null
defaultColumnId?: string
hideHeader?: boolean
hideToolbar?: boolean
lockParentScroll?: boolean
linkComponent?: string | Component
}
const props = withDefaults(defineProps<Props>(), {
title: 'Kanban Board',
description: 'Drag tasks across columns to update their status.',
defaultColumnId: 'backlog',
hideHeader: false,
hideToolbar: false,
lockParentScroll: true,
linkComponent: 'a',
})
// Provide the link component to all child views (KanbanCard, KanbanListView,
// KanbanTaskSheet, SubtaskList) so they render SPA-aware links without
// hard-coding NuxtLink. Defaults to a plain <a> for framework-agnostic use;
// pass NuxtLink or RouterLink for SPA routing.
provide(
kanbanLinkKey,
computed(() => props.linkComponent),
)
const emits = defineEmits<{
'update:columns': [value: KanbanColumnType[]]
}>()
const columnsRef = computed({
get: () => props.columns,
set: (v) => emits('update:columns', v),
})
const kanbanEl = ref<HTMLElement | null>(null)
let parentMain: HTMLElement | null = null
onMounted(() => {
if (!props.lockParentScroll) return
parentMain = kanbanEl.value?.closest('main[data-slot="sidebar-inset"]') as HTMLElement | null
if (parentMain) parentMain.style.overflow = 'hidden'
document.documentElement.style.overflow = 'hidden'
})
onUnmounted(() => {
if (parentMain) {
parentMain.style.overflow = ''
parentMain = null
}
if (props.lockParentScroll) document.documentElement.style.overflow = ''
})
const searchQuery = ref('')
const selectedPriority = ref<string | null>(null)
const selectedAssignee = ref<string | null>(null)
const viewMode = ref<'board' | 'list'>('board')
const filteredColumns = computed(() => {
return columnsRef.value.map((col) => ({
...col,
tasks: col.tasks.filter((task) => {
const q = searchQuery.value.toLowerCase()
const matchesSearch = !q || task.title.toLowerCase().includes(q) || task.id.toLowerCase().includes(q)
const matchesPriority = !selectedPriority.value || task.priority === selectedPriority.value
const matchesAssignee = !selectedAssignee.value || task.assignee.name === selectedAssignee.value
return matchesSearch && matchesPriority && matchesAssignee
}),
}))
})
const totalTasks = computed(() => columnsRef.value.reduce((sum, col) => sum + col.tasks.length, 0))
const collapsedColumns = ref<Set<string>>(new Set())
function toggleCollapse(columnId: string) {
const next = new Set(collapsedColumns.value)
if (next.has(columnId)) next.delete(columnId)
else next.add(columnId)
collapsedColumns.value = 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',
})
}
function moveTask(task: KanbanTask, targetColumnId: string) {
const sourceCol = columnsRef.value.find((c) => c.tasks.some((t) => t.id === task.id))
const targetCol = columnsRef.value.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])
}
const detailOpen = ref(false)
const detailTask = ref<KanbanTask | null>(null)
function openTaskDetail(task: KanbanTask) {
detailTask.value = task
detailOpen.value = true
}
const draggedTask = ref<string | null>(null)
const dragOverColumn = ref<string | null>(null)
const dropTargetIndex = ref<number>(-1)
let lastDragEnd = 0
function onDragStart(event: DragEvent, taskId: string) {
draggedTask.value = taskId
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', taskId)
}
}
function resetDrag() {
draggedTask.value = null
dragOverColumn.value = null
dropTargetIndex.value = -1
lastDragEnd = Date.now()
}
function onDrop() {
if (!draggedTask.value || !dragOverColumn.value) {
resetDrag()
return
}
let sourceColIdx = -1
let taskIdx = -1
for (let c = 0; c < columnsRef.value.length; c++) {
const col = columnsRef.value[c]
if (!col) continue
const tIdx = col.tasks.findIndex((t) => t.id === draggedTask.value)
if (tIdx !== -1) {
sourceColIdx = c
taskIdx = tIdx
break
}
}
const targetColIdx = columnsRef.value.findIndex((c) => c.id === dragOverColumn.value)
if (sourceColIdx === -1 || targetColIdx === -1) {
resetDrag()
return
}
const sourceCol = columnsRef.value[sourceColIdx]
const targetCol = columnsRef.value[targetColIdx]
if (!sourceCol || !targetCol) {
resetDrag()
return
}
const [task] = sourceCol.tasks.splice(taskIdx, 1)
if (!task) {
resetDrag()
return
}
let insertAt = dropTargetIndex.value
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() - lastDragEnd < 200) return
openTaskDetail(task)
}
function onCardDragOver(event: DragEvent, columnId: string, taskIndex: number) {
dragOverColumn.value = columnId
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const midY = rect.top + rect.height / 2
dropTargetIndex.value = event.clientY < midY ? taskIndex : taskIndex + 1
}
function onLaneDragOver(_event: DragEvent, columnId: string, taskCount: number) {
dragOverColumn.value = columnId
dropTargetIndex.value = taskCount
}
const addTaskOpen = ref(false)
const addTaskColumnId = ref(props.defaultColumnId)
function openAddTask(columnId: string) {
addTaskColumnId.value = columnId
addTaskOpen.value = true
}
function onCreateTask(columnId: string, tasks: KanbanTask[]) {
const col = columnsRef.value.find((c) => c.id === columnId)
if (col) col.tasks.push(...tasks)
}
</script>
<template>
<div
ref="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)]"
>
<div v-if="!hideHeader" class="mb-3 shrink-0">
<PageHeader>
<div class="flex items-start justify-between gap-4">
<PageHeaderHeading :title="title ?? ''" :description="description ?? ''" />
<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" @click="openAddTask(defaultColumnId)">
<Plus class="size-4" />
Add Task
</Button>
</div>
</div>
</PageHeader>
</div>
<KanbanToolbar
v-if="!hideToolbar"
v-model:search-query="searchQuery"
v-model:selected-priority="selectedPriority"
v-model:selected-assignee="selectedAssignee"
v-model:view-mode="viewMode"
/>
<div
v-if="viewMode === 'board'"
class="kanban-board relative flex min-h-0 flex-1 items-start gap-3 overflow-auto pb-3"
>
<KanbanColumn
v-for="column in filteredColumns"
:key="column.id"
:column="column"
:columns="columnsRef"
:collapsed="collapsedColumns.has(column.id)"
:dragged-task="draggedTask"
:drag-over-column="dragOverColumn"
:drop-target-index="dropTargetIndex"
@toggle-collapse="toggleCollapse"
@add-task="openAddTask"
@card-click="onCardClick"
@card-quick-view="openTaskDetail"
@drag-start="onDragStart"
@drag-end="resetDrag"
@card-drag-over="onCardDragOver"
@lane-drag-over="onLaneDragOver"
@drop="onDrop"
/>
</div>
<KanbanListView
v-else
:columns="filteredColumns"
:all-columns="columnsRef"
@task-click="openTaskDetail"
@move-task="moveTask"
/>
<KanbanTaskSheet
v-model:open="detailOpen"
:task="detailTask"
:columns="columnsRef"
@move-task="moveTask"
@add-comment="addComment"
/>
<KanbanAddTaskDialog
v-model:open="addTaskOpen"
:columns="columnsRef"
:initial-column-id="addTaskColumnId"
@create="onCreateTask"
/>
</div>
</template>
<style scoped>
.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>
<script setup lang="ts">
import { ref } from 'vue'
import { Plus, Calendar, GripVertical, Check } from 'lucide-vue-next'
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[]
}
interface Props {
columns: SimpleKanbanColumn[]
allowAdd?: boolean
className?: string
cardClassName?: string
}
const props = withDefaults(defineProps<Props>(), {
allowAdd: true,
})
const emits = defineEmits<{
'update:columns': [value: SimpleKanbanColumn[]]
'card-click': [item: SimpleKanbanItem, columnId: string]
'card-move': [item: SimpleKanbanItem, fromColumnId: string, toColumnId: string, newIndex: number]
'add-item': [columnId: string, title: 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' },
}
const draggedId = ref<string | null>(null)
const dragOverColumnId = ref<string | null>(null)
const dropTargetIndex = ref<number>(-1)
const addingColumnId = ref<string | null>(null)
const newTitle = ref('')
let lastDragTime = 0
function handleDragStart(e: DragEvent, id: string) {
draggedId.value = id
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', id)
}
}
function handleDragEnd() {
draggedId.value = null
dragOverColumnId.value = null
dropTargetIndex.value = -1
lastDragTime = Date.now()
}
function handleDragOverColumn(e: DragEvent, columnId: string, itemsCount: number) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
dragOverColumnId.value = columnId
dropTargetIndex.value = itemsCount
}
function handleCardDragOver(e: DragEvent, columnId: string, index: number) {
e.preventDefault()
e.stopPropagation()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
dragOverColumnId.value = columnId
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const midY = rect.top + rect.height / 2
dropTargetIndex.value = e.clientY < midY ? index : index + 1
}
function handleDrop(e: DragEvent, targetColumnId: string) {
e.preventDefault()
if (!draggedId.value || !targetColumnId) {
handleDragEnd()
return
}
const next = props.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.value)
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.value
if (at < 0) at = targetCol.items.length
if (sourceColIdx === targetColIdx && itemIdx < at) at--
targetCol.items.splice(at, 0, removed)
emits('update:columns', next)
emits('card-move', removed, sourceCol.id, targetColumnId, at)
handleDragEnd()
}
function handleInlineAdd(columnId: string) {
if (!newTitle.value.trim()) {
addingColumnId.value = null
return
}
const text = newTitle.value.trim()
emits('add-item', columnId, text)
const next = props.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,
})
emits('update:columns', next)
}
newTitle.value = ''
addingColumnId.value = null
}
function onCardClick(item: SimpleKanbanItem, columnId: string) {
if (Date.now() - lastDragTime < 150) return
emits('card-click', item, columnId)
}
</script>
<template>
<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', props.className)
"
>
<div
v-for="column in columns"
:key="column.id"
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',
)
"
@dragover="(e) => handleDragOverColumn(e, column.id, column.items.length)"
@drop="(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',
)
"
/>
<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>
<Button
v-if="allowAdd"
variant="ghost"
size="icon"
class="text-muted-foreground hover:text-foreground size-7 rounded-lg"
:aria-label="`Add item to ${column.title}`"
@click="
() => {
addingColumnId = column.id
newTitle = ''
}
"
>
<Plus class="size-3.5" />
</Button>
</div>
<!-- Column Cards Container -->
<div class="flex min-h-[120px] flex-1 flex-col gap-2.5">
<!-- Inline quick add input -->
<div
v-if="addingColumnId === column.id"
class="bg-card border-border animate-in fade-in zoom-in-95 rounded-lg border p-2.5 shadow-xs duration-150"
>
<input
v-model="newTitle"
type="text"
autofocus
placeholder="Item title..."
class="placeholder:text-muted-foreground/60 w-full bg-transparent text-sm font-medium outline-none"
@keydown.enter="handleInlineAdd(column.id)"
@keydown.esc="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" @click="addingColumnId = null"> Cancel </Button>
<Button size="sm" class="h-7 gap-1 px-2.5 text-xs" @click="handleInlineAdd(column.id)">
<Check class="size-3" />
Add
</Button>
</div>
</div>
<!-- Cards list -->
<template v-for="(item, index) in column.items" :key="item.id">
<div
v-if="dragOverColumnId === column.id && dropTargetIndex === index && draggedId !== item.id"
class="bg-primary/20 h-1.5 w-full rounded-full"
/>
<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',
props.cardClassName,
)
"
@dragstart="(e) => handleDragStart(e, item.id)"
@dragend="handleDragEnd"
@dragover="(e) => handleCardDragOver(e, column.id, index)"
@click="onCardClick(item, column.id)"
@keydown.enter.prevent="onCardClick(item, column.id)"
@keydown.space.prevent="onCardClick(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 -->
<p v-if="item.description" class="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{{ item.description }}
</p>
<!-- Metadata row: Date, Tag, Priority, Assignee -->
<div
v-if="item.date || item.tag || item.priority || item.assignee"
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">
<div v-if="item.date" class="text-muted-foreground flex items-center gap-1 text-xs">
<Calendar class="text-muted-foreground/70 size-3" />
<span>{{ item.date }}</span>
</div>
<span
v-if="item.tag"
:class="
cn(
'rounded-md px-1.5 py-0.5 text-xs font-medium',
item.tagColor || 'bg-secondary text-secondary-foreground',
)
"
>
{{ item.tag }}
</span>
<span
v-if="item.priority && PRIORITY_STYLES[item.priority]"
:class="cn('rounded-md px-1.5 py-0.5 text-xs', PRIORITY_STYLES[item.priority].class)"
>
{{ PRIORITY_STYLES[item.priority].label }}
</span>
</div>
<Avatar v-if="item.assignee" 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>
</div>
</div>
</template>
<!-- Trailing drop indicator -->
<div
v-if="dragOverColumnId === column.id && dropTargetIndex >= column.items.length && draggedId"
class="bg-primary/20 h-1.5 w-full rounded-full"
/>
<!-- Empty column placeholder -->
<div
v-if="column.items.length === 0 && addingColumnId !== column.id"
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>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import { type KanbanTask, type KanbanColumn, priorityConfig, getTaskColumn } from '@/composables/useKanban'
import TagBadge from './TagBadge.vue'
import SubtaskProgress from './SubtaskProgress.vue'
import DueDateBadge from './DueDateBadge.vue'
import UserAvatar from './UserAvatar.vue'
import { kanbanLinkKey } from './link-injection'
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 { MoreHorizontal, MessageSquare, Paperclip, ExternalLink } from 'lucide-vue-next'
const linkComponent = inject(
kanbanLinkKey,
computed(() => 'a' as const),
)
const props = defineProps<{
task: KanbanTask
isDone: boolean
columns: KanbanColumn[]
}>()
const subtasksDone = computed(() => {
if (!props.columns || !props.task.subtaskIds.length) return 0
return props.task.subtaskIds.filter((id) => getTaskColumn(props.columns, id)?.id === 'done').length
})
defineEmits<{
click: [task: KanbanTask]
'quick-view': [task: KanbanTask]
}>()
</script>
<template>
<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',
isDone ? 'opacity-75 hover:opacity-100' : '',
]"
@click="$emit('click', task)"
>
<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 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 as-child>
<Button
aria-label="Card options"
variant="ghost"
size="icon"
class="text-muted-foreground -mr-1 size-6 opacity-0 group-hover/card:opacity-100"
@click.stop
>
<MoreHorizontal class="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-36">
<DropdownMenuItem @click.stop="$emit('quick-view', task)">Quick view</DropdownMenuItem>
<DropdownMenuItem as-child>
<component
:is="linkComponent"
:to="`/dashboard/kanban/${task.id}`"
:href="`/dashboard/kanban/${task.id}`"
class="gap-2"
>
<ExternalLink class="size-3.5" />
Open detail
</component>
</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>
<div v-if="task.tags.length" class="mb-2 flex flex-wrap gap-1 pl-2">
<TagBadge v-for="tag in task.tags" :key="tag.label" :label="tag.label" :color="tag.color" />
</div>
<div v-if="task.subtaskIds.length" class="mb-2 pl-2">
<SubtaskProgress :done="subtasksDone" :total="task.subtaskIds.length" />
</div>
<div class="flex items-center gap-2 pl-2">
<DueDateBadge v-if="task.dueDate" :due-date="task.dueDate" variant="chip" />
<div v-if="task.commentItems.length" class="text-muted-foreground/70 flex items-center gap-1 text-xs">
<MessageSquare class="size-3" />
{{ task.commentItems.length }}
</div>
<div v-if="task.fileItems.length" class="text-muted-foreground/70 flex items-center gap-1 text-xs">
<Paperclip class="size-3" />
{{ task.fileItems.length }}
</div>
<div class="ml-auto">
<TooltipProvider :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<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>
</template>
<style scoped>
.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>
<script setup lang="ts">
import { Plus, MoreHorizontal, ChevronsLeft, ChevronsRight } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import KanbanCard from './KanbanCard.vue'
import type { KanbanColumn as KanbanColumnType, KanbanTask } from '@/composables/useKanban'
defineProps<{
column: { id: string; title: string; color: string; dotColor: string; tasks: KanbanTask[] }
columns: KanbanColumnType[]
collapsed: boolean
draggedTask: string | null
dragOverColumn: string | null
dropTargetIndex: number
}>()
defineEmits<{
'toggle-collapse': [columnId: string]
'add-task': [columnId: string]
'card-click': [task: KanbanTask]
'card-quick-view': [task: KanbanTask]
'drag-start': [event: DragEvent, taskId: string]
'drag-end': []
'card-drag-over': [event: DragEvent, columnId: string, taskIndex: number]
'lane-drag-over': [event: DragEvent, columnId: string, taskCount: number]
drop: []
}>()
</script>
<template>
<div
data-slot="kanban-board"
:class="['group/col flex shrink-0 flex-col transition-[width] duration-200', collapsed ? 'w-12' : 'w-80']"
@dragover.prevent
@drop="$emit('drop')"
>
<button
v-if="collapsed"
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"
@click="$emit('toggle-collapse', column.id)"
>
<span :class="['size-2 shrink-0 rounded-full', column.dotColor]" />
<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>
<template v-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"
@click="$emit('toggle-collapse', column.id)"
>
<ChevronsLeft class="size-3.5" />
</button>
<span :class="['size-2 shrink-0 rounded-full', column.dotColor]" />
<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"
@click="$emit('add-task', 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',
]"
@dragover.prevent="$emit('lane-drag-over', $event, column.id, column.tasks.length)"
>
<template v-for="(task, taskIndex) in column.tasks" :key="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
:data-task-id="task.id"
draggable="true"
:class="['mt-2 first:mt-0', draggedTask === task.id ? 'scale-95 rotate-1 opacity-30' : 'opacity-100']"
@dragstart="$emit('drag-start', $event, task.id)"
@dragend="$emit('drag-end')"
@dragover.stop.prevent="$emit('card-drag-over', $event, column.id, taskIndex)"
>
<KanbanCard
:task="task"
:columns="columns"
:is-done="column.id === 'done'"
@click="$emit('card-click', $event)"
@quick-view="$emit('card-quick-view', $event)"
/>
</div>
</template>
<div
v-if="column.tasks.length > 0"
: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',
]"
/>
<button
aria-label="Add task"
v-if="column.tasks.length === 0"
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"
@click="$emit('add-task', column.id)"
>
<Plus class="mb-1 size-4" />
<p class="text-xs">No tasks</p>
</button>
</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"
@click="$emit('add-task', column.id)"
>
<Plus class="size-3.5" />
Add task
</button>
</template>
</div>
</template>
<style scoped>
.kanban-lane {
min-height: 60px;
}
</style>
<script setup lang="ts">
import { ref, computed, inject } from 'vue'
import type { KanbanTask, KanbanColumn } from '@/composables/useKanban'
import { getTaskColumn } from '@/composables/useKanban'
import PriorityBadge from './PriorityBadge.vue'
import TagBadge from './TagBadge.vue'
import DueDateBadge from './DueDateBadge.vue'
import UserAvatar from './UserAvatar.vue'
import SubtaskProgress from './SubtaskProgress.vue'
import { kanbanLinkKey } from './link-injection'
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 { MessageSquare, Paperclip, ExternalLink, ArrowUpDown, ChevronDown, ChevronRight } from 'lucide-vue-next'
const linkComponent = inject(
kanbanLinkKey,
computed(() => 'a' as const),
)
const props = defineProps<{
columns: KanbanColumn[]
allColumns: KanbanColumn[]
}>()
defineEmits<{
'task-click': [task: KanbanTask]
'move-task': [task: KanbanTask, targetColumnId: string]
}>()
type SortField = 'id' | 'title' | 'priority' | 'assignee' | 'dueDate' | 'status'
type SortDir = 'asc' | 'desc'
const sortField = ref<SortField>('status')
const sortDir = ref<SortDir>('asc')
const priorityOrder: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3 }
function toggleSort(field: SortField) {
if (sortField.value === field) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDir.value = 'asc'
}
}
const groupByStatus = ref(true)
const collapsedGroups = ref<Set<string>>(new Set())
function toggleGroup(columnId: string) {
const next = new Set(collapsedGroups.value)
if (next.has(columnId)) next.delete(columnId)
else next.add(columnId)
collapsedGroups.value = 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.value) {
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 = props.allColumns.map((c) => c.id)
cmp = colOrder.indexOf(a.columnId) - colOrder.indexOf(b.columnId)
break
}
}
return sortDir.value === 'desc' ? -cmp : cmp
})
}
const flatTasks = computed<FlatTask[]>(() => {
const items: FlatTask[] = []
for (const col of props.columns) {
for (const task of col.tasks) {
items.push({
task,
columnId: col.id,
columnTitle: col.title,
dotColor: col.dotColor,
})
}
}
return sortTasks(items)
})
const groupedTasks = computed(() => {
if (!groupByStatus.value) return null
const groups: { column: KanbanColumn; tasks: FlatTask[] }[] = []
for (const col of props.allColumns) {
const tasks = flatTasks.value.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(props.allColumns, id)?.id === 'done').length
}
</script>
<template>
<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" @click="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" @click="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" @click="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" @click="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" @click="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" @click="toggleSort('dueDate')">
Due
<ArrowUpDown :class="['size-3', sortField === 'dueDate' ? 'text-foreground' : 'text-muted-foreground/50']" />
</button>
<span class="text-center">Info</span>
</div>
<template v-if="groupedTasks">
<template v-for="group in groupedTasks" :key="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"
@click="toggleGroup(group.column.id)"
>
<component
:is="collapsedGroups.has(group.column.id) ? ChevronRight : ChevronDown"
class="text-muted-foreground size-3.5"
/>
<span :class="['size-2 rounded-full', group.column.dotColor]" />
<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>
<template v-if="!collapsedGroups.has(group.column.id)">
<div
v-for="item in group.tasks"
:key="item.task.id"
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"
@click="$emit('task-click', item.task)"
>
<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>
<component
:is="linkComponent"
:to="`/dashboard/kanban/${item.task.id}`"
:href="`/dashboard/kanban/${item.task.id}`"
class="text-muted-foreground hover:text-foreground shrink-0 opacity-0 transition-opacity group-hover/row:opacity-100"
@click.stop
>
<ExternalLink class="size-3" />
</component>
</div>
<div v-if="item.task.tags.length || item.task.subtaskIds.length" class="mt-0.5 flex items-center gap-1.5">
<TagBadge
v-for="tag in item.task.tags"
:key="tag.label"
:label="tag.label"
:color="tag.color"
class="!px-1.5 !py-0 !text-xs"
/>
<SubtaskProgress
v-if="item.task.subtaskIds.length"
:done="subtasksDone(item.task)"
:total="item.task.subtaskIds.length"
class="ml-1"
/>
</div>
</div>
<div>
<Select
:model-value="item.columnId"
@update:model-value="(val) => $emit('move-task', 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"
@click.stop
>
<span class="flex items-center gap-1.5">
<span :class="['size-1.5 rounded-full', item.dotColor]" />
<SelectValue />
</span>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="col in allColumns" :key="col.id" :value="col.id">
<span class="flex items-center gap-1.5">
<span :class="['size-1.5 rounded-full', col.dotColor]" />
{{ col.title }}
</span>
</SelectItem>
</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>
<DueDateBadge v-if="item.task.dueDate" :due-date="item.task.dueDate" variant="chip" />
<span v-else class="text-muted-foreground/50 text-xs">—</span>
</div>
<div class="flex items-center justify-center gap-2">
<TooltipProvider :delay-duration="200">
<Tooltip v-if="item.task.commentItems.length">
<TooltipTrigger as-child>
<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>
<Tooltip v-if="item.task.fileItems.length">
<TooltipTrigger as-child>
<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>
</TooltipProvider>
</div>
</div>
</template>
</template>
</template>
<div
v-if="flatTasks.length === 0"
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>
</div>
</template>
<style scoped>
.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>
<script setup lang="ts">
import { computed, inject } from 'vue'
import { type KanbanTask, type KanbanColumn, priorityConfig, fileIconMap } from '@/composables/useKanban'
import PriorityBadge from './PriorityBadge.vue'
import TagBadge from './TagBadge.vue'
import DueDateBadge from './DueDateBadge.vue'
import UserAvatar from './UserAvatar.vue'
import CommentList from './CommentList.vue'
import SubtaskList from './SubtaskList.vue'
import { kanbanLinkKey } from './link-injection'
import { Sheet, SheetContent, SheetTitle, SheetDescription, SheetFooter, SheetClose } from '@/components/ui/sheet'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Clock, Download, ExternalLink } from 'lucide-vue-next'
const linkComponent = inject(
kanbanLinkKey,
computed(() => 'a' as const),
)
const props = defineProps<{
open: boolean
task: KanbanTask | null
columns: KanbanColumn[]
}>()
defineEmits<{
'update:open': [value: boolean]
'move-task': [task: KanbanTask, columnId: string]
'add-comment': [task: KanbanTask, text: string]
}>()
const columnIdForTask = computed(() => {
if (!props.task) return ''
return props.columns.find((c) => c.tasks.some((t) => t.id === props.task!.id))?.id ?? ''
})
</script>
<template>
<Sheet data-slot="kanban-board" :open="open" @update:open="$emit('update:open', $event)">
<SheetContent class="flex max-w-[420px] flex-col gap-0 overflow-hidden p-0 sm:max-w-full">
<template v-if="task">
<div :class="['h-1 w-full shrink-0', priorityConfig[task.priority]?.bg]" />
<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
:model-value="columnIdForTask"
@update:model-value="(val) => task && $emit('move-task', task, String(val))"
>
<SelectTrigger
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>
<SelectItem v-for="col in columns" :key="col.id" :value="col.id">
<span class="flex items-center gap-1.5">
<span :class="['size-1.5 rounded-full', col.dotColor]" />
{{ col.title }}
</span>
</SelectItem>
</SelectContent>
</Select>
<PriorityBadge :priority="task.priority" icon-size="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>
<div
v-if="task.description"
class="text-muted-foreground rich-text-content prose prose-sm dark:prose-invert mt-1.5 max-w-none text-sm leading-relaxed"
v-html="task.description"
/>
<p v-else class="text-muted-foreground mt-1.5 text-sm leading-relaxed">No description provided.</p>
<div class="mt-3 flex flex-wrap gap-1.5">
<template v-if="task.tags.length">
<TagBadge v-for="tag in task.tags" :key="tag.label" :label="tag.label" :color="tag.color" />
</template>
<span v-else class="text-muted-foreground text-xs">No tags</span>
</div>
</div>
<div class="bg-border mx-5 h-px" />
<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">
<DueDateBadge v-if="task.dueDate" :due-date="task.dueDate" />
<p v-else class="text-muted-foreground flex items-center gap-1 text-sm leading-tight">
<Clock class="size-3" />
No due date
</p>
</div>
</div>
<div v-if="task.parentId" class="flex items-center gap-2">
<span class="text-muted-foreground text-xs">Parent:</span>
<component
:is="linkComponent"
:to="`/dashboard/kanban/${task.parentId}`"
:href="`/dashboard/kanban/${task.parentId}`"
class="text-primary text-xs font-medium hover:underline"
@click="$emit('update:open', false)"
>
{{ task.parentId }}
</component>
</div>
<div>
<h4 class="mb-2 text-sm font-semibold">
Subtasks
<span v-if="task.subtaskIds.length" class="text-muted-foreground font-normal">
({{ task.subtaskIds.length }})
</span>
</h4>
<SubtaskList :subtask-ids="task.subtaskIds" :columns="columns" compact />
</div>
<div class="bg-border h-px" />
<div>
<h4 class="mb-2 text-sm font-semibold">
Comments
<span v-if="task.commentItems.length" class="text-muted-foreground font-normal">
({{ task.commentItems.length }})
</span>
</h4>
<CommentList
:comments="task.commentItems"
compact
@add="(text) => task && $emit('add-comment', task, text)"
/>
</div>
<div class="bg-border h-px" />
<div>
<h4 class="mb-2 text-sm font-semibold">
Files
<span v-if="task.fileItems.length" class="text-muted-foreground font-normal">
({{ task.fileItems.length }})
</span>
</h4>
<div v-if="task.fileItems.length" class="space-y-1">
<div
v-for="file in task.fileItems"
:key="file.id"
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">
<component :is="fileIconMap[file.type]" class="text-muted-foreground size-4" />
</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>
</div>
<p v-else class="text-muted-foreground text-xs">No files attached.</p>
</div>
</div>
</div>
<SheetFooter class="shrink-0 border-t px-5 py-3">
<div class="flex w-full items-center gap-2">
<component
:is="linkComponent"
:to="`/dashboard/kanban/${task.id}`"
:href="`/dashboard/kanban/${task.id}`"
class="flex-1"
@click="$emit('update:open', false)"
>
<Button variant="outline" size="sm" class="w-full gap-1.5">
<ExternalLink class="size-3.5" />
View full detail
</Button>
</component>
<SheetClose as-child>
<Button variant="ghost" size="sm">Close</Button>
</SheetClose>
</div>
</SheetFooter>
</template>
</SheetContent>
</Sheet>
</template>
<script setup lang="ts">
import { ref, shallowRef, watch } from 'vue'
import type { KanbanTask, KanbanColumn } from '@/composables/useKanban'
import { priorityConfig, assignees, tagPresets } from '@/composables/useKanban'
import {
Dialog,
DialogScrollContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Calendar } from '@/components/ui/calendar'
import { RichTextEditor } from '@/components/ui/rich-text-editor'
import { Plus, X, CheckCircle2, CalendarIcon } from 'lucide-vue-next'
import { getLocalTimeZone, DateFormatter } from '@internationalized/date'
import type { DateValue } from 'reka-ui'
const props = defineProps<{
open: boolean
columns: KanbanColumn[]
initialColumnId: string
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
create: [columnId: string, tasks: KanbanTask[]]
}>()
const df = new DateFormatter('en-US', { dateStyle: 'medium' })
const columnId = ref(props.initialColumnId)
const dueDate = shallowRef<DateValue | undefined>(undefined)
const form = ref({
title: '',
description: '',
priority: 'medium' as KanbanTask['priority'],
assigneeKey: 'alice' as keyof typeof assignees,
tagKeys: [] as (keyof typeof tagPresets)[],
subtaskTexts: [] as string[],
})
const newSubtaskText = ref('')
watch(
() => props.initialColumnId,
(val) => {
columnId.value = val
},
)
watch(
() => props.open,
(val) => {
if (val) {
columnId.value = props.initialColumnId
form.value = {
title: '',
description: '',
priority: 'medium',
assigneeKey: 'alice',
tagKeys: [],
subtaskTexts: [],
}
dueDate.value = undefined
newSubtaskText.value = ''
}
},
)
function addSubtask() {
const text = newSubtaskText.value.trim()
if (!text) return
form.value.subtaskTexts.push(text)
newSubtaskText.value = ''
}
function removeSubtask(index: number) {
form.value.subtaskTexts.splice(index, 1)
}
function toggleTag(key: keyof typeof tagPresets) {
const idx = form.value.tagKeys.indexOf(key)
if (idx >= 0) form.value.tagKeys.splice(idx, 1)
else form.value.tagKeys.push(key)
}
function submit() {
if (!form.value.title.trim()) return
const maxId = props.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 = props.columns.flatMap((c) => c.tasks)[0]?.id.split('-')[0] ?? 'TASK'
const parentId = `${idPrefix}-${maxId + 1}`
const subtaskTasks: KanbanTask[] = form.value.subtaskTexts.map((text, i) => ({
id: `${idPrefix}-${maxId + 2 + i}`,
title: text,
priority: 'medium' as const,
assignee: assignees[form.value.assigneeKey],
tags: [],
parentId,
subtaskIds: [],
commentItems: [],
fileItems: [],
}))
const newTask: KanbanTask = {
id: parentId,
title: form.value.title.trim(),
description: form.value.description.trim() || undefined,
priority: form.value.priority,
assignee: assignees[form.value.assigneeKey],
tags: form.value.tagKeys.map((k) => tagPresets[k]),
dueDate: dueDate.value ? dueDate.value.toString() : undefined,
subtaskIds: subtaskTasks.map((t) => t.id),
commentItems: [],
fileItems: [],
}
emit('create', columnId.value, [newTask, ...subtaskTasks])
emit('update:open', false)
}
</script>
<template>
<Dialog data-slot="kanban-board" :open="open" @update:open="$emit('update:open', $event)">
<DialogScrollContent 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" v-model="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 v-model="form.description" />
</div>
<div class="grid grid-cols-3 gap-4">
<div class="grid gap-1.5">
<Label for="task-priority">Priority</Label>
<Select v-model="form.priority">
<SelectTrigger id="task-priority">
<SelectValue placeholder="Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="(config, key) in priorityConfig" :key="key" :value="key">
{{ config.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid gap-1.5">
<Label for="task-assignee">Assignee</Label>
<Select v-model="form.assigneeKey">
<SelectTrigger id="task-assignee">
<SelectValue placeholder="Assignee" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="(person, key) in assignees" :key="key" :value="key">
{{ person.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid gap-1.5">
<Label for="task-column">Column</Label>
<Select v-model="columnId">
<SelectTrigger id="task-column">
<SelectValue placeholder="Column" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="col in columns" :key="col.id" :value="col.id">
{{ col.title }}
</SelectItem>
</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 as-child>
<Button
variant="outline"
: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>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<Calendar v-model="dueDate" initial-focus />
</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">
<button
v-for="(tag, key) in tagPresets"
:key="key"
type="button"
:class="[
'inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-colors',
form.tagKeys.includes(key as keyof typeof tagPresets)
? 'bg-primary text-primary-foreground border-transparent'
: 'border-border bg-background text-foreground hover:bg-muted',
]"
@click="toggleTag(key as keyof typeof tagPresets)"
>
<CheckCircle2 v-if="form.tagKeys.includes(key as keyof typeof tagPresets)" class="size-3" />
{{ tag.label }}
</button>
</div>
</div>
<div class="grid gap-1.5">
<Label>
Subtasks
<span class="text-muted-foreground text-xs">(optional)</span>
</Label>
<div v-if="form.subtaskTexts.length" class="space-y-2">
<div v-for="(text, index) in form.subtaskTexts" :key="index" 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"
@click="removeSubtask(index)"
>
<X class="size-3" />
</Button>
</div>
</div>
<div class="flex items-center gap-2">
<Input v-model="newSubtaskText" placeholder="Add a subtask" @keyup.enter="addSubtask" />
<Button variant="outline" size="icon" aria-label="Add subtask" @click="addSubtask">
<Plus class="size-4" />
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="$emit('update:open', false)">Cancel</Button>
<Button :disabled="!form.title.trim()" @click="submit">Create Task</Button>
</DialogFooter>
</DialogScrollContent>
</Dialog>
</template>
<script setup lang="ts">
import { Search, Filter, ChevronDown, X, LayoutGrid, List } from 'lucide-vue-next'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { priorityConfig, assignees, getInitials } from '@/composables/useKanban'
defineProps<{
searchQuery: string
selectedPriority: string | null
selectedAssignee: string | null
viewMode: 'board' | 'list'
}>()
defineEmits<{
'update:searchQuery': [value: string]
'update:selectedPriority': [value: string | null]
'update:selectedAssignee': [value: string | null]
'update:viewMode': [value: 'board' | 'list']
}>()
</script>
<template>
<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
:model-value="searchQuery"
placeholder="Search tasks..."
class="h-8 pl-8 text-sm"
@update:model-value="$emit('update:searchQuery', String($event))"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="h-8 gap-1.5 text-xs">
<Filter class="size-3" />
Priority
<Badge
v-if="selectedPriority"
variant="default"
class="ml-0.5 h-4 min-w-4 justify-center rounded px-1 text-xs"
>
1
</Badge>
<ChevronDown class="text-muted-foreground size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-40">
<DropdownMenuItem
v-for="(config, key) in priorityConfig"
:key="key"
class="gap-2"
@click="$emit('update:selectedPriority', selectedPriority === key ? null : (key as string))"
>
<component :is="config.icon" :class="['size-3.5', config.class]" />
{{ config.label }}
<span v-if="selectedPriority === key" class="bg-primary ml-auto size-1.5 rounded-full" />
</DropdownMenuItem>
<DropdownMenuSeparator v-if="selectedPriority" />
<DropdownMenuItem v-if="selectedPriority" @click="$emit('update:selectedPriority', null)">
Clear filter
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Badge
v-if="selectedAssignee"
variant="secondary"
class="h-7 cursor-pointer gap-1 pr-1.5 text-xs"
@click="$emit('update:selectedAssignee', null)"
>
{{ selectedAssignee }}
<X class="size-3 opacity-50" />
</Badge>
<ToggleGroup
type="single"
size="sm"
:model-value="viewMode"
class="bg-muted ml-auto flex items-center gap-0.5 rounded-md p-0.5"
@update:model-value="(v) => v && $emit('update:viewMode', v as 'board' | 'list')"
>
<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 :delay-duration="300">
<Tooltip v-for="(a, key) in assignees" :key="key">
<TooltipTrigger as-child>
<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',
]"
@click="$emit('update: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 }}
<span v-if="selectedAssignee === a.name" class="text-muted-foreground ml-1">(filtered)</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { priorityConfig } from '@/composables/useKanban'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{
priority: string
iconSize?: string
class?: string
}>(),
{
iconSize: 'size-3.5',
},
)
const config = computed(() => priorityConfig[props.priority])
</script>
<template>
<div data-slot="kanban-board" v-if="config" :class="cn('flex items-center gap-1', props.class)">
<component :is="config.icon" :class="[iconSize, config.class]" />
<span :class="['text-xs font-semibold', config.class]">{{ config.label }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Clock } from 'lucide-vue-next'
import { getDueStatus, formatDueDate } from '@/composables/useKanban'
import { cn } from '@/lib/utils'
const props = defineProps<{
dueDate: string
variant?: 'chip' | 'inline'
class?: string
}>()
const status = computed(() => getDueStatus(props.dueDate))
const formatted = computed(() => formatDueDate(props.dueDate))
const chipClasses = computed(() => {
switch (status.value) {
case 'overdue':
return 'bg-destructive/10 text-destructive'
case 'soon':
return 'bg-warning/10 text-warning'
default:
return 'text-muted-foreground bg-muted'
}
})
const inlineClasses = computed(() => {
switch (status.value) {
case 'overdue':
return 'text-destructive'
case 'soon':
return 'text-warning'
default:
return 'text-foreground'
}
})
</script>
<template>
<div
data-slot="kanban-board"
v-if="variant === 'chip'"
:class="cn('flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-medium', chipClasses, props.class)"
>
<Clock class="size-3" />
{{ formatted }}
</div>
<p v-else :class="cn('flex items-center gap-1 text-sm leading-tight font-medium', inlineClasses, props.class)">
<Clock class="size-3" />
{{ formatted }}
</p>
</template>
<script setup lang="ts">
import { cn } from '@/lib/utils'
defineProps<{
label: string
color: string
class?: string
}>()
</script>
<template>
<span
data-slot="kanban-board"
:class="cn('rounded-md px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset', color, $props.class)"
>
{{ label }}
</span>
</template>
<script setup lang="ts">
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { getInitials } from '@/composables/useKanban'
import { cn } from '@/lib/utils'
defineProps<{
name: string
color: string
size?: 'xs' | 'sm' | 'md'
class?: string
}>()
const sizeMap = {
xs: { avatar: 'size-6', text: 'text-xs' },
sm: { avatar: 'size-7', text: 'text-xs' },
md: { avatar: 'size-8', text: 'text-xs' },
}
</script>
<template>
<Avatar data-slot="kanban-board" :class="cn(sizeMap[size ?? 'sm'].avatar, 'shrink-0', $props.class)">
<AvatarFallback :class="[sizeMap[size ?? 'sm'].text, 'font-bold', color]">
{{ getInitials(name) }}
</AvatarFallback>
</Avatar>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { CheckCircle2 } from 'lucide-vue-next'
const props = defineProps<{
done: number
total: number
barHeight?: string
}>()
const percent = computed(() => (props.total > 0 ? Math.round((props.done / props.total) * 100) : 0))
const isComplete = computed(() => props.done === props.total && props.total > 0)
</script>
<template>
<div data-slot="kanban-board" v-if="total > 0">
<div class="mb-1 flex items-center justify-between">
<span class="text-muted-foreground text-xs">
<CheckCircle2 v-if="isComplete" class="text-success mr-0.5 inline size-3" />
{{ 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 ?? 'h-1.5']">
<div
:class="[
'h-full rounded-full transition-[width,background-color] duration-500',
isComplete ? 'bg-success' : 'bg-primary',
]"
:style="{ width: `${percent}%` }"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import type { KanbanColumn } from '@/composables/useKanban'
import { findTaskById, getTaskColumn } from '@/composables/useKanban'
import { kanbanLinkKey } from './link-injection'
const props = defineProps<{
subtaskIds: string[]
columns: KanbanColumn[]
compact?: boolean
}>()
const subtasks = computed(() => {
return props.subtaskIds
.map((id) => {
const task = findTaskById(props.columns, id)
const column = getTaskColumn(props.columns, id)
return task ? { task, column } : null
})
.filter(Boolean) as {
task: NonNullable<ReturnType<typeof findTaskById>>
column: ReturnType<typeof getTaskColumn>
}[]
})
const doneCount = computed(() => subtasks.value.filter((s) => s.column?.id === 'done').length)
const linkComponent = inject(
kanbanLinkKey,
computed(() => 'a' as const),
)
</script>
<template>
<div data-slot="kanban-board" v-if="subtasks.length">
<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">
{{ subtasks.length > 0 ? Math.round((doneCount / subtasks.length) * 100) : 0 }}%
</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: `${subtasks.length > 0 ? Math.round((doneCount / subtasks.length) * 100) : 0}%` }"
/>
</div>
<div :class="compact ? 'space-y-0.5' : 'space-y-1'">
<component
:is="linkComponent"
v-for="{ task, column } in subtasks"
:key="task.id"
:to="`/dashboard/kanban/${task.id}`"
:href="`/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',
]"
>
<span :class="['size-1.5 shrink-0 rounded-full', column?.dotColor ?? 'bg-muted-foreground']" />
<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>
</component>
</div>
</div>
<p v-else :class="compact ? 'text-muted-foreground text-xs' : 'text-muted-foreground text-sm'">No subtasks yet.</p>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Send } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { RichTextEditor } from '@/components/ui/rich-text-editor'
import UserAvatar from './UserAvatar.vue'
import type { CommentItem } from '@/composables/useKanban'
withDefaults(
defineProps<{
comments: CommentItem[]
compact?: boolean
}>(),
{
compact: false,
},
)
const emit = defineEmits<{
add: [text: string]
}>()
const newComment = ref('')
function submit() {
const stripped = newComment.value.replace(/<[^>]*>/g, '').trim()
if (!stripped) return
emit('add', newComment.value)
newComment.value = ''
}
</script>
<template>
<div data-slot="kanban-board" v-if="comments.length" :class="compact ? 'space-y-3' : 'space-y-4'">
<div v-for="comment in comments" :key="comment.id" :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',
]"
v-html="comment.text"
/>
</div>
</div>
</div>
<p v-else :class="compact ? 'text-muted-foreground text-xs' : 'text-muted-foreground text-sm'">No comments yet.</p>
<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
v-model:model-value="newComment"
placeholder="Write a comment..."
:min-height="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="!newComment.replace(/<[^>]*>/g, '').trim()"
@click="submit"
>
<Send class="size-3" />
Comment
</Button>
</div>
</div>
</div>
</template>
import type { InjectionKey, ComputedRef, Component } from 'vue'
// Shared provide/inject key for the kanban-board link component.
// KanbanBoard provides it; KanbanCard, KanbanListView, KanbanTaskSheet,
// and SubtaskList inject it so task links render with the consumer's
// router component (NuxtLink, RouterLink) or a plain <a> by default.
export const kanbanLinkKey: InjectionKey<ComputedRef<string | Component>> = Symbol('kanbanLink')
Raw manifest:https://uipkge.dev/r/vue/kanban-task-board.json