diff --git a/src/components/board/BoardView.tsx b/src/components/board/BoardView.tsx index 46e75fd..76cc310 100644 --- a/src/components/board/BoardView.tsx +++ b/src/components/board/BoardView.tsx @@ -1,17 +1,56 @@ -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useCallback } from "react"; import { Plus } from "lucide-react"; +import { + DndContext, + DragOverlay, + closestCorners, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragStartEvent, + type DragOverEvent, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + horizontalListSortingStrategy, +} from "@dnd-kit/sortable"; import { Button } from "@/components/ui/button"; import { useBoardStore } from "@/stores/board-store"; import { KanbanColumn } from "@/components/board/KanbanColumn"; +import { + CardOverlay, + ColumnOverlay, +} from "@/components/board/DragOverlayContent"; +import type { Board } from "@/types/board"; + +function findColumnByCardId(board: Board, cardId: string) { + return board.columns.find((col) => col.cardIds.includes(cardId)); +} export function BoardView() { const board = useBoardStore((s) => s.board); const addColumn = useBoardStore((s) => s.addColumn); + const moveCard = useBoardStore((s) => s.moveCard); + const moveColumn = useBoardStore((s) => s.moveColumn); const [addingColumn, setAddingColumn] = useState(false); const [newColumnTitle, setNewColumnTitle] = useState(""); const inputRef = useRef(null); + // Drag state + const [activeId, setActiveId] = useState(null); + const [activeType, setActiveType] = useState<"card" | "column" | null>(null); + + // Sensors: PointerSensor with distance of 5px prevents accidental drags on click + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }), + useSensor(KeyboardSensor) + ); + useEffect(() => { if (addingColumn && inputRef.current) { inputRef.current.focus(); @@ -23,7 +62,6 @@ export function BoardView() { if (trimmed) { addColumn(trimmed); setNewColumnTitle(""); - // Keep the input open for quick successive adds inputRef.current?.focus(); } } @@ -37,6 +75,145 @@ export function BoardView() { } } + // --- Drag handlers --- + + const handleDragStart = useCallback((event: DragStartEvent) => { + const { active } = event; + const type = active.data.current?.type as "card" | "column" | undefined; + setActiveId(active.id as string); + setActiveType(type ?? null); + }, []); + + const handleDragOver = useCallback( + (event: DragOverEvent) => { + const { active, over } = event; + if (!over || !board) return; + + const activeType = active.data.current?.type; + if (activeType !== "card") return; // Only handle card cross-column moves here + + const activeCardId = active.id as string; + const overId = over.id as string; + + // Determine the source column + const activeColumn = findColumnByCardId(board, activeCardId); + if (!activeColumn) return; + + // Determine the target column + let overColumn: ReturnType; + let overIndex: number; + + // Check if we're hovering over a card + const overType = over.data.current?.type; + if (overType === "card") { + overColumn = findColumnByCardId(board, overId); + if (!overColumn) return; + overIndex = overColumn.cardIds.indexOf(overId); + } else if (overType === "column") { + // Hovering over the droppable area of a column + const columnId = over.data.current?.columnId as string | undefined; + if (columnId) { + overColumn = board.columns.find((c) => c.id === columnId); + } else { + overColumn = board.columns.find((c) => c.id === overId); + } + if (!overColumn) return; + overIndex = overColumn.cardIds.length; // Append to end + } else { + return; + } + + // Only move if we're going to a different column or different position + if (activeColumn.id === overColumn.id) return; + + moveCard(activeCardId, activeColumn.id, overColumn.id, overIndex); + }, + [board, moveCard] + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + + if (!over || !board) { + setActiveId(null); + setActiveType(null); + return; + } + + const type = active.data.current?.type; + + if (type === "column") { + // Column reordering + const activeColumnId = active.id as string; + const overColumnId = over.id as string; + + if (activeColumnId !== overColumnId) { + const fromIndex = board.columns.findIndex( + (c) => c.id === activeColumnId + ); + const toIndex = board.columns.findIndex( + (c) => c.id === overColumnId + ); + if (fromIndex !== -1 && toIndex !== -1) { + moveColumn(fromIndex, toIndex); + } + } + } else if (type === "card") { + // Card reordering within same column (cross-column already handled in onDragOver) + const activeCardId = active.id as string; + const overId = over.id as string; + + const activeColumn = findColumnByCardId(board, activeCardId); + if (!activeColumn) { + setActiveId(null); + setActiveType(null); + return; + } + + const overType = over.data.current?.type; + + if (overType === "card") { + const overColumn = findColumnByCardId(board, overId); + if (!overColumn) { + setActiveId(null); + setActiveType(null); + return; + } + + if (activeColumn.id === overColumn.id) { + // Within same column, reorder + const oldIndex = activeColumn.cardIds.indexOf(activeCardId); + const newIndex = activeColumn.cardIds.indexOf(overId); + if (oldIndex !== newIndex) { + moveCard(activeCardId, activeColumn.id, activeColumn.id, newIndex); + } + } + } else if (overType === "column") { + // Dropped on an empty column droppable + const columnId = over.data.current?.columnId as string | undefined; + const targetColumnId = columnId ?? (over.id as string); + const targetColumn = board.columns.find( + (c) => c.id === targetColumnId + ); + + if (targetColumn && activeColumn.id !== targetColumn.id) { + moveCard( + activeCardId, + activeColumn.id, + targetColumn.id, + targetColumn.cardIds.length + ); + } + } + } + + setActiveId(null); + setActiveType(null); + }, + [board, moveCard, moveColumn] + ); + if (!board) { return (
@@ -45,58 +222,90 @@ export function BoardView() { ); } - return ( -
- {board.columns.map((column) => ( - - ))} + // Get the active item for the drag overlay + const activeCard = + activeType === "card" && activeId ? board.cards[activeId] : null; + const activeColumn = + activeType === "column" && activeId + ? board.columns.find((c) => c.id === activeId) + : null; - {/* Add column button / inline input */} -
- {addingColumn ? ( -
- setNewColumnTitle(e.target.value)} - onKeyDown={handleKeyDown} - onBlur={() => { - if (!newColumnTitle.trim()) { - setAddingColumn(false); - } - }} - placeholder="Column title..." - className="h-8 rounded-md bg-pylon-surface px-3 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary focus:ring-1 focus:ring-pylon-accent" - /> -
- + const columnIds = board.columns.map((c) => c.id); + + return ( + + +
+ {board.columns.map((column) => ( + + ))} + + {/* Add column button / inline input */} +
+ {addingColumn ? ( +
+ setNewColumnTitle(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { + if (!newColumnTitle.trim()) { + setAddingColumn(false); + } + }} + placeholder="Column title..." + className="h-8 rounded-md bg-pylon-surface px-3 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary focus:ring-1 focus:ring-pylon-accent" + /> +
+ + +
+
+ ) : ( -
+ )}
- ) : ( - - )} -
-
+
+ + + {/* Drag overlay - renders a styled copy of the dragged item */} + + {activeCard ? ( + + ) : activeColumn ? ( + + ) : null} + + ); } diff --git a/src/components/board/CardThumbnail.tsx b/src/components/board/CardThumbnail.tsx index d5e66b2..8c027a8 100644 --- a/src/components/board/CardThumbnail.tsx +++ b/src/components/board/CardThumbnail.tsx @@ -1,4 +1,6 @@ import { format, isPast, isToday } from "date-fns"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import type { Card, Label } from "@/types/board"; import { LabelDots } from "@/components/board/LabelDots"; import { ChecklistBar } from "@/components/board/ChecklistBar"; @@ -6,9 +8,28 @@ import { ChecklistBar } from "@/components/board/ChecklistBar"; interface CardThumbnailProps { card: Card; boardLabels: Label[]; + columnId: string; } -export function CardThumbnail({ card, boardLabels }: CardThumbnailProps) { +export function CardThumbnail({ card, boardLabels, columnId }: CardThumbnailProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: card.id, + data: { type: "card", columnId }, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.3 : undefined, + }; + const hasDueDate = card.dueDate != null; const dueDate = hasDueDate ? new Date(card.dueDate!) : null; const overdue = dueDate != null && isPast(dueDate) && !isToday(dueDate); @@ -20,8 +41,12 @@ export function CardThumbnail({ card, boardLabels }: CardThumbnailProps) { return (