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:
162
src/components/card-detail/CardDetailModal.tsx
Normal file
162
src/components/card-detail/CardDetailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user