Files
openpylon/src/components/board/BoardView.tsx
Your Name 2a81849c8d fix: move hooks before early return in BoardView, remove unused attachmentMode prop
Fixed React hooks rules violation where useState and useCallback were
called after a conditional return in BoardView. Removed unused
attachmentMode prop from AttachmentSection (can be re-added when file
dialog is wired up).
2026-02-15 19:30:58 +02:00

374 lines
12 KiB
TypeScript

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 { CardDetailModal } from "@/components/card-detail/CardDetailModal";
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 [selectedCardId, setSelectedCardId] = useState<string | null>(null);
const [addingColumn, setAddingColumn] = useState(false);
const [newColumnTitle, setNewColumnTitle] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
// 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();
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") {
handleAddColumn();
} else if (e.key === "Escape") {
setAddingColumn(false);
setNewColumnTitle("");
}
}
// --- 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<typeof findColumnByCardId>;
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]
);
const [announcement, setAnnouncement] = useState("");
const handleDragEndWithAnnouncement = useCallback(
(event: DragEndEvent) => {
handleDragEnd(event);
const { active, over } = event;
if (over && board) {
const type = active.data.current?.type;
if (type === "card") {
const card = board.cards[active.id as string];
const targetCol = over.data.current?.type === "column"
? board.columns.find((c) => c.id === (over.data.current?.columnId ?? over.id))
: findColumnByCardId(board, over.id as string);
if (card && targetCol) {
setAnnouncement(`Moved card "${card.title}" to ${targetCol.title}`);
}
} else if (type === "column") {
const col = board.columns.find((c) => c.id === (active.id as string));
if (col) {
setAnnouncement(`Reordered column "${col.title}"`);
}
}
}
},
[handleDragEnd, board]
);
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 (
<>
{/* Visually hidden live region for drag-and-drop announcements */}
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement}
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEndWithAnnouncement}
>
<SortableContext
items={columnIds}
strategy={horizontalListSortingStrategy}
>
<div className="flex h-full gap-6 overflow-x-auto p-6">
{board.columns.map((column) => (
<KanbanColumn
key={column.id}
column={column}
onCardClick={setSelectedCardId}
/>
))}
{/* 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>
</div>
</SortableContext>
{/* Drag overlay - renders a styled copy of the dragged item */}
<DragOverlay>
{activeCard ? (
<CardOverlay card={activeCard} boardLabels={board.labels} />
) : activeColumn ? (
<ColumnOverlay column={activeColumn} />
) : null}
</DragOverlay>
</DndContext>
<CardDetailModal
cardId={selectedCardId}
onClose={() => setSelectedCardId(null)}
/>
</>
);
}