feat: add two-panel card detail modal with markdown, checklist, labels, dates, attachments

- CardDetailModal: two-panel layout (60/40) with inline title editing
- MarkdownEditor: edit/preview toggle with react-markdown + remark-gfm
- ChecklistSection: add/toggle/edit/delete items with progress counter
- LabelPicker: toggle labels + create new labels with color swatches
- DueDatePicker: date input with relative time and overdue styling
- AttachmentSection: list with remove, placeholder add button
- Wired into BoardView via selectedCardId state
This commit is contained in:
Your Name
2026-02-15 19:05:02 +02:00
parent 86de747bc4
commit b527d441e3
9 changed files with 796 additions and 5 deletions

View File

@@ -0,0 +1,162 @@
import { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { useBoardStore } from "@/stores/board-store";
import { MarkdownEditor } from "@/components/card-detail/MarkdownEditor";
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";
interface CardDetailModalProps {
cardId: string | null;
onClose: () => void;
}
export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
const card = useBoardStore((s) =>
cardId ? s.board?.cards[cardId] ?? null : null
);
const boardLabels = useBoardStore((s) => s.board?.labels ?? []);
const attachmentMode = useBoardStore(
(s) => s.board?.settings.attachmentMode ?? "link"
);
const updateCard = useBoardStore((s) => s.updateCard);
const open = cardId != null && card != null;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-3xl gap-0 overflow-hidden p-0">
{card && cardId && (
<>
{/* Hidden accessible description */}
<DialogDescription className="sr-only">
Card detail editor
</DialogDescription>
<div className="flex max-h-[80vh] flex-col sm:flex-row">
{/* Left panel: Title + Markdown (60%) */}
<div className="flex flex-1 flex-col overflow-y-auto p-6 sm:w-[60%]">
<DialogHeader className="mb-4">
<InlineTitle
cardId={cardId}
title={card.title}
updateCard={updateCard}
/>
</DialogHeader>
<MarkdownEditor cardId={cardId} value={card.description} />
</div>
{/* Vertical separator */}
<Separator orientation="vertical" className="hidden sm:block" />
{/* Right sidebar (40%) */}
<div className="flex flex-col gap-5 overflow-y-auto border-t border-border p-5 sm:w-[40%] sm:border-t-0">
<LabelPicker
cardId={cardId}
cardLabelIds={card.labels}
boardLabels={boardLabels}
/>
<Separator />
<DueDatePicker cardId={cardId} dueDate={card.dueDate} />
<Separator />
<ChecklistSection
cardId={cardId}
checklist={card.checklist}
/>
<Separator />
<AttachmentSection
cardId={cardId}
attachments={card.attachments}
attachmentMode={attachmentMode}
/>
</div>
</div>
</>
)}
</DialogContent>
</Dialog>
);
}
/* ---------- Inline editable title ---------- */
interface InlineTitleProps {
cardId: string;
title: string;
updateCard: (cardId: string, updates: { title: string }) => void;
}
function InlineTitle({ cardId, title, updateCard }: InlineTitleProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
// Sync when title changes externally
useEffect(() => {
setDraft(title);
}, [title]);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
function handleSave() {
const trimmed = draft.trim();
if (trimmed && trimmed !== title) {
updateCard(cardId, { title: trimmed });
} else {
setDraft(title);
}
setEditing(false);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
setDraft(title);
setEditing(false);
}
}
if (editing) {
return (
<input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="font-heading text-xl text-pylon-text bg-transparent outline-none border-b border-pylon-accent pb-0.5"
/>
);
}
return (
<DialogTitle
onClick={() => setEditing(true)}
className="cursor-pointer font-heading text-xl text-pylon-text transition-colors hover:text-pylon-accent"
>
{title}
</DialogTitle>
);
}