feat: add drag-and-drop for cards and columns with keyboard support
This commit is contained in:
@@ -1,17 +1,56 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { Plus } from "lucide-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 { Button } from "@/components/ui/button";
|
||||||
import { useBoardStore } from "@/stores/board-store";
|
import { useBoardStore } from "@/stores/board-store";
|
||||||
import { KanbanColumn } from "@/components/board/KanbanColumn";
|
import { KanbanColumn } from "@/components/board/KanbanColumn";
|
||||||
|
import {
|
||||||
|
CardOverlay,
|
||||||
|
ColumnOverlay,
|
||||||
|
} from "@/components/board/DragOverlayContent";
|
||||||
|
import type { Board } from "@/types/board";
|
||||||
|
|
||||||
|
function findColumnByCardId(board: Board, cardId: string) {
|
||||||
|
return board.columns.find((col) => col.cardIds.includes(cardId));
|
||||||
|
}
|
||||||
|
|
||||||
export function BoardView() {
|
export function BoardView() {
|
||||||
const board = useBoardStore((s) => s.board);
|
const board = useBoardStore((s) => s.board);
|
||||||
const addColumn = useBoardStore((s) => s.addColumn);
|
const addColumn = useBoardStore((s) => s.addColumn);
|
||||||
|
const moveCard = useBoardStore((s) => s.moveCard);
|
||||||
|
const moveColumn = useBoardStore((s) => s.moveColumn);
|
||||||
|
|
||||||
const [addingColumn, setAddingColumn] = useState(false);
|
const [addingColumn, setAddingColumn] = useState(false);
|
||||||
const [newColumnTitle, setNewColumnTitle] = useState("");
|
const [newColumnTitle, setNewColumnTitle] = useState("");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (addingColumn && inputRef.current) {
|
if (addingColumn && inputRef.current) {
|
||||||
inputRef.current.focus();
|
inputRef.current.focus();
|
||||||
@@ -23,7 +62,6 @@ export function BoardView() {
|
|||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
addColumn(trimmed);
|
addColumn(trimmed);
|
||||||
setNewColumnTitle("");
|
setNewColumnTitle("");
|
||||||
// Keep the input open for quick successive adds
|
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +75,145 @@ export function BoardView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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]
|
||||||
|
);
|
||||||
|
|
||||||
if (!board) {
|
if (!board) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-pylon-text-secondary">
|
<div className="flex h-full items-center justify-center text-pylon-text-secondary">
|
||||||
@@ -45,7 +222,28 @@ export function BoardView() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCorners}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={columnIds}
|
||||||
|
strategy={horizontalListSortingStrategy}
|
||||||
|
>
|
||||||
<div className="flex h-full gap-6 overflow-x-auto p-6">
|
<div className="flex h-full gap-6 overflow-x-auto p-6">
|
||||||
{board.columns.map((column) => (
|
{board.columns.map((column) => (
|
||||||
<KanbanColumn key={column.id} column={column} />
|
<KanbanColumn key={column.id} column={column} />
|
||||||
@@ -98,5 +296,16 @@ export function BoardView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { format, isPast, isToday } from "date-fns";
|
import { format, isPast, isToday } from "date-fns";
|
||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import type { Card, Label } from "@/types/board";
|
import type { Card, Label } from "@/types/board";
|
||||||
import { LabelDots } from "@/components/board/LabelDots";
|
import { LabelDots } from "@/components/board/LabelDots";
|
||||||
import { ChecklistBar } from "@/components/board/ChecklistBar";
|
import { ChecklistBar } from "@/components/board/ChecklistBar";
|
||||||
@@ -6,9 +8,28 @@ import { ChecklistBar } from "@/components/board/ChecklistBar";
|
|||||||
interface CardThumbnailProps {
|
interface CardThumbnailProps {
|
||||||
card: Card;
|
card: Card;
|
||||||
boardLabels: Label[];
|
boardLabels: Label[];
|
||||||
|
columnId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardThumbnail({ card, boardLabels }: CardThumbnailProps) {
|
export function CardThumbnail({ card, boardLabels, columnId }: CardThumbnailProps) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id: card.id,
|
||||||
|
data: { type: "card", columnId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.3 : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
const hasDueDate = card.dueDate != null;
|
const hasDueDate = card.dueDate != null;
|
||||||
const dueDate = hasDueDate ? new Date(card.dueDate!) : null;
|
const dueDate = hasDueDate ? new Date(card.dueDate!) : null;
|
||||||
const overdue = dueDate != null && isPast(dueDate) && !isToday(dueDate);
|
const overdue = dueDate != null && isPast(dueDate) && !isToday(dueDate);
|
||||||
@@ -20,8 +41,12 @@ export function CardThumbnail({ card, boardLabels }: CardThumbnailProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className="w-full rounded-lg bg-pylon-surface p-3 shadow-sm text-left transition-all duration-200 hover:-translate-y-px hover:shadow-md"
|
className="w-full rounded-lg bg-pylon-surface p-3 shadow-sm text-left transition-all duration-200 hover:-translate-y-px hover:shadow-md"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
>
|
>
|
||||||
{/* Label dots */}
|
{/* Label dots */}
|
||||||
{card.labels.length > 0 && (
|
{card.labels.length > 0 && (
|
||||||
|
|||||||
81
src/components/board/DragOverlayContent.tsx
Normal file
81
src/components/board/DragOverlayContent.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { Card, Column, Label } from "@/types/board";
|
||||||
|
import { LabelDots } from "@/components/board/LabelDots";
|
||||||
|
import { ChecklistBar } from "@/components/board/ChecklistBar";
|
||||||
|
import { format, isPast, isToday } from "date-fns";
|
||||||
|
|
||||||
|
interface CardOverlayProps {
|
||||||
|
card: Card;
|
||||||
|
boardLabels: Label[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardOverlay({ card, boardLabels }: CardOverlayProps) {
|
||||||
|
const hasDueDate = card.dueDate != null;
|
||||||
|
const dueDate = hasDueDate ? new Date(card.dueDate!) : null;
|
||||||
|
const overdue = dueDate != null && isPast(dueDate) && !isToday(dueDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[260px] rotate-2 scale-[1.03] rounded-lg bg-pylon-surface p-3 opacity-90 shadow-xl">
|
||||||
|
{/* Label dots */}
|
||||||
|
{card.labels.length > 0 && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<LabelDots labelIds={card.labels} boardLabels={boardLabels} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Card title */}
|
||||||
|
<p className="text-sm font-medium text-pylon-text">{card.title}</p>
|
||||||
|
|
||||||
|
{/* Footer row: due date + checklist */}
|
||||||
|
{(hasDueDate || card.checklist.length > 0) && (
|
||||||
|
<div className="mt-2 flex items-center gap-3">
|
||||||
|
{dueDate && (
|
||||||
|
<span
|
||||||
|
className={`font-mono text-xs ${
|
||||||
|
overdue
|
||||||
|
? "rounded bg-pylon-danger/10 px-1 py-0.5 text-pylon-danger"
|
||||||
|
: "text-pylon-text-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{format(dueDate, "MMM d")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{card.checklist.length > 0 && (
|
||||||
|
<ChecklistBar checklist={card.checklist} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnOverlayProps {
|
||||||
|
column: Column;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColumnOverlay({ column }: ColumnOverlayProps) {
|
||||||
|
return (
|
||||||
|
<div className="w-[280px] rotate-1 scale-[1.02] rounded-lg bg-pylon-column p-3 opacity-90 shadow-xl">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border pb-2">
|
||||||
|
<span className="truncate font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary">
|
||||||
|
{column.title}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 font-mono text-xs text-pylon-text-secondary">
|
||||||
|
{column.cardIds.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{column.cardIds.slice(0, 3).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-6 rounded bg-pylon-surface/50"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{column.cardIds.length > 3 && (
|
||||||
|
<p className="text-center font-mono text-xs text-pylon-text-secondary">
|
||||||
|
+{column.cardIds.length - 3} more
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
import { useDroppable } from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { ColumnHeader } from "@/components/board/ColumnHeader";
|
import { ColumnHeader } from "@/components/board/ColumnHeader";
|
||||||
@@ -24,16 +31,51 @@ export function KanbanColumn({ column }: KanbanColumnProps) {
|
|||||||
|
|
||||||
const width = WIDTH_MAP[column.width];
|
const width = WIDTH_MAP[column.width];
|
||||||
|
|
||||||
|
// Make the column itself sortable (for column reordering)
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef: setSortableNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id: column.id,
|
||||||
|
data: { type: "column" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make the column a droppable target so empty columns can receive cards
|
||||||
|
const { setNodeRef: setDroppableNodeRef } = useDroppable({
|
||||||
|
id: `column-droppable-${column.id}`,
|
||||||
|
data: { type: "column", columnId: column.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : undefined,
|
||||||
|
width,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={setSortableNodeRef}
|
||||||
|
style={style}
|
||||||
className="group/column flex shrink-0 flex-col rounded-lg bg-pylon-column"
|
className="group/column flex shrink-0 flex-col rounded-lg bg-pylon-column"
|
||||||
style={{ width }}
|
{...attributes}
|
||||||
>
|
>
|
||||||
|
{/* The column header is the drag handle for column reordering */}
|
||||||
|
<div {...listeners}>
|
||||||
<ColumnHeader column={column} cardCount={column.cardIds.length} />
|
<ColumnHeader column={column} cardCount={column.cardIds.length} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Card list */}
|
{/* Card list - wrapped in SortableContext for within-column sorting */}
|
||||||
|
<SortableContext
|
||||||
|
items={column.cardIds}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
<ScrollArea className="flex-1 overflow-y-auto">
|
<ScrollArea className="flex-1 overflow-y-auto">
|
||||||
<div className="flex flex-col gap-2 p-2">
|
<div ref={setDroppableNodeRef} className="flex min-h-[40px] flex-col gap-2 p-2">
|
||||||
{column.cardIds.map((cardId) => {
|
{column.cardIds.map((cardId) => {
|
||||||
const card = board?.cards[cardId];
|
const card = board?.cards[cardId];
|
||||||
if (!card) return null;
|
if (!card) return null;
|
||||||
@@ -42,11 +84,13 @@ export function KanbanColumn({ column }: KanbanColumnProps) {
|
|||||||
key={cardId}
|
key={cardId}
|
||||||
card={card}
|
card={card}
|
||||||
boardLabels={board?.labels ?? []}
|
boardLabels={board?.labels ?? []}
|
||||||
|
columnId={column.id}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
{/* Add card section */}
|
{/* Add card section */}
|
||||||
{showAddCard ? (
|
{showAddCard ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user