From e535177914f5c2a080ecd51ba65ae4d1996460d8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 16 Feb 2026 14:46:20 +0200 Subject: [PATCH] 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 --- src/components/board/CardThumbnail.tsx | 222 ++++++++++++------ src/components/board/ColumnHeader.tsx | 77 +++--- src/components/board/KanbanColumn.tsx | 85 +++++-- .../card-detail/CardDetailModal.tsx | 23 +- .../card-detail/ChecklistSection.tsx | 87 +++++-- src/components/card-detail/PriorityPicker.tsx | 46 ++++ src/stores/board-store.ts | 128 ++++++++++ 7 files changed, 526 insertions(+), 142 deletions(-) create mode 100644 src/components/card-detail/PriorityPicker.tsx diff --git a/src/components/board/CardThumbnail.tsx b/src/components/board/CardThumbnail.tsx index 1d03873..907409e 100644 --- a/src/components/board/CardThumbnail.tsx +++ b/src/components/board/CardThumbnail.tsx @@ -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 ( - - {/* Cover color bar */} - {card.coverColor && ( -
+ + - )} - - {/* Label dots */} - {card.labels.length > 0 && ( -
- -
- )} - - {/* Card title */} -

{card.title}

- - {/* Footer row: priority + due date + checklist + icons */} - {(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description || card.priority !== "none") && ( -
- {card.priority !== "none" && ( - + {/* Cover color bar */} + {card.coverColor && ( +
)} - {dueDateStatus && card.dueDate && ( - - {format(new Date(card.dueDate), "MMM d")} - + + {/* Label dots */} + {card.labels.length > 0 && ( +
+ +
)} - {card.checklist.length > 0 && ( - + + {/* Card title */} +

{card.title}

+ + {/* Footer row: priority + due date + checklist + icons */} + {(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description || card.priority !== "none") && ( +
+ {card.priority !== "none" && ( + + )} + {dueDateStatus && card.dueDate && ( + + {format(new Date(card.dueDate), "MMM d")} + + )} + {card.checklist.length > 0 && ( + + )} + {card.description && ( + + )} + {card.attachments.length > 0 && ( + + + {card.attachments.length} + + )} +
)} - {card.description && ( - - )} - {card.attachments.length > 0 && ( - - - {card.attachments.length} - - )} -
+ + + + + ); +} + +/* ---------- 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 ( + + {otherColumns.length > 0 && ( + + Move to + + {otherColumns.map((col) => ( + moveCard(cardId, columnId, col.id, col.cardIds.length)} + > + {col.title} + + ))} + + )} - + + Set priority + + {priorities.map(({ value, label }) => ( + updateCard(cardId, { priority: value })} + > + {label} + + ))} + + + duplicateCard(cardId)}> + Duplicate + + + deleteCard(cardId)} + > + Delete + + ); } diff --git a/src/components/board/ColumnHeader.tsx b/src/components/board/ColumnHeader.tsx index f79c660..a81a8a3 100644 --- a/src/components/board/ColumnHeader.tsx +++ b/src/components/board/ColumnHeader.tsx @@ -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(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 ( -
+
{editing ? ( )} - - {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}` : ""}
@@ -126,38 +130,28 @@ export function ColumnHeader({ column, cardCount, boardColor }: ColumnHeaderProp > Rename + toggleColumnCollapse(column.id)}> + Collapse + Width - handleWidthChange("narrow")}> - Narrow - {column.width === "narrow" && ( - * - )} - - handleWidthChange("standard")}> - Standard - {column.width === "standard" && ( - * - )} - - handleWidthChange("wide")}> - Wide - {column.width === "wide" && ( - * - )} - + handleWidthChange(v as ColumnWidth)}> + Narrow + Standard + Wide + Color - setColumnColor(column.id, null)}> + setColumnColor(column.id, null)} + > None - {column.color == null && ( - * - )} - +
{COLOR_PRESETS.map(({ hue, label }) => ( @@ -176,6 +170,21 @@ export function ColumnHeader({ column, cardCount, boardColor }: ColumnHeaderProp
+ + WIP Limit + + setColumnWipLimit(column.id, v === "none" ? null : parseInt(v))} + > + None + 3 + 5 + 7 + 10 + + + 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 ( - + {column.collapsed ? ( + + ) : ( + {/* The column header is the drag handle for column reordering */}
- +
{/* Card list - wrapped in SortableContext for within-column sorting */} @@ -86,7 +125,11 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) { items={column.cardIds} strategy={verticalListSortingStrategy} > - + )} - + {/* Add card section */} @@ -138,5 +181,7 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
)} + )} + ); } diff --git a/src/components/card-detail/CardDetailModal.tsx b/src/components/card-detail/CardDetailModal.tsx index 77d914b..ca18d70 100644 --- a/src/components/card-detail/CardDetailModal.tsx +++ b/src/components/card-detail/CardDetailModal.tsx @@ -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) {
{/* Dashboard grid body */} + - {/* Row 3: Cover + Attachments */} + {/* Row 3: Priority + Cover */} + + + + + {/* Row 4: Attachments (full width) */} @@ -150,6 +166,7 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) { /> +
diff --git a/src/components/card-detail/ChecklistSection.tsx b/src/components/card-detail/ChecklistSection.tsx index d03265f..10531bc 100644 --- a/src/components/card-detail/ChecklistSection.tsx +++ b/src/components/card-detail/ChecklistSection.tsx @@ -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(null); const checked = checklist.filter((item) => item.checked).length; @@ -60,18 +90,28 @@ export function ChecklistSection({ cardId, checklist }: ChecklistSectionProps) {
{/* Items */} -
- {checklist.map((item) => ( - toggleChecklistItem(cardId, item.id)} - onUpdate={(text) => updateChecklistItem(cardId, item.id, text)} - onDelete={() => deleteChecklistItem(cardId, item.id)} - /> - ))} -
+ + + item.id)} strategy={verticalListSortingStrategy}> +
+ {checklist.map((item) => ( + toggleChecklistItem(cardId, item.id)} + onUpdate={(text) => updateChecklistItem(cardId, item.id, text)} + onDelete={() => deleteChecklistItem(cardId, item.id)} + /> + ))} +
+
+
+
{/* Add item */}
@@ -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 ( -
+
+ + + s.updateCard); + + return ( +
+

+ Priority +

+
+ {PRIORITIES.map(({ value, label, color }) => ( + + ))} +
+
+ ); +} diff --git a/src/stores/board-store.ts b/src/stores/board-store.ts index 664ec86..84488cf 100644 --- a/src/stores/board-store.ts +++ b/src/stores/board-store.ts @@ -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) => 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) => 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()( })); }, + 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()( }); }, + 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()( }); }, + 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()( }); }, + // -- 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) => {