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:
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user