"use client"; import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, pointerWithin, closestCenter, type CollisionDetection, type DragStartEvent, type DragEndEvent, type DragOverEvent, } from "@dnd-kit/core"; import { arrayMove } from "@dnd-kit/sortable"; import { Eye, Loader2, MoreHorizontal } from "lucide-react"; import type { Issue, IssueStatus } from "@/shared/types"; import { Button } from "@/components/ui/button"; import { useLoadMoreDoneIssues } from "@core/issues/mutations"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config"; import { useViewStoreApi, useViewStore } from "@/features/issues/stores/view-store-context"; import type { SortField, SortDirection } from "@/features/issues/stores/view-store"; import { sortIssues } from "@/features/issues/utils/sort"; import { StatusIcon } from "./status-icon"; import { BoardColumn } from "./board-column"; import { BoardCardContent } from "./board-card"; /** Sentinel that triggers `onVisible` when scrolled into view. */ function InfiniteScrollSentinel({ onVisible, loading }: { onVisible: () => void; loading: boolean }) { const sentinelRef = useRef(null); const onVisibleRef = useRef(onVisible); onVisibleRef.current = onVisible; useEffect(() => { const node = sentinelRef.current; if (!node) return; const observer = new IntersectionObserver( ([entry]) => { if (entry?.isIntersecting) onVisibleRef.current(); }, { rootMargin: "100px" }, ); observer.observe(node); return () => observer.disconnect(); }, []); return (
{loading && }
); } 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); }; /** Build column ID arrays from TQ issue data, respecting current sort. */ function buildColumns( issues: Issue[], visibleStatuses: IssueStatus[], sortBy: SortField, sortDirection: SortDirection, ): Record { const cols = {} as Record; for (const status of visibleStatuses) { const sorted = sortIssues( issues.filter((i) => i.status === status), sortBy, sortDirection, ); cols[status] = sorted.map((i) => i.id); } return cols; } /** Compute a float position for `activeId` based on its neighbors in `ids`. */ function computePosition(ids: string[], activeId: string, issueMap: Map): number { const idx = ids.indexOf(activeId); if (idx === -1) return 0; const getPos = (id: string) => issueMap.get(id)?.position ?? 0; if (ids.length === 1) return issueMap.get(activeId)?.position ?? 0; if (idx === 0) return getPos(ids[1]!) - 1; if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1; return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2; } /** Find which column (status) contains a given ID (issue or column droppable). */ function findColumn( columns: Record, id: string, visibleStatuses: IssueStatus[], ): IssueStatus | null { if (visibleStatuses.includes(id as IssueStatus)) return id as IssueStatus; for (const [status, ids] of Object.entries(columns)) { if (ids.includes(id)) return status as IssueStatus; } return null; } 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 sortBy = useViewStore((s) => s.sortBy); const sortDirection = useViewStore((s) => s.sortDirection); const { loadMore, hasMore, isLoading: loadingMore, doneTotal } = useLoadMoreDoneIssues(); // --- Drag state --- const [activeIssue, setActiveIssue] = useState(null); const isDraggingRef = useRef(false); // --- Local columns state --- // Between drags: follows TQ via useEffect. // During drag: local-only, driven by onDragOver/onDragEnd. const [columns, setColumns] = useState>(() => buildColumns(issues, visibleStatuses, sortBy, sortDirection), ); const columnsRef = useRef(columns); columnsRef.current = columns; useEffect(() => { if (!isDraggingRef.current) { setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection)); } }, [issues, visibleStatuses, sortBy, sortDirection]); // After a cross-column move, lock for one animation frame so dnd-kit's // collision detection can stabilize before processing the next move. // Without this, collision oscillates: A→B→A→B… until React bails out. const recentlyMovedRef = useRef(false); useEffect(() => { const id = requestAnimationFrame(() => { recentlyMovedRef.current = false; }); return () => cancelAnimationFrame(id); }, [columns]); // --- Issue map --- // Frozen during drag so BoardColumn/DraggableBoardCard props stay // referentially stable even if a TQ refetch lands mid-drag. const issueMap = useMemo(() => { const map = new Map(); for (const issue of issues) map.set(issue.id, issue); return map; }, [issues]); const issueMapRef = useRef(issueMap); if (!isDraggingRef.current) { issueMapRef.current = issueMap; } const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 }, }) ); const handleDragStart = useCallback( (event: DragStartEvent) => { isDraggingRef.current = true; const issue = issueMapRef.current.get(event.active.id as string) ?? null; setActiveIssue(issue); }, [], ); const handleDragOver = useCallback( (event: DragOverEvent) => { const { active, over } = event; if (!over || recentlyMovedRef.current) return; const activeId = active.id as string; const overId = over.id as string; setColumns((prev) => { const activeCol = findColumn(prev, activeId, visibleStatuses); const overCol = findColumn(prev, overId, visibleStatuses); if (!activeCol || !overCol || activeCol === overCol) return prev; recentlyMovedRef.current = true; const oldIds = prev[activeCol]!.filter((id) => id !== activeId); const newIds = [...prev[overCol]!]; const overIndex = newIds.indexOf(overId); const insertIndex = overIndex >= 0 ? overIndex : newIds.length; newIds.splice(insertIndex, 0, activeId); return { ...prev, [activeCol]: oldIds, [overCol]: newIds }; }); }, [visibleStatuses], ); const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; isDraggingRef.current = false; setActiveIssue(null); const resetColumns = () => setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection)); if (!over) { resetColumns(); return; } const activeId = active.id as string; const overId = over.id as string; const cols = columnsRef.current; const activeCol = findColumn(cols, activeId, visibleStatuses); const overCol = findColumn(cols, overId, visibleStatuses); if (!activeCol || !overCol) { resetColumns(); return; } // Same-column reorder let finalColumns = cols; if (activeCol === overCol) { const ids = cols[activeCol]!; const oldIndex = ids.indexOf(activeId); const newIndex = ids.indexOf(overId); if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) { const reordered = arrayMove(ids, oldIndex, newIndex); finalColumns = { ...cols, [activeCol]: reordered }; setColumns(finalColumns); } } const finalCol = findColumn(finalColumns, activeId, visibleStatuses); if (!finalCol) { resetColumns(); return; } const map = issueMapRef.current; const finalIds = finalColumns[finalCol]!; const newPosition = computePosition(finalIds, activeId, map); const currentIssue = map.get(activeId); if ( currentIssue && currentIssue.status === finalCol && currentIssue.position === newPosition ) { return; } onMoveIssue(activeId, finalCol, newPosition); }, [issues, visibleStatuses, sortBy, sortDirection, onMoveIssue], ); return (
{visibleStatuses.map((status) => ( ) : undefined } /> ))} {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
); })}
); }