import React, { useState, useRef, useEffect, useCallback } from "react"; import { Plus } from "lucide-react"; import { AnimatePresence, motion } from "framer-motion"; import { OverlayScrollbarsComponent, type OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; import { staggerContainer } from "@/lib/motion"; 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 { CardDetailModal } from "@/components/card-detail/CardDetailModal"; import { FilterBar, type FilterState, EMPTY_FILTER, isFilterActive } from "@/components/board/FilterBar"; import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation"; import type { Board } from "@/types/board"; function findColumnByCardId(board: Board, cardId: string) { return board.columns.find((col) => col.cardIds.includes(cardId)); } function getBoardBackground(board: Board): React.CSSProperties { const bg = board.settings.background; if (bg === "dots") { return { backgroundImage: `radial-gradient(circle, currentColor 1px, transparent 1px)`, backgroundSize: "20px 20px", color: "oklch(50% 0 0 / 5%)", }; } if (bg === "grid") { return { backgroundImage: `linear-gradient(currentColor 1px, transparent 1px), linear-gradient(90deg, currentColor 1px, transparent 1px)`, backgroundSize: "24px 24px", color: "oklch(50% 0 0 / 5%)", }; } if (bg === "gradient") { return { background: `linear-gradient(135deg, ${board.color}08, ${board.color}03, transparent)`, }; } return {}; } 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 [selectedCardId, setSelectedCardId] = useState(null); const [showFilterBar, setShowFilterBar] = useState(false); const [filters, setFilters] = useState(EMPTY_FILTER); const { focusedCardId, setFocusedCardId } = useKeyboardNavigation(board, handleCardClick); const [addingColumn, setAddingColumn] = useState(false); const [newColumnTitle, setNewColumnTitle] = useState(""); const inputRef = useRef(null); const osRef = useRef(null); // Track columns that existed on initial render (for stagger vs instant appearance) const initialColumnIds = useRef | null>(null); if (initialColumnIds.current === null && board) { initialColumnIds.current = new Set(board.columns.map((c) => c.id)); } function handleCardClick(cardId: string) { setSelectedCardId(cardId); setFocusedCardId(null); } function filterCards(cardIds: string[]): string[] { if (!isFilterActive(filters) || !board) return cardIds; return cardIds.filter((id) => { const card = board.cards[id]; if (!card) return false; if (filters.text && !card.title.toLowerCase().includes(filters.text.toLowerCase())) return false; if (filters.labels.length > 0 && !filters.labels.some((l) => card.labels.includes(l))) return false; if (filters.priority !== "all" && card.priority !== filters.priority) return false; if (filters.dueDate !== "all") { const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); if (filters.dueDate === "none" && card.dueDate != null) return false; if (filters.dueDate === "overdue" && (!card.dueDate || new Date(card.dueDate) >= today)) return false; if (filters.dueDate === "today") { if (!card.dueDate) return false; const d = new Date(card.dueDate); if (d.toDateString() !== today.toDateString()) return false; } if (filters.dueDate === "week") { if (!card.dueDate) return false; const d = new Date(card.dueDate); const weekEnd = new Date(today); weekEnd.setDate(weekEnd.getDate() + 7); if (d < today || d > weekEnd) return false; } } return true; }); } // Keyboard shortcut: "/" to open filter bar useEffect(() => { function handleKey(e: KeyboardEvent) { if (e.key === "/" && !e.ctrlKey && !e.metaKey) { const tag = (e.target as HTMLElement).tagName; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; e.preventDefault(); setShowFilterBar(true); } } document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); }, []); // Listen for toggle-filter-bar custom event from TopBar useEffect(() => { function handleToggleFilter() { setShowFilterBar((prev) => !prev); } document.addEventListener("toggle-filter-bar", handleToggleFilter); return () => document.removeEventListener("toggle-filter-bar", handleToggleFilter); }, []); // Listen for custom event to open card detail from command palette useEffect(() => { function handleOpenCard(e: Event) { const detail = (e as CustomEvent<{ cardId: string }>).detail; if (detail?.cardId) { setSelectedCardId(detail.cardId); } } document.addEventListener("open-card-detail", handleOpenCard); return () => { document.removeEventListener("open-card-detail", handleOpenCard); }; }, []); // 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(); } }, [addingColumn]); function handleAddColumn() { const trimmed = newColumnTitle.trim(); if (trimmed) { addColumn(trimmed); setNewColumnTitle(""); inputRef.current?.focus(); // Force OverlayScrollbars to detect the new content and scroll to show it setTimeout(() => { const instance = osRef.current?.osInstance(); if (instance) { instance.update(true); const viewport = instance.elements().viewport; viewport.scrollTo({ left: viewport.scrollWidth, behavior: "smooth" }); } }, 50); } } function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter") { handleAddColumn(); } else if (e.key === "Escape") { setAddingColumn(false); setNewColumnTitle(""); } } // --- Drag handlers --- // Debounce cross-column moves to prevent oscillation crashes const lastCrossColumnMoveRef = useRef(0); const clearDragState = useCallback(() => { setActiveId(null); setActiveType(null); lastCrossColumnMoveRef.current = 0; }, []); 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); lastCrossColumnMoveRef.current = 0; }, []); const handleDragOver = useCallback( (event: DragOverEvent) => { const { active, over } = event; if (!over) return; // Always read fresh state to avoid stale-closure bugs const currentBoard = useBoardStore.getState().board; if (!currentBoard) return; const activeType = active.data.current?.type; if (activeType !== "card") return; const activeCardId = active.id as string; const overId = over.id as string; if (overId === activeCardId) return; const activeColumn = findColumnByCardId(currentBoard, activeCardId); if (!activeColumn) return; let overColumn: ReturnType; let overIndex: number; const overType = over.data.current?.type; if (overType === "card") { overColumn = findColumnByCardId(currentBoard, overId); if (!overColumn) return; overIndex = overColumn.cardIds.indexOf(overId); } else if (overType === "column") { const columnId = over.data.current?.columnId as string | undefined; if (columnId) { overColumn = currentBoard.columns.find((c) => c.id === columnId); } else { overColumn = currentBoard.columns.find((c) => c.id === overId); } if (!overColumn) return; overIndex = overColumn.cardIds.length; } else { return; } // Only move cross-column (within-column handled by sortable transforms + dragEnd) if (activeColumn.id === overColumn.id) return; // Debounce: prevent rapid oscillation between columns const now = Date.now(); if (now - lastCrossColumnMoveRef.current < 100) return; lastCrossColumnMoveRef.current = now; moveCard(activeCardId, activeColumn.id, overColumn.id, overIndex); }, [moveCard] ); const handleDragEnd = useCallback( (event: DragEndEvent) => { try { const { active, over } = event; // Always read fresh state const currentBoard = useBoardStore.getState().board; if (!over || !currentBoard) return; const type = active.data.current?.type; if (type === "column") { const activeColumnId = active.id as string; const overColumnId = over.id as string; if (activeColumnId !== overColumnId) { const fromIndex = currentBoard.columns.findIndex( (c) => c.id === activeColumnId ); const toIndex = currentBoard.columns.findIndex( (c) => c.id === overColumnId ); if (fromIndex !== -1 && toIndex !== -1) { moveColumn(fromIndex, toIndex); } } } else if (type === "card") { const activeCardId = active.id as string; const overId = over.id as string; const activeColumn = findColumnByCardId(currentBoard, activeCardId); if (!activeColumn) return; const overType = over.data.current?.type; if (overType === "card") { const overColumn = findColumnByCardId(currentBoard, overId); if (!overColumn) return; if (activeColumn.id === overColumn.id) { 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") { const columnId = over.data.current?.columnId as string | undefined; const targetColumnId = columnId ?? (over.id as string); const targetColumn = currentBoard.columns.find( (c) => c.id === targetColumnId ); if (targetColumn && activeColumn.id !== targetColumn.id) { moveCard( activeCardId, activeColumn.id, targetColumn.id, targetColumn.cardIds.length ); } } } } finally { clearDragState(); } }, [moveCard, moveColumn, clearDragState] ); const [announcement, setAnnouncement] = useState(""); const handleDragEndWithAnnouncement = useCallback( (event: DragEndEvent) => { // Read board BEFORE handleDragEnd potentially modifies it const currentBoard = useBoardStore.getState().board; handleDragEnd(event); const { active, over } = event; if (over && currentBoard) { const type = active.data.current?.type; if (type === "card") { const card = currentBoard.cards[active.id as string]; const targetCol = over.data.current?.type === "column" ? currentBoard.columns.find((c) => c.id === (over.data.current?.columnId ?? over.id)) : findColumnByCardId(currentBoard, over.id as string); if (card && targetCol) { setAnnouncement(`Moved card "${card.title}" to ${targetCol.title}`); } } else if (type === "column") { const col = currentBoard.columns.find((c) => c.id === (active.id as string)); if (col) { setAnnouncement(`Reordered column "${col.title}"`); } } } }, [handleDragEnd] ); if (!board) { return (
Loading board...
); } // 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; const columnIds = board.columns.map((c) => c.id); return (
{/* Visually hidden live region for drag-and-drop announcements */}
{announcement}
{showFilterBar && board && ( { setShowFilterBar(false); setFilters(EMPTY_FILTER); }} boardLabels={board.labels} /> )} {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}
{ setSelectedCardId(null); }} />
); }