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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
46
src/components/card-detail/PriorityPicker.tsx
Normal file
46
src/components/card-detail/PriorityPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user