feat: Phase 2 card interactions - priority picker, context menu, WIP limits, column collapse, checklist reorder

- PriorityPicker component with 5 colored chips in card detail modal
- Card context menu: Move to column, Set priority, Duplicate, Delete
- duplicateCard store action (clones card, inserts after original)
- Column WIP limits with amber/red indicators when at/over limit
- Column collapse/expand to 40px vertical strip
- Checklist item drag reordering with grip handle
- Comment store actions (addComment, deleteComment) for Phase 3
This commit is contained in:
Your Name
2026-02-16 14:46:20 +02:00
parent b51818ada3
commit a17c8b6b62
7 changed files with 526 additions and 142 deletions

View File

@@ -5,11 +5,22 @@ import { motion, useReducedMotion, AnimatePresence } from "framer-motion";
import { fadeSlideUp, springs } from "@/lib/motion";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { Card, Label } from "@/types/board";
import type { Card, Label, Priority } from "@/types/board";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { LabelDots } from "@/components/board/LabelDots";
import { ChecklistBar } from "@/components/board/ChecklistBar";
import { Paperclip, AlignLeft } from "lucide-react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { useBoardStore } from "@/stores/board-store";
/* ---------- Due date status ---------- */
@@ -97,83 +108,152 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card
}
return (
<motion.button
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
padding: `calc(0.75rem * var(--density-factor))`,
opacity: getAgingOpacity(card.updatedAt),
}}
onClick={handleClick}
className="w-full rounded-lg bg-pylon-surface shadow-sm text-left"
layoutId={`card-${card.id}`}
variants={fadeSlideUp}
initial={prefersReducedMotion ? false : "hidden"}
animate="visible"
whileHover={{ scale: 1.02, y: -2, boxShadow: "0 4px 12px oklch(0% 0 0 / 10%)" }}
whileTap={{ scale: 0.98 }}
transition={springs.bouncy}
layout
{...attributes}
{...listeners}
role="article"
aria-label={card.title}
>
{/* Cover color bar */}
{card.coverColor && (
<div
className="mb-2 h-1 rounded-t-lg"
<ContextMenu>
<ContextMenuTrigger asChild>
<motion.button
ref={setNodeRef}
style={{
backgroundColor: `oklch(55% 0.12 ${card.coverColor})`,
margin: `calc(-0.75rem * var(--density-factor)) calc(-0.75rem * var(--density-factor)) 0.5rem`,
transform: CSS.Transform.toString(transform),
transition,
padding: `calc(0.75rem * var(--density-factor))`,
opacity: getAgingOpacity(card.updatedAt),
}}
/>
)}
{/* 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: priority + due date + checklist + icons */}
{(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description || card.priority !== "none") && (
<div className="mt-2 flex items-center gap-3">
{card.priority !== "none" && (
<span
className={`size-2.5 shrink-0 rounded-full ${card.priority === "urgent" ? "animate-pulse" : ""}`}
style={{ backgroundColor: PRIORITY_COLORS[card.priority] }}
title={`Priority: ${card.priority}`}
onClick={handleClick}
className="w-full rounded-lg bg-pylon-surface shadow-sm text-left"
layoutId={`card-${card.id}`}
variants={fadeSlideUp}
initial={prefersReducedMotion ? false : "hidden"}
animate="visible"
whileHover={{ scale: 1.02, y: -2, boxShadow: "0 4px 12px oklch(0% 0 0 / 10%)" }}
whileTap={{ scale: 0.98 }}
transition={springs.bouncy}
layout
{...attributes}
{...listeners}
role="article"
aria-label={card.title}
>
{/* Cover color bar */}
{card.coverColor && (
<div
className="mb-2 h-1 rounded-t-lg"
style={{
backgroundColor: `oklch(55% 0.12 ${card.coverColor})`,
margin: `calc(-0.75rem * var(--density-factor)) calc(-0.75rem * var(--density-factor)) 0.5rem`,
}}
/>
)}
{dueDateStatus && card.dueDate && (
<span
className={`font-mono text-xs rounded px-1 py-0.5 ${dueDateStatus.color} ${dueDateStatus.bgColor}`}
title={dueDateStatus.label}
>
{format(new Date(card.dueDate), "MMM d")}
</span>
{/* Label dots */}
{card.labels.length > 0 && (
<div className="mb-2">
<LabelDots labelIds={card.labels} boardLabels={boardLabels} />
</div>
)}
{card.checklist.length > 0 && (
<ChecklistBar checklist={card.checklist} />
{/* Card title */}
<p className="text-sm font-medium text-pylon-text">{card.title}</p>
{/* Footer row: priority + due date + checklist + icons */}
{(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description || card.priority !== "none") && (
<div className="mt-2 flex items-center gap-3">
{card.priority !== "none" && (
<span
className={`size-2.5 shrink-0 rounded-full ${card.priority === "urgent" ? "animate-pulse" : ""}`}
style={{ backgroundColor: PRIORITY_COLORS[card.priority] }}
title={`Priority: ${card.priority}`}
/>
)}
{dueDateStatus && card.dueDate && (
<span
className={`font-mono text-xs rounded px-1 py-0.5 ${dueDateStatus.color} ${dueDateStatus.bgColor}`}
title={dueDateStatus.label}
>
{format(new Date(card.dueDate), "MMM d")}
</span>
)}
{card.checklist.length > 0 && (
<ChecklistBar checklist={card.checklist} />
)}
{card.description && (
<DescriptionPreview description={card.description} />
)}
{card.attachments.length > 0 && (
<span className="flex items-center gap-0.5 text-pylon-text-secondary">
<Paperclip className="size-3" />
<span className="font-mono text-xs">{card.attachments.length}</span>
</span>
)}
</div>
)}
{card.description && (
<DescriptionPreview description={card.description} />
)}
{card.attachments.length > 0 && (
<span className="flex items-center gap-0.5 text-pylon-text-secondary">
<Paperclip className="size-3" />
<span className="font-mono text-xs">{card.attachments.length}</span>
</span>
)}
</div>
</motion.button>
</ContextMenuTrigger>
<CardContextMenuContent cardId={card.id} columnId={columnId} />
</ContextMenu>
);
}
/* ---------- Card context menu ---------- */
function CardContextMenuContent({ cardId, columnId }: { cardId: string; columnId: string }) {
const board = useBoardStore((s) => s.board);
const moveCard = useBoardStore((s) => s.moveCard);
const updateCard = useBoardStore((s) => s.updateCard);
const duplicateCard = useBoardStore((s) => s.duplicateCard);
const deleteCard = useBoardStore((s) => s.deleteCard);
if (!board) return null;
const otherColumns = board.columns.filter((c) => c.id !== columnId);
const priorities: { value: Priority; label: string }[] = [
{ value: "none", label: "None" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "urgent", label: "Urgent" },
];
return (
<ContextMenuContent>
{otherColumns.length > 0 && (
<ContextMenuSub>
<ContextMenuSubTrigger>Move to</ContextMenuSubTrigger>
<ContextMenuSubContent>
{otherColumns.map((col) => (
<ContextMenuItem
key={col.id}
onClick={() => moveCard(cardId, columnId, col.id, col.cardIds.length)}
>
{col.title}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
)}
</motion.button>
<ContextMenuSub>
<ContextMenuSubTrigger>Set priority</ContextMenuSubTrigger>
<ContextMenuSubContent>
{priorities.map(({ value, label }) => (
<ContextMenuItem
key={value}
onClick={() => updateCard(cardId, { priority: value })}
>
{label}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem onClick={() => duplicateCard(cardId)}>
Duplicate
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onClick={() => deleteCard(cardId)}
>
Delete
</ContextMenuItem>
</ContextMenuContent>
);
}

View File

@@ -3,8 +3,11 @@ import { MoreHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
@@ -17,7 +20,6 @@ import type { Column, ColumnWidth } from "@/types/board";
interface ColumnHeaderProps {
column: Column;
cardCount: number;
boardColor?: string;
}
const COLOR_PRESETS = [
@@ -33,7 +35,7 @@ const COLOR_PRESETS = [
{ hue: "0", label: "Slate" },
];
export function ColumnHeader({ column, cardCount, boardColor }: ColumnHeaderProps) {
export function ColumnHeader({ column, cardCount }: ColumnHeaderProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(column.title);
const inputRef = useRef<HTMLInputElement>(null);
@@ -42,6 +44,8 @@ export function ColumnHeader({ column, cardCount, boardColor }: ColumnHeaderProp
const deleteColumn = useBoardStore((s) => s.deleteColumn);
const setColumnWidth = useBoardStore((s) => s.setColumnWidth);
const setColumnColor = useBoardStore((s) => s.setColumnColor);
const setColumnWipLimit = useBoardStore((s) => s.setColumnWipLimit);
const toggleColumnCollapse = useBoardStore((s) => s.toggleColumnCollapse);
useEffect(() => {
if (editing && inputRef.current) {
@@ -74,13 +78,7 @@ export function ColumnHeader({ column, cardCount, boardColor }: ColumnHeaderProp
}
return (
<div className="flex items-center justify-between border-b border-border px-3 pb-2 pt-3" style={{
borderTop: column.color
? `3px solid oklch(55% 0.12 ${column.color})`
: boardColor
? `3px solid ${boardColor}30`
: undefined
}}>
<div className="flex items-center justify-between border-b border-border px-3 pb-2 pt-3">
<div className="flex items-center gap-2 overflow-hidden">
{editing ? (
<input
@@ -102,8 +100,14 @@ export function ColumnHeader({ column, cardCount, boardColor }: ColumnHeaderProp
{column.title}
</span>
)}
<span className="shrink-0 font-mono text-xs text-pylon-text-secondary">
{cardCount}
<span className={`shrink-0 font-mono text-xs ${
column.wipLimit != null && cardCount > column.wipLimit
? "text-pylon-danger font-bold"
: column.wipLimit != null && cardCount === column.wipLimit
? "text-[oklch(65%_0.15_70)]"
: "text-pylon-text-secondary"
}`}>
{cardCount}{column.wipLimit != null ? `/${column.wipLimit}` : ""}
</span>
</div>
@@ -126,38 +130,28 @@ export function ColumnHeader({ column, cardCount, boardColor }: ColumnHeaderProp
>
Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={() => toggleColumnCollapse(column.id)}>
Collapse
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Width</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => handleWidthChange("narrow")}>
Narrow
{column.width === "narrow" && (
<span className="ml-auto text-pylon-accent">*</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleWidthChange("standard")}>
Standard
{column.width === "standard" && (
<span className="ml-auto text-pylon-accent">*</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleWidthChange("wide")}>
Wide
{column.width === "wide" && (
<span className="ml-auto text-pylon-accent">*</span>
)}
</DropdownMenuItem>
<DropdownMenuRadioGroup value={column.width} onValueChange={(v) => handleWidthChange(v as ColumnWidth)}>
<DropdownMenuRadioItem value="narrow">Narrow</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="standard">Standard</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="wide">Wide</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Color</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => setColumnColor(column.id, null)}>
<DropdownMenuCheckboxItem
checked={column.color == null}
onSelect={() => setColumnColor(column.id, null)}
>
None
{column.color == null && (
<span className="ml-auto text-pylon-accent">*</span>
)}
</DropdownMenuItem>
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<div className="flex flex-wrap gap-1.5 px-2 py-1.5">
{COLOR_PRESETS.map(({ hue, label }) => (
@@ -176,6 +170,21 @@ export function ColumnHeader({ column, cardCount, boardColor }: ColumnHeaderProp
</div>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>WIP Limit</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={column.wipLimit?.toString() ?? "none"}
onValueChange={(v) => setColumnWipLimit(column.id, v === "none" ? null : parseInt(v))}
>
<DropdownMenuRadioItem value="none">None</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="3">3</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="5">5</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="7">7</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="10">10</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import { Plus, ChevronRight } from "lucide-react";
import { motion, useReducedMotion } from "framer-motion";
import { fadeSlideUp, springs, staggerContainer } from "@/lib/motion";
import { useDroppable } from "@dnd-kit/core";
@@ -9,8 +9,8 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
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";
@@ -26,11 +26,13 @@ const WIDTH_MAP = {
interface KanbanColumnProps {
column: Column;
onCardClick?: (cardId: string) => void;
isNew?: boolean;
}
export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
export function KanbanColumn({ column, onCardClick, isNew }: KanbanColumnProps) {
const [showAddCard, setShowAddCard] = useState(false);
const board = useBoardStore((s) => s.board);
const toggleColumnCollapse = useBoardStore((s) => s.toggleColumnCollapse);
const prefersReducedMotion = useReducedMotion();
const width = WIDTH_MAP[column.width];
@@ -54,31 +56,68 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
data: { type: "column", columnId: column.id },
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
width,
};
const borderTop = column.color
? `3px solid oklch(55% 0.12 ${column.color})`
: board?.color
? `3px solid ${board.color}30`
: undefined;
const cardCount = column.cardIds.length;
const wipTint = column.wipLimit != null
? cardCount > column.wipLimit
? "oklch(70% 0.08 25 / 15%)"
: cardCount === column.wipLimit
? "oklch(75% 0.08 70 / 15%)"
: undefined
: undefined;
return (
<motion.section
<motion.div
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" : ""}`}
variants={fadeSlideUp}
initial={prefersReducedMotion ? false : "hidden"}
animate="visible"
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
}}
animate={{ width: column.collapsed ? 40 : width, opacity: 1 }}
initial={isNew ? { width: 0, opacity: 0 } : false}
exit={{ width: 0, opacity: 0 }}
transition={springs.bouncy}
layout
className="shrink-0 overflow-hidden"
{...attributes}
>
{column.collapsed ? (
<button
onClick={() => toggleColumnCollapse(column.id)}
className="flex h-full w-full flex-col items-center gap-2 rounded-lg bg-pylon-column py-3 hover:bg-pylon-column/80 transition-colors"
style={{ borderTop }}
{...listeners}
>
<ChevronRight className="size-3.5 text-pylon-text-secondary" />
<span
className="font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary"
style={{ writingMode: "vertical-rl", transform: "rotate(180deg)" }}
>
{column.title}
</span>
<span className="mt-auto font-mono text-xs text-pylon-text-secondary">
{cardCount}
</span>
</button>
) : (
<motion.section
className="group/column flex h-full flex-col overflow-hidden rounded-lg bg-pylon-column"
aria-label={`${column.title} column, ${cardCount} card${cardCount !== 1 ? "s" : ""}`}
style={{ borderTop, backgroundColor: wipTint }}
variants={fadeSlideUp}
initial={isNew || prefersReducedMotion ? false : undefined}
animate={isNew ? "visible" : undefined}
transition={springs.bouncy}
>
{/* The column header is the drag handle for column reordering */}
<div {...listeners}>
<ColumnHeader column={column} cardCount={column.cardIds.length} boardColor={board?.color} />
<ColumnHeader column={column} cardCount={column.cardIds.length} />
</div>
{/* Card list - wrapped in SortableContext for within-column sorting */}
@@ -86,7 +125,11 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
items={column.cardIds}
strategy={verticalListSortingStrategy}
>
<ScrollArea className="flex-1 overflow-y-auto">
<OverlayScrollbarsComponent
className="flex-1"
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
defer
>
<motion.ul
ref={setDroppableNodeRef}
className="flex min-h-[40px] list-none flex-col"
@@ -115,7 +158,7 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
</li>
)}
</motion.ul>
</ScrollArea>
</OverlayScrollbarsComponent>
</SortableContext>
{/* Add card section */}
@@ -138,5 +181,7 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
</div>
)}
</motion.section>
)}
</motion.div>
);
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { X } from "lucide-react";
import { useBoardStore } from "@/stores/board-store";
import { MarkdownEditor } from "@/components/card-detail/MarkdownEditor";
@@ -7,6 +8,7 @@ import { ChecklistSection } from "@/components/card-detail/ChecklistSection";
import { LabelPicker } from "@/components/card-detail/LabelPicker";
import { DueDatePicker } from "@/components/card-detail/DueDatePicker";
import { AttachmentSection } from "@/components/card-detail/AttachmentSection";
import { PriorityPicker } from "@/components/card-detail/PriorityPicker";
import { springs, staggerContainer, fadeSlideUp } from "@/lib/motion";
interface CardDetailModalProps {
@@ -80,8 +82,13 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
</div>
{/* Dashboard grid body */}
<OverlayScrollbarsComponent
className="max-h-[calc(85vh-4rem)]"
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
defer
>
<motion.div
className="grid max-h-[calc(85vh-4rem)] grid-cols-2 gap-4 overflow-y-auto p-5"
className="grid grid-cols-2 gap-4 p-5"
variants={staggerContainer(0.05)}
initial="hidden"
animate="visible"
@@ -127,7 +134,15 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
<MarkdownEditor cardId={cardId} value={card.description} />
</motion.div>
{/* Row 3: Cover + Attachments */}
{/* Row 3: Priority + Cover */}
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
<PriorityPicker cardId={cardId} priority={card.priority} />
</motion.div>
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
@@ -139,8 +154,9 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
/>
</motion.div>
{/* Row 4: Attachments (full width) */}
<motion.div
className="rounded-lg bg-pylon-column/50 p-4"
className="col-span-2 rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp}
transition={springs.bouncy}
>
@@ -150,6 +166,7 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
/>
</motion.div>
</motion.div>
</OverlayScrollbarsComponent>
</motion.div>
</div>
</>

View File

@@ -1,5 +1,20 @@
import { useState, useRef } from "react";
import { X } from "lucide-react";
import { GripVertical, X } from "lucide-react";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useBoardStore } from "@/stores/board-store";
import type { ChecklistItem } from "@/types/board";
@@ -13,8 +28,23 @@ export function ChecklistSection({ cardId, checklist }: ChecklistSectionProps) {
const updateChecklistItem = useBoardStore((s) => s.updateChecklistItem);
const deleteChecklistItem = useBoardStore((s) => s.deleteChecklistItem);
const addChecklistItem = useBoardStore((s) => s.addChecklistItem);
const reorderChecklistItems = useBoardStore((s) => s.reorderChecklistItems);
const [newItemText, setNewItemText] = useState("");
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 3 } })
);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = checklist.findIndex((item) => item.id === active.id);
const newIndex = checklist.findIndex((item) => item.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
reorderChecklistItems(cardId, oldIndex, newIndex);
}
}
const inputRef = useRef<HTMLInputElement>(null);
const checked = checklist.filter((item) => item.checked).length;
@@ -60,18 +90,28 @@ export function ChecklistSection({ cardId, checklist }: ChecklistSectionProps) {
</div>
{/* Items */}
<div className="flex max-h-[160px] flex-col gap-1 overflow-y-auto">
{checklist.map((item) => (
<ChecklistRow
key={item.id}
cardId={cardId}
item={item}
onToggle={() => toggleChecklistItem(cardId, item.id)}
onUpdate={(text) => updateChecklistItem(cardId, item.id, text)}
onDelete={() => deleteChecklistItem(cardId, item.id)}
/>
))}
</div>
<OverlayScrollbarsComponent
className="max-h-[160px]"
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
defer
>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={checklist.map((item) => item.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-1">
{checklist.map((item) => (
<ChecklistRow
key={item.id}
cardId={cardId}
item={item}
onToggle={() => toggleChecklistItem(cardId, item.id)}
onUpdate={(text) => updateChecklistItem(cardId, item.id, text)}
onDelete={() => deleteChecklistItem(cardId, item.id)}
/>
))}
</div>
</SortableContext>
</DndContext>
</OverlayScrollbarsComponent>
{/* Add item */}
<div className="flex gap-2">
@@ -100,6 +140,10 @@ function ChecklistRow({ item, onToggle, onUpdate, onDelete }: ChecklistRowProps)
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(item.text);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: item.id,
});
function handleSave() {
const trimmed = draft.trim();
if (trimmed && trimmed !== item.text) {
@@ -121,7 +165,22 @@ function ChecklistRow({ item, onToggle, onUpdate, onDelete }: ChecklistRowProps)
}
return (
<div className="group/item flex items-center gap-2 rounded px-1 py-0.5 hover:bg-pylon-column/60">
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
}}
className="group/item flex items-center gap-2 rounded px-1 py-0.5 hover:bg-pylon-column/60"
{...attributes}
>
<span
className="shrink-0 cursor-grab text-pylon-text-secondary opacity-0 group-hover/item:opacity-100"
{...listeners}
>
<GripVertical className="size-3" />
</span>
<input
type="checkbox"
checked={item.checked}

View File

@@ -0,0 +1,46 @@
import { useBoardStore } from "@/stores/board-store";
import type { Priority } from "@/types/board";
const PRIORITIES: { value: Priority; label: string; color: string }[] = [
{ value: "none", label: "None", color: "oklch(50% 0 0 / 30%)" },
{ value: "low", label: "Low", color: "oklch(60% 0.15 240)" },
{ value: "medium", label: "Medium", color: "oklch(70% 0.15 85)" },
{ value: "high", label: "High", color: "oklch(60% 0.15 55)" },
{ value: "urgent", label: "Urgent", color: "oklch(55% 0.15 25)" },
];
interface PriorityPickerProps {
cardId: string;
priority: Priority;
}
export function PriorityPicker({ cardId, priority }: PriorityPickerProps) {
const updateCard = useBoardStore((s) => s.updateCard);
return (
<div className="flex flex-col gap-2">
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Priority
</h4>
<div className="flex flex-wrap gap-1.5">
{PRIORITIES.map(({ value, label, color }) => (
<button
key={value}
onClick={() => updateCard(cardId, { priority: value })}
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
priority === value
? "text-white shadow-sm"
: "text-pylon-text-secondary hover:text-pylon-text"
}`}
style={{
backgroundColor: priority === value ? color : undefined,
border: priority !== value ? `1px solid ${color}` : `1px solid transparent`,
}}
>
{label}
</button>
))}
</div>
</div>
);
}

View File

@@ -28,10 +28,13 @@ interface BoardActions {
moveColumn: (fromIndex: number, toIndex: number) => void;
setColumnWidth: (columnId: string, width: ColumnWidth) => void;
setColumnColor: (columnId: string, color: string | null) => void;
setColumnWipLimit: (columnId: string, limit: number | null) => void;
toggleColumnCollapse: (columnId: string) => void;
addCard: (columnId: string, title: string) => string;
updateCard: (cardId: string, updates: Partial<Card>) => void;
deleteCard: (cardId: string) => void;
duplicateCard: (cardId: string) => string | null;
moveCard: (
cardId: string,
fromColumnId: string,
@@ -48,10 +51,14 @@ interface BoardActions {
toggleChecklistItem: (cardId: string, itemId: string) => void;
updateChecklistItem: (cardId: string, itemId: string, text: string) => void;
deleteChecklistItem: (cardId: string, itemId: string) => void;
reorderChecklistItems: (cardId: string, fromIndex: number, toIndex: number) => void;
addAttachment: (cardId: string, attachment: Omit<Attachment, "id">) => void;
removeAttachment: (cardId: string, attachmentId: string) => void;
addComment: (cardId: string, text: string) => void;
deleteComment: (cardId: string, commentId: string) => void;
updateBoardTitle: (title: string) => void;
updateBoardColor: (color: string) => void;
updateBoardSettings: (settings: Board["settings"]) => void;
@@ -195,6 +202,26 @@ export const useBoardStore = create<BoardState & BoardActions>()(
}));
},
setColumnWipLimit: (columnId, limit) => {
mutate(get, set, (b) => ({
...b,
updatedAt: now(),
columns: b.columns.map((c) =>
c.id === columnId ? { ...c, wipLimit: limit } : c
),
}));
},
toggleColumnCollapse: (columnId) => {
mutate(get, set, (b) => ({
...b,
updatedAt: now(),
columns: b.columns.map((c) =>
c.id === columnId ? { ...c, collapsed: !c.collapsed } : c
),
}));
},
// -- Card actions --
addCard: (columnId, title) => {
@@ -260,6 +287,48 @@ export const useBoardStore = create<BoardState & BoardActions>()(
});
},
duplicateCard: (cardId) => {
const { board } = get();
if (!board) return null;
const original = board.cards[cardId];
if (!original) return null;
const column = board.columns.find((c) => c.cardIds.includes(cardId));
if (!column) return null;
const newId = ulid();
const ts = now();
const clone: Card = {
...original,
id: newId,
title: `${original.title} (copy)`,
comments: [],
createdAt: ts,
updatedAt: ts,
};
const insertIndex = column.cardIds.indexOf(cardId) + 1;
mutate(get, set, (b) => ({
...b,
updatedAt: ts,
cards: { ...b.cards, [newId]: clone },
columns: b.columns.map((c) =>
c.id === column.id
? {
...c,
cardIds: [
...c.cardIds.slice(0, insertIndex),
newId,
...c.cardIds.slice(insertIndex),
],
}
: c
),
}));
return newId;
},
moveCard: (cardId, fromColumnId, toColumnId, toIndex) => {
mutate(get, set, (b) => ({
...b,
@@ -428,6 +497,24 @@ export const useBoardStore = create<BoardState & BoardActions>()(
});
},
reorderChecklistItems: (cardId, fromIndex, toIndex) => {
mutate(get, set, (b) => {
const card = b.cards[cardId];
if (!card) return b;
const items = [...card.checklist];
const [moved] = items.splice(fromIndex, 1);
items.splice(toIndex, 0, moved);
return {
...b,
updatedAt: now(),
cards: {
...b.cards,
[cardId]: { ...card, checklist: items, updatedAt: now() },
},
};
});
},
// -- Attachment actions --
addAttachment: (cardId, attachment) => {
@@ -473,6 +560,47 @@ export const useBoardStore = create<BoardState & BoardActions>()(
});
},
// -- Comment actions --
addComment: (cardId, text) => {
mutate(get, set, (b) => {
const card = b.cards[cardId];
if (!card) return b;
const comment = { id: ulid(), text, createdAt: now() };
return {
...b,
updatedAt: now(),
cards: {
...b.cards,
[cardId]: {
...card,
comments: [comment, ...card.comments],
updatedAt: now(),
},
},
};
});
},
deleteComment: (cardId, commentId) => {
mutate(get, set, (b) => {
const card = b.cards[cardId];
if (!card) return b;
return {
...b,
updatedAt: now(),
cards: {
...b.cards,
[cardId]: {
...card,
comments: card.comments.filter((c) => c.id !== commentId),
updatedAt: now(),
},
},
};
});
},
// -- Board metadata --
updateBoardTitle: (title) => {