517 lines
18 KiB
TypeScript
517 lines
18 KiB
TypeScript
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<string | null>(null);
|
|
const [showFilterBar, setShowFilterBar] = useState(false);
|
|
const [filters, setFilters] = useState<FilterState>(EMPTY_FILTER);
|
|
const { focusedCardId, setFocusedCardId } = useKeyboardNavigation(board, handleCardClick);
|
|
const [addingColumn, setAddingColumn] = useState(false);
|
|
const [newColumnTitle, setNewColumnTitle] = useState("");
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
|
|
|
|
// Track columns that existed on initial render (for stagger vs instant appearance)
|
|
const initialColumnIds = useRef<Set<string> | 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<string | null>(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<number>(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<typeof findColumnByCardId>;
|
|
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 (
|
|
<div className="flex h-full items-center justify-center text-pylon-text-secondary">
|
|
<span className="font-mono text-sm">Loading board...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<div className="flex h-full flex-col">
|
|
{/* Visually hidden live region for drag-and-drop announcements */}
|
|
<div
|
|
aria-live="polite"
|
|
aria-atomic="true"
|
|
className="sr-only"
|
|
>
|
|
{announcement}
|
|
</div>
|
|
<AnimatePresence>
|
|
{showFilterBar && board && (
|
|
<FilterBar
|
|
filters={filters}
|
|
onChange={setFilters}
|
|
onClose={() => { setShowFilterBar(false); setFilters(EMPTY_FILTER); }}
|
|
boardLabels={board.labels}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCorners}
|
|
onDragStart={handleDragStart}
|
|
onDragOver={handleDragOver}
|
|
onDragEnd={handleDragEndWithAnnouncement}
|
|
onDragCancel={clearDragState}
|
|
>
|
|
<SortableContext
|
|
items={columnIds}
|
|
strategy={horizontalListSortingStrategy}
|
|
>
|
|
<OverlayScrollbarsComponent
|
|
ref={osRef}
|
|
className="min-h-0 flex-1"
|
|
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { y: "hidden" } }}
|
|
defer
|
|
>
|
|
<motion.div
|
|
className="flex h-full"
|
|
style={{ gap: `calc(1.5rem * var(--density-factor))`, padding: `calc(1.5rem * var(--density-factor))`, ...getBoardBackground(board) }}
|
|
variants={staggerContainer(0.06)}
|
|
initial="hidden"
|
|
animate="visible"
|
|
>
|
|
<AnimatePresence>
|
|
{board.columns.map((column) => (
|
|
<KanbanColumn
|
|
key={column.id}
|
|
column={column}
|
|
filteredCardIds={isFilterActive(filters) ? filterCards(column.cardIds) : undefined}
|
|
focusedCardId={focusedCardId}
|
|
onCardClick={handleCardClick}
|
|
isNew={!initialColumnIds.current?.has(column.id)}
|
|
/>
|
|
))}
|
|
</AnimatePresence>
|
|
|
|
{/* Add column button / inline input */}
|
|
<div className="shrink-0">
|
|
{addingColumn ? (
|
|
<div className="flex w-[280px] flex-col gap-2 rounded-lg bg-pylon-column p-3">
|
|
<input
|
|
ref={inputRef}
|
|
value={newColumnTitle}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button size="sm" onClick={handleAddColumn}>
|
|
Add
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setAddingColumn(false);
|
|
setNewColumnTitle("");
|
|
}}
|
|
className="text-pylon-text-secondary"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setAddingColumn(true)}
|
|
className="h-10 w-[280px] justify-start border border-dashed border-pylon-text-secondary/30 text-pylon-text-secondary hover:border-pylon-text-secondary/60 hover:text-pylon-text"
|
|
>
|
|
<Plus className="size-4" />
|
|
Add column
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</OverlayScrollbarsComponent>
|
|
</SortableContext>
|
|
|
|
{/* Drag overlay - renders a styled copy of the dragged item */}
|
|
<DragOverlay dropAnimation={null}>
|
|
<AnimatePresence>
|
|
{activeCard ? (
|
|
<CardOverlay key="card-overlay" card={activeCard} boardLabels={board.labels} />
|
|
) : activeColumn ? (
|
|
<ColumnOverlay key="column-overlay" column={activeColumn} />
|
|
) : null}
|
|
</AnimatePresence>
|
|
</DragOverlay>
|
|
</DndContext>
|
|
|
|
<CardDetailModal
|
|
cardId={selectedCardId}
|
|
onClose={() => { setSelectedCardId(null); }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|