From b51818ada3d1436d831b9788c569744270868c97 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 16 Feb 2026 14:33:48 +0200 Subject: [PATCH] feat: Phase 1 quick wins - defaultColumnWidth, due date colors, card aging, open attachments --- src/components/board/CardThumbnail.tsx | 222 ++++++++++++++++-- .../card-detail/AttachmentSection.tsx | 10 +- src/stores/board-store.ts | 4 +- 3 files changed, 211 insertions(+), 25 deletions(-) diff --git a/src/components/board/CardThumbnail.tsx b/src/components/board/CardThumbnail.tsx index 01ad8d4..1d03873 100644 --- a/src/components/board/CardThumbnail.tsx +++ b/src/components/board/CardThumbnail.tsx @@ -1,14 +1,54 @@ -import React from "react"; -import { format, isPast, isToday } from "date-fns"; -import { motion, useReducedMotion } from "framer-motion"; +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 } 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"; +/* ---------- 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 = { + 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[]; @@ -31,25 +71,40 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card data: { type: "card", columnId }, }); - const style: React.CSSProperties = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.3 : undefined, - padding: `calc(0.75rem * var(--density-factor))`, - }; - const hasDueDate = card.dueDate != null; - const dueDate = hasDueDate ? new Date(card.dueDate!) : null; - const overdue = dueDate != null && isPast(dueDate) && !isToday(dueDate); + const dueDateStatus = getDueDateStatus(card.dueDate); function handleClick() { onCardClick?.(card.id); } + // Drop indicator line when this card is being dragged + if (isDragging) { + return ( +
+
+
+ ); + } + return ( {card.title}

- {/* Footer row: due date + checklist */} - {(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description) && ( + {/* Footer row: priority + due date + checklist + icons */} + {(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description || card.priority !== "none") && (
- {dueDate && ( + {card.priority !== "none" && ( + )} + {dueDateStatus && card.dueDate && ( + - {format(dueDate, "MMM d")} + {format(new Date(card.dueDate), "MMM d")} )} {card.checklist.length > 0 && ( )} {card.description && ( - + )} {card.attachments.length > 0 && ( @@ -117,3 +176,120 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card ); } + +/* ---------- 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 + )} + + ); +} diff --git a/src/components/card-detail/AttachmentSection.tsx b/src/components/card-detail/AttachmentSection.tsx index 38d744d..56af145 100644 --- a/src/components/card-detail/AttachmentSection.tsx +++ b/src/components/card-detail/AttachmentSection.tsx @@ -1,4 +1,5 @@ -import { FileIcon, X, Plus } from "lucide-react"; +import { FileIcon, X, Plus, ExternalLink } from "lucide-react"; +import { openPath } from "@tauri-apps/plugin-opener"; import { open } from "@tauri-apps/plugin-dialog"; import { Button } from "@/components/ui/button"; import { useBoardStore } from "@/stores/board-store"; @@ -68,6 +69,13 @@ export function AttachmentSection({ {att.name} +