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 { fadeSlideUp, springs } from "@/lib/motion";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; 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 { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { LabelDots } from "@/components/board/LabelDots"; import { LabelDots } from "@/components/board/LabelDots";
import { ChecklistBar } from "@/components/board/ChecklistBar"; import { ChecklistBar } from "@/components/board/ChecklistBar";
import { Paperclip, AlignLeft } from "lucide-react"; 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 ---------- */ /* ---------- Due date status ---------- */
@@ -97,83 +108,152 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card
} }
return ( return (
<motion.button <ContextMenu>
ref={setNodeRef} <ContextMenuTrigger asChild>
style={{ <motion.button
transform: CSS.Transform.toString(transform), ref={setNodeRef}
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"
style={{ style={{
backgroundColor: `oklch(55% 0.12 ${card.coverColor})`, transform: CSS.Transform.toString(transform),
margin: `calc(-0.75rem * var(--density-factor)) calc(-0.75rem * var(--density-factor)) 0.5rem`, 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}`}
{/* Label dots */} variants={fadeSlideUp}
{card.labels.length > 0 && ( initial={prefersReducedMotion ? false : "hidden"}
<div className="mb-2"> animate="visible"
<LabelDots labelIds={card.labels} boardLabels={boardLabels} /> whileHover={{ scale: 1.02, y: -2, boxShadow: "0 4px 12px oklch(0% 0 0 / 10%)" }}
</div> whileTap={{ scale: 0.98 }}
)} transition={springs.bouncy}
layout
{/* Card title */} {...attributes}
<p className="text-sm font-medium text-pylon-text">{card.title}</p> {...listeners}
role="article"
{/* Footer row: priority + due date + checklist + icons */} aria-label={card.title}
{(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description || card.priority !== "none") && ( >
<div className="mt-2 flex items-center gap-3"> {/* Cover color bar */}
{card.priority !== "none" && ( {card.coverColor && (
<span <div
className={`size-2.5 shrink-0 rounded-full ${card.priority === "urgent" ? "animate-pulse" : ""}`} className="mb-2 h-1 rounded-t-lg"
style={{ backgroundColor: PRIORITY_COLORS[card.priority] }} style={{
title={`Priority: ${card.priority}`} 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 {/* Label dots */}
className={`font-mono text-xs rounded px-1 py-0.5 ${dueDateStatus.color} ${dueDateStatus.bgColor}`} {card.labels.length > 0 && (
title={dueDateStatus.label} <div className="mb-2">
> <LabelDots labelIds={card.labels} boardLabels={boardLabels} />
{format(new Date(card.dueDate), "MMM d")} </div>
</span>
)} )}
{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 && ( </motion.button>
<DescriptionPreview description={card.description} /> </ContextMenuTrigger>
)} <CardContextMenuContent cardId={card.id} columnId={columnId} />
{card.attachments.length > 0 && ( </ContextMenu>
<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> /* ---------- Card context menu ---------- */
)}
</div> 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 { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubContent, DropdownMenuSubContent,
@@ -17,7 +20,6 @@ import type { Column, ColumnWidth } from "@/types/board";
interface ColumnHeaderProps { interface ColumnHeaderProps {
column: Column; column: Column;
cardCount: number; cardCount: number;
boardColor?: string;
} }
const COLOR_PRESETS = [ const COLOR_PRESETS = [
@@ -33,7 +35,7 @@ const COLOR_PRESETS = [
{ hue: "0", label: "Slate" }, { hue: "0", label: "Slate" },
]; ];
export function ColumnHeader({ column, cardCount, boardColor }: ColumnHeaderProps) { export function ColumnHeader({ column, cardCount }: ColumnHeaderProps) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(column.title); const [editValue, setEditValue] = useState(column.title);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -42,6 +44,8 @@ export function ColumnHeader({ column, cardCount, boardColor }: ColumnHeaderProp
const deleteColumn = useBoardStore((s) => s.deleteColumn); const deleteColumn = useBoardStore((s) => s.deleteColumn);
const setColumnWidth = useBoardStore((s) => s.setColumnWidth); const setColumnWidth = useBoardStore((s) => s.setColumnWidth);
const setColumnColor = useBoardStore((s) => s.setColumnColor); const setColumnColor = useBoardStore((s) => s.setColumnColor);
const setColumnWipLimit = useBoardStore((s) => s.setColumnWipLimit);
const toggleColumnCollapse = useBoardStore((s) => s.toggleColumnCollapse);
useEffect(() => { useEffect(() => {
if (editing && inputRef.current) { if (editing && inputRef.current) {
@@ -74,13 +78,7 @@ export function ColumnHeader({ column, cardCount, boardColor }: ColumnHeaderProp
} }
return ( return (
<div className="flex items-center justify-between border-b border-border px-3 pb-2 pt-3" style={{ <div className="flex items-center justify-between border-b border-border px-3 pb-2 pt-3">
borderTop: column.color
? `3px solid oklch(55% 0.12 ${column.color})`
: boardColor
? `3px solid ${boardColor}30`
: undefined
}}>
<div className="flex items-center gap-2 overflow-hidden"> <div className="flex items-center gap-2 overflow-hidden">
{editing ? ( {editing ? (
<input <input
@@ -102,8 +100,14 @@ export function ColumnHeader({ column, cardCount, boardColor }: ColumnHeaderProp
{column.title} {column.title}
</span> </span>
)} )}
<span className="shrink-0 font-mono text-xs text-pylon-text-secondary"> <span className={`shrink-0 font-mono text-xs ${
{cardCount} 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> </span>
</div> </div>
@@ -126,38 +130,28 @@ export function ColumnHeader({ column, cardCount, boardColor }: ColumnHeaderProp
> >
Rename Rename
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => toggleColumnCollapse(column.id)}>
Collapse
</DropdownMenuItem>
<DropdownMenuSub> <DropdownMenuSub>
<DropdownMenuSubTrigger>Width</DropdownMenuSubTrigger> <DropdownMenuSubTrigger>Width</DropdownMenuSubTrigger>
<DropdownMenuSubContent> <DropdownMenuSubContent>
<DropdownMenuItem onClick={() => handleWidthChange("narrow")}> <DropdownMenuRadioGroup value={column.width} onValueChange={(v) => handleWidthChange(v as ColumnWidth)}>
Narrow <DropdownMenuRadioItem value="narrow">Narrow</DropdownMenuRadioItem>
{column.width === "narrow" && ( <DropdownMenuRadioItem value="standard">Standard</DropdownMenuRadioItem>
<span className="ml-auto text-pylon-accent">*</span> <DropdownMenuRadioItem value="wide">Wide</DropdownMenuRadioItem>
)} </DropdownMenuRadioGroup>
</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>
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
<DropdownMenuSub> <DropdownMenuSub>
<DropdownMenuSubTrigger>Color</DropdownMenuSubTrigger> <DropdownMenuSubTrigger>Color</DropdownMenuSubTrigger>
<DropdownMenuSubContent> <DropdownMenuSubContent>
<DropdownMenuItem onClick={() => setColumnColor(column.id, null)}> <DropdownMenuCheckboxItem
checked={column.color == null}
onSelect={() => setColumnColor(column.id, null)}
>
None None
{column.color == null && ( </DropdownMenuCheckboxItem>
<span className="ml-auto text-pylon-accent">*</span>
)}
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="flex flex-wrap gap-1.5 px-2 py-1.5"> <div className="flex flex-wrap gap-1.5 px-2 py-1.5">
{COLOR_PRESETS.map(({ hue, label }) => ( {COLOR_PRESETS.map(({ hue, label }) => (
@@ -176,6 +170,21 @@ export function ColumnHeader({ column, cardCount, boardColor }: ColumnHeaderProp
</div> </div>
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </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 /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
variant="destructive" variant="destructive"

View File

@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Plus } from "lucide-react"; import { Plus, ChevronRight } from "lucide-react";
import { motion, useReducedMotion } from "framer-motion"; import { motion, useReducedMotion } from "framer-motion";
import { fadeSlideUp, springs, staggerContainer } from "@/lib/motion"; import { fadeSlideUp, springs, staggerContainer } from "@/lib/motion";
import { useDroppable } from "@dnd-kit/core"; import { useDroppable } from "@dnd-kit/core";
@@ -9,8 +9,8 @@ import {
useSortable, useSortable,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ColumnHeader } from "@/components/board/ColumnHeader"; import { ColumnHeader } from "@/components/board/ColumnHeader";
import { AddCardInput } from "@/components/board/AddCardInput"; import { AddCardInput } from "@/components/board/AddCardInput";
import { CardThumbnail } from "@/components/board/CardThumbnail"; import { CardThumbnail } from "@/components/board/CardThumbnail";
@@ -26,11 +26,13 @@ const WIDTH_MAP = {
interface KanbanColumnProps { interface KanbanColumnProps {
column: Column; column: Column;
onCardClick?: (cardId: string) => void; 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 [showAddCard, setShowAddCard] = useState(false);
const board = useBoardStore((s) => s.board); const board = useBoardStore((s) => s.board);
const toggleColumnCollapse = useBoardStore((s) => s.toggleColumnCollapse);
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const width = WIDTH_MAP[column.width]; const width = WIDTH_MAP[column.width];
@@ -54,31 +56,68 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
data: { type: "column", columnId: column.id }, data: { type: "column", columnId: column.id },
}); });
const style = { const borderTop = column.color
transform: CSS.Transform.toString(transform), ? `3px solid oklch(55% 0.12 ${column.color})`
transition, : board?.color
opacity: isDragging ? 0.5 : undefined, ? `3px solid ${board.color}30`
width, : undefined;
};
const cardCount = column.cardIds.length; 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 ( return (
<motion.section <motion.div
ref={setSortableNodeRef} ref={setSortableNodeRef}
style={style} style={{
className="group/column flex shrink-0 flex-col rounded-lg bg-pylon-column" transform: CSS.Transform.toString(transform),
aria-label={`${column.title} column, ${cardCount} card${cardCount !== 1 ? "s" : ""}`} transition,
variants={fadeSlideUp} opacity: isDragging ? 0.5 : undefined,
initial={prefersReducedMotion ? false : "hidden"} }}
animate="visible" animate={{ width: column.collapsed ? 40 : width, opacity: 1 }}
initial={isNew ? { width: 0, opacity: 0 } : false}
exit={{ width: 0, opacity: 0 }}
transition={springs.bouncy} transition={springs.bouncy}
layout className="shrink-0 overflow-hidden"
{...attributes} {...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 */} {/* The column header is the drag handle for column reordering */}
<div {...listeners}> <div {...listeners}>
<ColumnHeader column={column} cardCount={column.cardIds.length} boardColor={board?.color} /> <ColumnHeader column={column} cardCount={column.cardIds.length} />
</div> </div>
{/* Card list - wrapped in SortableContext for within-column sorting */} {/* Card list - wrapped in SortableContext for within-column sorting */}
@@ -86,7 +125,11 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
items={column.cardIds} items={column.cardIds}
strategy={verticalListSortingStrategy} 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 <motion.ul
ref={setDroppableNodeRef} ref={setDroppableNodeRef}
className="flex min-h-[40px] list-none flex-col" className="flex min-h-[40px] list-none flex-col"
@@ -115,7 +158,7 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
</li> </li>
)} )}
</motion.ul> </motion.ul>
</ScrollArea> </OverlayScrollbarsComponent>
</SortableContext> </SortableContext>
{/* Add card section */} {/* Add card section */}
@@ -138,5 +181,7 @@ export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) {
</div> </div>
)} )}
</motion.section> </motion.section>
)}
</motion.div>
); );
} }

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
import { MarkdownEditor } from "@/components/card-detail/MarkdownEditor"; 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 { LabelPicker } from "@/components/card-detail/LabelPicker";
import { DueDatePicker } from "@/components/card-detail/DueDatePicker"; import { DueDatePicker } from "@/components/card-detail/DueDatePicker";
import { AttachmentSection } from "@/components/card-detail/AttachmentSection"; import { AttachmentSection } from "@/components/card-detail/AttachmentSection";
import { PriorityPicker } from "@/components/card-detail/PriorityPicker";
import { springs, staggerContainer, fadeSlideUp } from "@/lib/motion"; import { springs, staggerContainer, fadeSlideUp } from "@/lib/motion";
interface CardDetailModalProps { interface CardDetailModalProps {
@@ -80,8 +82,13 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
</div> </div>
{/* Dashboard grid body */} {/* 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 <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)} variants={staggerContainer(0.05)}
initial="hidden" initial="hidden"
animate="visible" animate="visible"
@@ -127,7 +134,15 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
<MarkdownEditor cardId={cardId} value={card.description} /> <MarkdownEditor cardId={cardId} value={card.description} />
</motion.div> </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 <motion.div
className="rounded-lg bg-pylon-column/50 p-4" className="rounded-lg bg-pylon-column/50 p-4"
variants={fadeSlideUp} variants={fadeSlideUp}
@@ -139,8 +154,9 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
/> />
</motion.div> </motion.div>
{/* Row 4: Attachments (full width) */}
<motion.div <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} variants={fadeSlideUp}
transition={springs.bouncy} transition={springs.bouncy}
> >
@@ -150,6 +166,7 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
/> />
</motion.div> </motion.div>
</motion.div> </motion.div>
</OverlayScrollbarsComponent>
</motion.div> </motion.div>
</div> </div>
</> </>

View File

@@ -1,5 +1,20 @@
import { useState, useRef } from "react"; 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 { useBoardStore } from "@/stores/board-store";
import type { ChecklistItem } from "@/types/board"; import type { ChecklistItem } from "@/types/board";
@@ -13,8 +28,23 @@ export function ChecklistSection({ cardId, checklist }: ChecklistSectionProps) {
const updateChecklistItem = useBoardStore((s) => s.updateChecklistItem); const updateChecklistItem = useBoardStore((s) => s.updateChecklistItem);
const deleteChecklistItem = useBoardStore((s) => s.deleteChecklistItem); const deleteChecklistItem = useBoardStore((s) => s.deleteChecklistItem);
const addChecklistItem = useBoardStore((s) => s.addChecklistItem); const addChecklistItem = useBoardStore((s) => s.addChecklistItem);
const reorderChecklistItems = useBoardStore((s) => s.reorderChecklistItems);
const [newItemText, setNewItemText] = useState(""); 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 inputRef = useRef<HTMLInputElement>(null);
const checked = checklist.filter((item) => item.checked).length; const checked = checklist.filter((item) => item.checked).length;
@@ -60,18 +90,28 @@ export function ChecklistSection({ cardId, checklist }: ChecklistSectionProps) {
</div> </div>
{/* Items */} {/* Items */}
<div className="flex max-h-[160px] flex-col gap-1 overflow-y-auto"> <OverlayScrollbarsComponent
{checklist.map((item) => ( className="max-h-[160px]"
<ChecklistRow options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
key={item.id} defer
cardId={cardId} >
item={item} <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
onToggle={() => toggleChecklistItem(cardId, item.id)} <SortableContext items={checklist.map((item) => item.id)} strategy={verticalListSortingStrategy}>
onUpdate={(text) => updateChecklistItem(cardId, item.id, text)} <div className="flex flex-col gap-1">
onDelete={() => deleteChecklistItem(cardId, item.id)} {checklist.map((item) => (
/> <ChecklistRow
))} key={item.id}
</div> 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 */} {/* Add item */}
<div className="flex gap-2"> <div className="flex gap-2">
@@ -100,6 +140,10 @@ function ChecklistRow({ item, onToggle, onUpdate, onDelete }: ChecklistRowProps)
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(item.text); const [draft, setDraft] = useState(item.text);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: item.id,
});
function handleSave() { function handleSave() {
const trimmed = draft.trim(); const trimmed = draft.trim();
if (trimmed && trimmed !== item.text) { if (trimmed && trimmed !== item.text) {
@@ -121,7 +165,22 @@ function ChecklistRow({ item, onToggle, onUpdate, onDelete }: ChecklistRowProps)
} }
return ( 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 <input
type="checkbox" type="checkbox"
checked={item.checked} 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; moveColumn: (fromIndex: number, toIndex: number) => void;
setColumnWidth: (columnId: string, width: ColumnWidth) => void; setColumnWidth: (columnId: string, width: ColumnWidth) => void;
setColumnColor: (columnId: string, color: string | null) => 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; addCard: (columnId: string, title: string) => string;
updateCard: (cardId: string, updates: Partial<Card>) => void; updateCard: (cardId: string, updates: Partial<Card>) => void;
deleteCard: (cardId: string) => void; deleteCard: (cardId: string) => void;
duplicateCard: (cardId: string) => string | null;
moveCard: ( moveCard: (
cardId: string, cardId: string,
fromColumnId: string, fromColumnId: string,
@@ -48,10 +51,14 @@ interface BoardActions {
toggleChecklistItem: (cardId: string, itemId: string) => void; toggleChecklistItem: (cardId: string, itemId: string) => void;
updateChecklistItem: (cardId: string, itemId: string, text: string) => void; updateChecklistItem: (cardId: string, itemId: string, text: string) => void;
deleteChecklistItem: (cardId: string, itemId: string) => void; deleteChecklistItem: (cardId: string, itemId: string) => void;
reorderChecklistItems: (cardId: string, fromIndex: number, toIndex: number) => void;
addAttachment: (cardId: string, attachment: Omit<Attachment, "id">) => void; addAttachment: (cardId: string, attachment: Omit<Attachment, "id">) => void;
removeAttachment: (cardId: string, attachmentId: string) => void; removeAttachment: (cardId: string, attachmentId: string) => void;
addComment: (cardId: string, text: string) => void;
deleteComment: (cardId: string, commentId: string) => void;
updateBoardTitle: (title: string) => void; updateBoardTitle: (title: string) => void;
updateBoardColor: (color: string) => void; updateBoardColor: (color: string) => void;
updateBoardSettings: (settings: Board["settings"]) => 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 -- // -- Card actions --
addCard: (columnId, title) => { 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) => { moveCard: (cardId, fromColumnId, toColumnId, toIndex) => {
mutate(get, set, (b) => ({ mutate(get, set, (b) => ({
...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 -- // -- Attachment actions --
addAttachment: (cardId, attachment) => { 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 -- // -- Board metadata --
updateBoardTitle: (title) => { updateBoardTitle: (title) => {