Files
openpylon/src/components/board/KanbanColumn.tsx

133 lines
4.2 KiB
TypeScript

import { useState } from "react";
import { Plus } from "lucide-react";
import { motion, useReducedMotion } from "framer-motion";
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 { ScrollArea } from "@/components/ui/scroll-area";
import { ColumnHeader } from "@/components/board/ColumnHeader";
import { AddCardInput } from "@/components/board/AddCardInput";
import { CardThumbnail } from "@/components/board/CardThumbnail";
import { useBoardStore } from "@/stores/board-store";
import type { Column } from "@/types/board";
const WIDTH_MAP = {
narrow: 180,
standard: 280,
wide: 360,
} as const;
interface KanbanColumnProps {
column: Column;
onCardClick?: (cardId: string) => void;
}
export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
const [showAddCard, setShowAddCard] = useState(false);
const board = useBoardStore((s) => s.board);
const prefersReducedMotion = useReducedMotion();
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,
};
const cardCount = column.cardIds.length;
return (
<motion.section
ref={setSortableNodeRef}
style={style}
className="group/column flex shrink-0 flex-col rounded-lg bg-pylon-column"
aria-label={`${column.title} column, ${cardCount} card${cardCount !== 1 ? "s" : ""}`}
initial={prefersReducedMotion ? false : { opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
{...attributes}
>
{/* The column header is the drag handle for column reordering */}
<div {...listeners}>
<ColumnHeader column={column} cardCount={column.cardIds.length} boardColor={board?.color} />
</div>
{/* Card list - wrapped in SortableContext for within-column sorting */}
<SortableContext
items={column.cardIds}
strategy={verticalListSortingStrategy}
>
<ScrollArea className="flex-1 overflow-y-auto">
<ul ref={setDroppableNodeRef} className="flex min-h-[40px] list-none flex-col" style={{ gap: `calc(0.5rem * var(--density-factor))`, padding: `calc(0.5rem * var(--density-factor))` }}>
{column.cardIds.map((cardId) => {
const card = board?.cards[cardId];
if (!card) return null;
return (
<li key={cardId}>
<CardThumbnail
card={card}
boardLabels={board?.labels ?? []}
columnId={column.id}
onCardClick={onCardClick}
/>
</li>
);
})}
{column.cardIds.length === 0 && (
<li className="flex min-h-[60px] items-center justify-center rounded-md border border-dashed border-pylon-text-secondary/20 text-xs text-pylon-text-secondary/50">
Drop or add a card
</li>
)}
</ul>
</ScrollArea>
</SortableContext>
{/* Add card section */}
{showAddCard ? (
<AddCardInput
columnId={column.id}
onClose={() => setShowAddCard(false)}
/>
) : (
<div className="p-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowAddCard(true)}
className="w-full justify-start text-pylon-text-secondary hover:text-pylon-text"
>
<Plus className="size-4" />
Add card
</Button>
</div>
)}
</motion.section>
);
}