From e55dab6473ae49883cce6d45b40adce8f978a8ea Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 26 Jul 2026 01:04:42 +0200 Subject: [PATCH] feat(ui): Kanban-Board mit Ziehen und Ablegen Dritte Ansicht neben Raster und Gruppierung: eine Spalte je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und erst danach gespeichert; scheitert das Speichern, springt die Karte zurueck. Details, die den Unterschied machen: - Gezogen wird am Griff links, nicht an der ganzen Karte - so bleibt der Titel ein normaler Link. - Das Ziehen startet erst nach sechs Pixeln Bewegung, sonst liesse sich die Karte nicht mehr anklicken. Auf Touchgeraeten nach kurzem Halten. - Die mitgezogene Karte haengt in einem DragOverlay am Cursor, das Original bleibt blass stehen - sonst wuerde die Spalte unter dem Finger springen. - Bestaetigt der Server die Verschiebung, wird die oertliche Ausnahme wieder entfernt, damit sie nicht veraltet. Die Reihenfolge innerhalb einer Spalte folgt der gewaehlten Sortierung. Frei sortieren hiesse, eine Position je Projekt zu speichern - bewusst nicht gebaut. Nachgewiesen im Browser: Karte aus "Idee" nach "Fertig" gezogen, Zielspalte hebt sich hervor und zeigt "Hier ablegen", danach steht das Projekt serverseitig auf DONE. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/KanbanBoard.tsx | 276 +++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 src/components/KanbanBoard.tsx diff --git a/src/components/KanbanBoard.tsx b/src/components/KanbanBoard.tsx new file mode 100644 index 0000000..020910b --- /dev/null +++ b/src/components/KanbanBoard.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { + DndContext, + DragOverlay, + PointerSensor, + TouchSensor, + closestCorners, + useDraggable, + useDroppable, + useSensor, + useSensors, + type DragEndEvent, + type DragStartEvent, +} from "@dnd-kit/core"; +import { restrictToWindowEdges } from "@dnd-kit/modifiers"; +import { GitBranch, GripVertical, NotebookPen } from "lucide-react"; +import type { ProjectStatus } from "@prisma/client"; +import { CiBadge } from "@/components/CiBadge"; +import { STATUS_META, STATUS_ORDER, accentOf } from "@/lib/status"; +import { cn, truncate } from "@/lib/utils"; +import type { ProjectListItem } from "@/types"; + +/** + * Statusspalten mit Ziehen und Ablegen. + * + * Der Statuswechsel wird sofort angezeigt und erst danach gespeichert; + * scheitert das Speichern, springt die Karte zurueck. Die Reihenfolge + * innerhalb einer Spalte ist nicht frei sortierbar – sie folgt der im Board + * gewaehlten Sortierung, sonst muesste eine Position mitgespeichert werden. + */ +export function KanbanBoard({ + projects, + showArchived, +}: { + projects: ProjectListItem[]; + showArchived: boolean; +}) { + const router = useRouter(); + const [overrides, setOverrides] = useState>({}); + const [activeId, setActiveId] = useState(null); + + // Eine Verschiebung, die der Server bestaetigt hat, braucht keine + // oertliche Ausnahme mehr. + useEffect(() => { + setOverrides((current) => { + const next: Record = {}; + for (const [id, status] of Object.entries(current)) { + const project = projects.find((entry) => entry.id === id); + if (project && project.status !== status) next[id] = status; + } + return Object.keys(next).length === Object.keys(current).length ? current : next; + }); + }, [projects]); + + const sensors = useSensors( + // Erst ab ein paar Pixeln Bewegung ziehen – sonst liesse sich die Karte + // nicht mehr anklicken. + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }), + ); + + const statusOf = (project: ProjectListItem) => overrides[project.id] ?? project.status; + + const columns = useMemo(() => { + const visible = STATUS_ORDER.filter( + (status) => showArchived || status !== "ARCHIVED", + ); + return visible.map((status) => ({ + status, + items: projects.filter((project) => statusOf(project) === status), + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projects, overrides, showArchived]); + + const active = activeId ? projects.find((project) => project.id === activeId) : null; + + function onDragStart(event: DragStartEvent) { + setActiveId(String(event.active.id)); + } + + async function onDragEnd(event: DragEndEvent) { + setActiveId(null); + + const id = String(event.active.id); + const target = event.over?.id ? (String(event.over.id) as ProjectStatus) : null; + if (!target) return; + + const project = projects.find((entry) => entry.id === id); + if (!project) return; + + const previous = statusOf(project); + if (previous === target) return; + + setOverrides((current) => ({ ...current, [id]: target })); + + try { + const response = await fetch(`/api/projects/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: target }), + }); + if (!response.ok) throw new Error("failed"); + router.refresh(); + } catch { + setOverrides((current) => { + const next = { ...current }; + delete next[id]; + return next; + }); + } + } + + return ( + setActiveId(null)} + > +
+ {columns.map((column) => ( + + {column.items.map((project) => ( + + ))} + + ))} +
+ + {/* Die mitgezogene Karte haengt am Cursor, nicht in der Spalte. */} + + {active ? : null} + +
+ ); +} + +function Column({ + status, + count, + children, +}: { + status: ProjectStatus; + count: number; + children: React.ReactNode; +}) { + const meta = STATUS_META[status]; + const { setNodeRef, isOver } = useDroppable({ id: status }); + + return ( +
+
+ +

+ {meta.label} +

+ + {count} + +
+ +
+ {children} + {count === 0 ? ( +

+ {isOver ? "Hier ablegen" : "leer"} +

+ ) : null} +
+
+ ); +} + +function KanbanCard({ + project, + overlay = false, +}: { + project: ProjectListItem; + overlay?: boolean; +}) { + const accent = accentOf(project.accent); + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: project.id }); + + return ( +
+
+ ); +}