feat: Phase 1 quick wins - defaultColumnWidth, due date colors, card aging, open attachments
This commit is contained in:
@@ -1,14 +1,54 @@
|
|||||||
import React from "react";
|
import { useState, useRef } from "react";
|
||||||
import { format, isPast, isToday } from "date-fns";
|
import { createPortal } from "react-dom";
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { format } from "date-fns";
|
||||||
|
import { motion, useReducedMotion, AnimatePresence } from "framer-motion";
|
||||||
import { fadeSlideUp, springs } from "@/lib/motion";
|
import { fadeSlideUp, springs } from "@/lib/motion";
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import type { Card, Label } from "@/types/board";
|
import type { Card, Label } from "@/types/board";
|
||||||
|
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
||||||
import { LabelDots } from "@/components/board/LabelDots";
|
import { LabelDots } from "@/components/board/LabelDots";
|
||||||
import { ChecklistBar } from "@/components/board/ChecklistBar";
|
import { ChecklistBar } from "@/components/board/ChecklistBar";
|
||||||
import { Paperclip, AlignLeft } from "lucide-react";
|
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<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 {
|
interface CardThumbnailProps {
|
||||||
card: Card;
|
card: Card;
|
||||||
boardLabels: Label[];
|
boardLabels: Label[];
|
||||||
@@ -31,25 +71,40 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card
|
|||||||
data: { type: "card", columnId },
|
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 hasDueDate = card.dueDate != null;
|
||||||
const dueDate = hasDueDate ? new Date(card.dueDate!) : null;
|
const dueDateStatus = getDueDateStatus(card.dueDate);
|
||||||
const overdue = dueDate != null && isPast(dueDate) && !isToday(dueDate);
|
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
onCardClick?.(card.id);
|
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 (
|
return (
|
||||||
<motion.button
|
<motion.button
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={{
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
padding: `calc(0.75rem * var(--density-factor))`,
|
||||||
|
opacity: getAgingOpacity(card.updatedAt),
|
||||||
|
}}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className="w-full rounded-lg bg-pylon-surface shadow-sm text-left"
|
className="w-full rounded-lg bg-pylon-surface shadow-sm text-left"
|
||||||
layoutId={`card-${card.id}`}
|
layoutId={`card-${card.id}`}
|
||||||
@@ -86,25 +141,29 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card
|
|||||||
{/* Card title */}
|
{/* Card title */}
|
||||||
<p className="text-sm font-medium text-pylon-text">{card.title}</p>
|
<p className="text-sm font-medium text-pylon-text">{card.title}</p>
|
||||||
|
|
||||||
{/* Footer row: due date + checklist */}
|
{/* Footer row: priority + due date + checklist + icons */}
|
||||||
{(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description) && (
|
{(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description || card.priority !== "none") && (
|
||||||
<div className="mt-2 flex items-center gap-3">
|
<div className="mt-2 flex items-center gap-3">
|
||||||
{dueDate && (
|
{card.priority !== "none" && (
|
||||||
<span
|
<span
|
||||||
className={`font-mono text-xs ${
|
className={`size-2.5 shrink-0 rounded-full ${card.priority === "urgent" ? "animate-pulse" : ""}`}
|
||||||
overdue
|
style={{ backgroundColor: PRIORITY_COLORS[card.priority] }}
|
||||||
? "rounded bg-pylon-danger/10 px-1 py-0.5 text-pylon-danger"
|
title={`Priority: ${card.priority}`}
|
||||||
: "text-pylon-text-secondary"
|
/>
|
||||||
}`}
|
)}
|
||||||
|
{dueDateStatus && card.dueDate && (
|
||||||
|
<span
|
||||||
|
className={`font-mono text-xs rounded px-1 py-0.5 ${dueDateStatus.color} ${dueDateStatus.bgColor}`}
|
||||||
|
title={dueDateStatus.label}
|
||||||
>
|
>
|
||||||
{format(dueDate, "MMM d")}
|
{format(new Date(card.dueDate), "MMM d")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{card.checklist.length > 0 && (
|
{card.checklist.length > 0 && (
|
||||||
<ChecklistBar checklist={card.checklist} />
|
<ChecklistBar checklist={card.checklist} />
|
||||||
)}
|
)}
|
||||||
{card.description && (
|
{card.description && (
|
||||||
<AlignLeft className="size-3 text-pylon-text-secondary" />
|
<DescriptionPreview description={card.description} />
|
||||||
)}
|
)}
|
||||||
{card.attachments.length > 0 && (
|
{card.attachments.length > 0 && (
|
||||||
<span className="flex items-center gap-0.5 text-pylon-text-secondary">
|
<span className="flex items-center gap-0.5 text-pylon-text-secondary">
|
||||||
@@ -117,3 +176,120 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: Card
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useBoardStore } from "@/stores/board-store";
|
import { useBoardStore } from "@/stores/board-store";
|
||||||
@@ -68,6 +69,13 @@ export function AttachmentSection({
|
|||||||
<span className="flex-1 truncate text-sm text-pylon-text">
|
<span className="flex-1 truncate text-sm text-pylon-text">
|
||||||
{att.name}
|
{att.name}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => openPath(att.path)}
|
||||||
|
className="shrink-0 rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-accent/10 hover:text-pylon-accent group-hover/att:opacity-100"
|
||||||
|
aria-label="Open attachment"
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-3" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeAttachment(cardId, att.id)}
|
onClick={() => removeAttachment(cardId, att.id)}
|
||||||
className="shrink-0 rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-danger/10 hover:text-pylon-danger group-hover/att:opacity-100"
|
className="shrink-0 rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-danger/10 hover:text-pylon-danger group-hover/att:opacity-100"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
ColumnWidth,
|
ColumnWidth,
|
||||||
} from "@/types/board";
|
} from "@/types/board";
|
||||||
import { saveBoard, loadBoard } from "@/lib/storage";
|
import { saveBoard, loadBoard } from "@/lib/storage";
|
||||||
|
import { useAppStore } from "@/stores/app-store";
|
||||||
|
|
||||||
interface BoardState {
|
interface BoardState {
|
||||||
board: Board | null;
|
board: Board | null;
|
||||||
@@ -121,6 +122,7 @@ export const useBoardStore = create<BoardState & BoardActions>()(
|
|||||||
// -- Column actions --
|
// -- Column actions --
|
||||||
|
|
||||||
addColumn: (title: string) => {
|
addColumn: (title: string) => {
|
||||||
|
const defaultWidth = useAppStore.getState().settings.defaultColumnWidth;
|
||||||
mutate(get, set, (b) => ({
|
mutate(get, set, (b) => ({
|
||||||
...b,
|
...b,
|
||||||
updatedAt: now(),
|
updatedAt: now(),
|
||||||
@@ -130,7 +132,7 @@ export const useBoardStore = create<BoardState & BoardActions>()(
|
|||||||
id: ulid(),
|
id: ulid(),
|
||||||
title,
|
title,
|
||||||
cardIds: [],
|
cardIds: [],
|
||||||
width: "standard" as ColumnWidth,
|
width: defaultWidth,
|
||||||
color: null,
|
color: null,
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
wipLimit: null,
|
wipLimit: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user