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

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

View File

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

View File

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