Kanban shenanigans

This commit is contained in:
2025-07-05 10:49:28 +02:00
parent 75623f37bc
commit babc1d95ba
4 changed files with 79 additions and 39 deletions

View File

@ -3,7 +3,7 @@
import { mockCards } from "@/data/mockCards"
import type { KanbanCardData } from "@/types/kanban"
import { DndContext, DragEndEvent, closestCenter } from "@dnd-kit/core"
import { useState } from "react"
import { useEffect, useState } from "react"
import { KanbanColumn } from "./KanbanColumn"
export type Status = "PENDING" | "ACCEPTED" | "IN_PROGRESS" | "DONE" | "REJECTED"
@ -13,6 +13,11 @@ const columns: Status[] = ["PENDING", "ACCEPTED", "IN_PROGRESS", "DONE", "REJECT
export function KanbanBoard() {
const [cards, setCards] = useState<Record<Status, KanbanCardData[]>>(mockCards)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
function findColumnByCardId(cardId: string): Status | null {
for (const status of columns) {
if (cards[status].some((c) => c.id === cardId)) return status
@ -41,12 +46,18 @@ export function KanbanBoard() {
}
return (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-4">
{mounted ? (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
{columns.map((status) => (
<KanbanColumn key={status} status={status} items={cards[status]} />
))}
</div>
</DndContext>
) : (
columns.map((status) => (
<KanbanColumn key={status} status={status} items={cards[status]} />
))
)}
</div>
)
}

View File

@ -1,8 +1,8 @@
"use client"
import { Dialog, DialogContent } from "@/components/ui/dialog"
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
import type { KanbanCardData } from "@/types/kanban"
import { VisuallyHidden } from "@radix-ui/react-visually-hidden"
export interface KanbanCardModalProps {
card: KanbanCardData | null
onClose: () => void
@ -12,6 +12,11 @@ export function KanbanCardModal({ card, onClose }: KanbanCardModalProps) {
return (
<Dialog open={!!card} onOpenChange={onClose}>
<DialogContent>
<VisuallyHidden>
<DialogTitle className="text-lg font-semibold mb-2">
{card?.title ?? "Card Details"}
</DialogTitle>
</VisuallyHidden>
{card && (
<div className="space-y-4">
<h3 className="text-xl font-semibold">{card.title}</h3>

View File

@ -1,43 +1,71 @@
// src/components/kanban/KanbanCardPreview.tsx
"use client"
import type { KanbanCardData } from "@/types/kanban"
import { cn } from "@/lib/utils"
import { KanbanCardData } from "@/types/kanban"
import { useDraggable } from "@dnd-kit/core"
import { PointerEvent, useRef } from "react"
interface KanbanCardPreviewProps extends KanbanCardData {
interface KanbanCardPreviewProps {
card: KanbanCardData
onClick: () => void
}
export function KanbanCardPreview({
id,
title,
labels = [],
todos = [],
onClick,
}: KanbanCardPreviewProps) {
const { setNodeRef, attributes, listeners, transform, isDragging } = useDraggable({
id,
export function KanbanCardPreview({ card, onClick }: KanbanCardPreviewProps) {
const {
attributes,
listeners = {},
setNodeRef,
transform,
isDragging,
} = useDraggable({
id: card.id,
data: { card },
})
const done = todos.filter((t) => t.done).length
const total = todos.length
const dragThreshold = 5
const pointerStart = useRef<{ x: number; y: number } | null>(null)
const handlePointerDown = (e: PointerEvent) => {
pointerStart.current = { x: e.clientX, y: e.clientY }
listeners.onPointerDown?.(e)
}
const handlePointerUp = (e: PointerEvent) => {
if (!pointerStart.current) return
const dx = Math.abs(e.clientX - pointerStart.current.x)
const dy = Math.abs(e.clientY - pointerStart.current.y)
const isDrag = dx > dragThreshold || dy > dragThreshold
if (!isDrag) {
onClick()
}
pointerStart.current = null
}
const todoCount = card.todos?.length ?? 0
const doneCount = card.todos?.filter((t) => t.done).length ?? 0
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
onClick={onClick}
className={`rounded-md bg-white dark:bg-zinc-900 border border-border p-3 shadow-sm cursor-pointer hover:ring-2 hover:ring-primary/40 transition ${isDragging ? "opacity-50" : ""
}`}
{...listeners}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
style={{
transform: transform
? `translate(${transform.x}px, ${transform.y}px)`
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
: undefined,
opacity: isDragging ? 0.5 : 1,
}}
className={cn(
"rounded-md bg-card p-3 shadow transition-all cursor-pointer select-none",
isDragging && "ring-2 ring-ring"
)}
>
<div className="flex gap-1 mb-2 flex-wrap">
{labels.map((label) => (
<span
<div className="flex gap-1 mb-1 flex-wrap">
{card.labels?.map((label) => (
<div
key={label.id}
className="w-3 h-3 rounded-full"
style={{ backgroundColor: label.color }}
@ -45,14 +73,10 @@ export function KanbanCardPreview({
/>
))}
</div>
<div className="font-medium text-sm mb-1">{title}</div>
{total > 0 && (
<h3 className="font-medium text-sm mb-1">{card.title}</h3>
<div className="text-xs text-muted-foreground">
{done} / {total} tasks done
{doneCount}/{todoCount} ToDos
</div>
)}
</div>
)
}

View File

@ -27,7 +27,7 @@ export function KanbanColumn({ status, items }: KanbanColumnProps) {
{items.map((item) => (
<KanbanCardPreview
key={item.id}
{...item}
card={item}
onClick={() => setActiveCard(item)}
/>
))}