Files
openpylon/src/components/board/CardThumbnail.tsx
Your Name a17c8b6b62 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
2026-02-16 14:46:20 +02:00

376 lines
13 KiB
TypeScript

import { useState, useRef } from "react";
import { createPortal } from "react-dom";
import { format } from "date-fns";
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, 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 ---------- */
function getDueDateStatus(dueDate: string | null): { color: string; bgColor: string; label: string } | null {
if (!dueDate) return null;
const date = new Date(dueDate);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dueDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const diffDays = Math.ceil((dueDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return { color: "text-pylon-danger", bgColor: "bg-pylon-danger/10", label: "Overdue" };
}
if (diffDays <= 2) {
return { color: "text-[oklch(65%_0.15_70)]", bgColor: "bg-[oklch(65%_0.15_70/10%)]", label: "Due soon" };
}
return { color: "text-[oklch(55%_0.12_145)]", bgColor: "bg-[oklch(55%_0.12_145/10%)]", label: "Upcoming" };
}
/* ---------- Card aging ---------- */
function getAgingOpacity(updatedAt: string): number {
const days = (Date.now() - new Date(updatedAt).getTime()) / (1000 * 60 * 60 * 24);
if (days <= 7) return 1.0;
if (days <= 14) return 0.85;
if (days <= 30) return 0.7;
return 0.55;
}
/* ---------- Priority colors ---------- */
const PRIORITY_COLORS: Record<string, string> = {
low: "oklch(60% 0.15 240)",
medium: "oklch(70% 0.15 85)",
high: "oklch(60% 0.15 55)",
urgent: "oklch(55% 0.15 25)",
};
interface CardThumbnailProps {
card: Card;
boardLabels: Label[];
columnId: string;
onCardClick?: (cardId: string) => void;
}
export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: CardThumbnailProps) {
const prefersReducedMotion = useReducedMotion();
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: card.id,
data: { type: "card", columnId },
});
const hasDueDate = card.dueDate != null;
const dueDateStatus = getDueDateStatus(card.dueDate);
function handleClick() {
onCardClick?.(card.id);
}
// Drop indicator line when this card is being dragged
if (isDragging) {
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
className="py-1"
{...attributes}
{...listeners}
>
<div className="h-[3px] rounded-full bg-pylon-accent shadow-[0_0_6px_var(--pylon-accent),0_0_14px_var(--pylon-accent)]" />
</div>
);
}
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<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"
style={{
backgroundColor: `oklch(55% 0.12 ${card.coverColor})`,
margin: `calc(-0.75rem * var(--density-factor)) calc(-0.75rem * var(--density-factor)) 0.5rem`,
}}
/>
)}
{/* 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}`}
/>
)}
{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>
)}
</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>
)}
<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>
);
}
/* ---------- Description hover preview ---------- */
function DescriptionPreview({ description }: { description: string }) {
const [show, setShow] = useState(false);
const [pos, setPos] = useState<{ below: boolean; top?: number; bottom?: number; left: number; arrowLeft: number; maxHeight: number } | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const iconRef = useRef<HTMLSpanElement>(null);
function handleEnter() {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
if (iconRef.current) {
const rect = iconRef.current.getBoundingClientRect();
const zoom = parseFloat(getComputedStyle(document.documentElement).fontSize) / 16;
const popupW = 224 * zoom; // w-56 = 14rem, scales with zoom
const gap = 8;
// Flip below if more room below than above
const titleBarH = 52 * zoom;
const spaceAbove = rect.top - gap - titleBarH;
const spaceBelow = window.innerHeight - rect.bottom - gap - 8;
const below = spaceBelow > spaceAbove;
// Max height for popup content (stay within viewport)
const paddingPx = 24 * zoom; // p-3 top + bottom
const maxAvailable = below
? window.innerHeight - (rect.bottom + gap) - 8
: rect.top - gap - titleBarH;
const maxHeight = Math.max(60, Math.min(maxAvailable - paddingPx, 300 * zoom));
// Center horizontally, clamp to viewport
let left = rect.left + rect.width / 2 - popupW / 2;
left = Math.max(8, Math.min(left, window.innerWidth - popupW - 8));
// Arrow offset relative to popup left edge
const arrowLeft = Math.max(12, Math.min(rect.left + rect.width / 2 - left, popupW - 12));
// Position: use top when below, bottom when above (avoids height estimation)
setPos(below
? { below: true, top: rect.bottom + gap, left, arrowLeft, maxHeight }
: { below: false, bottom: window.innerHeight - rect.top + gap, left, arrowLeft, maxHeight }
);
}
setShow(true);
}, 300);
}
function handleLeave() {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setShow(false), 100);
}
function cancelHide() {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
}
// Strip markdown formatting for a clean preview
const plainText = description
.replace(/^#{1,6}\s+/gm, "")
.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(/\*(.+?)\*/g, "$1")
.replace(/`(.+?)`/g, "$1")
.replace(/\[(.+?)\]\(.+?\)/g, "$1")
.replace(/^[-*]\s+/gm, "- ")
.trim();
return (
<span
ref={iconRef}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
>
<AlignLeft className="size-3 text-pylon-text-secondary" />
{createPortal(
<AnimatePresence>
{show && pos && (
<motion.div
key="desc-preview"
initial={{ opacity: 0, y: pos.below ? -4 : 4, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: pos.below ? -4 : 4, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="pointer-events-auto fixed z-[9999] w-56 rounded-lg border border-border bg-pylon-surface p-3 shadow-xl"
style={{
...(pos.below ? { top: pos.top } : { bottom: pos.bottom }),
left: pos.left,
}}
onMouseEnter={cancelHide}
onMouseLeave={() => setShow(false)}
onClick={(e) => e.stopPropagation()}
>
{/* Arrow */}
<div
className={`absolute size-2 rotate-45 border-border bg-pylon-surface ${
pos.below ? "-top-1 border-l border-t" : "-bottom-1 border-b border-r"
}`}
style={{ left: pos.arrowLeft }}
/>
<OverlayScrollbarsComponent
style={{ maxHeight: pos.maxHeight }}
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
defer
>
<p className="whitespace-pre-wrap text-xs leading-relaxed text-pylon-text">
{plainText}
</p>
</OverlayScrollbarsComponent>
</motion.div>
)}
</AnimatePresence>,
document.body
)}
</span>
);
}