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