"use client"; import { useState, useCallback, useMemo } from "react"; import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, pointerWithin, closestCenter, type CollisionDetection, type DragStartEvent, type DragEndEvent, } from "@dnd-kit/core"; import { Eye, MoreHorizontal } from "lucide-react"; import type { Issue, IssueStatus } from "@/shared/types"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config"; import { useViewStoreApi } from "@/features/issues/stores/view-store-context"; import { StatusIcon } from "./status-icon"; import { BoardColumn } from "./board-column"; import { BoardCardContent } from "./board-card"; const COLUMN_IDS = new Set(ALL_STATUSES); const kanbanCollision: CollisionDetection = (args) => { const pointer = pointerWithin(args); if (pointer.length > 0) { // Prefer card collisions over column collisions so that // dragging down within a column finds the target card // instead of the column droppable. const cards = pointer.filter((c) => !COLUMN_IDS.has(c.id as string)); if (cards.length > 0) return cards; } // Fallback: closestCenter finds the nearest card even when // the pointer is in a gap between cards (common when dragging down). return closestCenter(args); }; /** Compute a float position to place an item at `targetIndex` within `siblings`. */ function computePosition(siblings: Issue[], targetIndex: number): number { if (siblings.length === 0) return 0; if (targetIndex <= 0) return siblings[0]!.position - 1; if (targetIndex >= siblings.length) return siblings[siblings.length - 1]!.position + 1; return (siblings[targetIndex - 1]!.position + siblings[targetIndex]!.position) / 2; } export function BoardView({ issues, allIssues, visibleStatuses, hiddenStatuses, onMoveIssue, }: { issues: Issue[]; allIssues: Issue[]; visibleStatuses: IssueStatus[]; hiddenStatuses: IssueStatus[]; onMoveIssue: ( issueId: string, newStatus: IssueStatus, newPosition?: number ) => void; }) { const [activeIssue, setActiveIssue] = useState(null); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 }, }) ); // Pre-sort issues by position per status for position calculations const issuesByStatus = useMemo(() => { const map: Record = {}; for (const status of visibleStatuses) { map[status] = issues .filter((i) => i.status === status) .sort((a, b) => a.position - b.position); } return map; }, [issues, visibleStatuses]); const handleDragStart = useCallback( (event: DragStartEvent) => { const issue = issues.find((i) => i.id === event.active.id); if (issue) setActiveIssue(issue); }, [issues] ); const handleDragEnd = useCallback( (event: DragEndEvent) => { setActiveIssue(null); const { active, over } = event; if (!over || active.id === over.id) return; const issueId = active.id as string; const currentIssue = issues.find((i) => i.id === issueId); if (!currentIssue) return; // Determine target status let targetStatus: IssueStatus; let overIsColumn = false; if (visibleStatuses.includes(over.id as IssueStatus)) { targetStatus = over.id as IssueStatus; overIsColumn = true; } else { const targetIssue = issues.find((i) => i.id === over.id); if (!targetIssue) return; targetStatus = targetIssue.status; } // Get sorted siblings in the target column (excluding the dragged item) const siblings = (issuesByStatus[targetStatus] ?? []).filter( (i) => i.id !== issueId ); // Compute new position let newPosition: number; if (overIsColumn) { // Dropped on empty area of column → append to end newPosition = computePosition(siblings, siblings.length); } else { // Dropped on a specific card → insert at that card's index const overIndex = siblings.findIndex((i) => i.id === over.id); if (overIndex === -1) { newPosition = computePosition(siblings, siblings.length); } else { const isSameColumn = currentIssue.status === targetStatus; const overIssuePosition = siblings[overIndex]!.position; if (isSameColumn && currentIssue.position < overIssuePosition) { // Moving down → insert after the over card newPosition = computePosition(siblings, overIndex + 1); } else { // Moving up or cross-column → insert before the over card newPosition = computePosition(siblings, overIndex); } } } // Skip if nothing changed if ( currentIssue.status === targetStatus && currentIssue.position === newPosition ) { return; } onMoveIssue(issueId, targetStatus, newPosition); }, [issues, issuesByStatus, onMoveIssue, visibleStatuses] ); return (
{visibleStatuses.map((status) => ( i.status === status)} /> ))} {hiddenStatuses.length > 0 && ( )}
{activeIssue ? (
) : null}
); } function HiddenColumnsPanel({ hiddenStatuses, issues, }: { hiddenStatuses: IssueStatus[]; issues: Issue[]; }) { const viewStoreApi = useViewStoreApi(); return (
Hidden columns
{hiddenStatuses.map((status) => { const cfg = STATUS_CONFIG[status]; const count = issues.filter((i) => i.status === status).length; return (
{cfg.label}
{count} } /> viewStoreApi.getState().showStatus(status) } > Show column
); })}
); }