import { useState, useRef, useEffect } 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.9; if (days <= 30) return 0.8; return 0.7; } /* ---------- Priority colors ---------- */ const PRIORITY_COLORS: Record = { 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; isFocused?: boolean; } export function CardThumbnail({ card, boardLabels, columnId, onCardClick, isFocused }: CardThumbnailProps) { const prefersReducedMotion = useReducedMotion(); const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: card.id, data: { type: "card", columnId }, }); const cardRef = useRef(null); useEffect(() => { if (isFocused && cardRef.current) { cardRef.current.scrollIntoView({ block: "nearest", behavior: "smooth" }); } }, [isFocused]); 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 (
); } return ( { setNodeRef(node); (cardRef as React.MutableRefObject).current = node; }} 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 ${ isFocused ? "ring-2 ring-pylon-accent ring-offset-2 ring-offset-pylon-column" : "" }`} layoutId={prefersReducedMotion ? undefined : `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={!prefersReducedMotion} {...attributes} {...listeners} role="article" aria-label={card.title} > {/* Cover color bar */} {card.coverColor && (
)} {/* Label dots */} {card.labels.length > 0 && (
)} {/* Card title */}

{card.title}

{/* Footer row: priority + due date + checklist + icons */} {(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description || card.priority !== "none") && (
{card.priority !== "none" && ( )} {dueDateStatus && card.dueDate && ( {format(new Date(card.dueDate), "MMM d")} )} {card.checklist.length > 0 && ( )} {card.description && ( )} {card.attachments.length > 0 && ( {card.attachments.length} )}
)} ); } /* ---------- 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 ( {otherColumns.length > 0 && ( Move to {otherColumns.map((col) => ( moveCard(cardId, columnId, col.id, col.cardIds.length)} > {col.title} ))} )} Set priority {priorities.map(({ value, label }) => ( updateCard(cardId, { priority: value })} > {label} ))} duplicateCard(cardId)}> Duplicate deleteCard(cardId)} > Delete ); } /* ---------- 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 | null>(null); const iconRef = useRef(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 ( {createPortal( {show && pos && ( setShow(false)} onClick={(e) => e.stopPropagation()} > {/* Arrow */}

{plainText}

)} , document.body )} ); }