From b527d441e302c5f9d7527e66edf0de62b5b0a444 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 15 Feb 2026 19:05:02 +0200 Subject: [PATCH] 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 --- src/components/board/BoardView.tsx | 15 +- src/components/board/CardThumbnail.tsx | 6 +- src/components/board/KanbanColumn.tsx | 4 +- .../card-detail/AttachmentSection.tsx | 69 +++++++ .../card-detail/CardDetailModal.tsx | 162 +++++++++++++++ .../card-detail/ChecklistSection.tsx | 156 +++++++++++++++ src/components/card-detail/DueDatePicker.tsx | 82 ++++++++ src/components/card-detail/LabelPicker.tsx | 186 ++++++++++++++++++ src/components/card-detail/MarkdownEditor.tsx | 121 ++++++++++++ 9 files changed, 796 insertions(+), 5 deletions(-) create mode 100644 src/components/card-detail/AttachmentSection.tsx create mode 100644 src/components/card-detail/CardDetailModal.tsx create mode 100644 src/components/card-detail/ChecklistSection.tsx create mode 100644 src/components/card-detail/DueDatePicker.tsx create mode 100644 src/components/card-detail/LabelPicker.tsx create mode 100644 src/components/card-detail/MarkdownEditor.tsx diff --git a/src/components/board/BoardView.tsx b/src/components/board/BoardView.tsx index 76cc310..528b867 100644 --- a/src/components/board/BoardView.tsx +++ b/src/components/board/BoardView.tsx @@ -23,6 +23,7 @@ import { CardOverlay, ColumnOverlay, } from "@/components/board/DragOverlayContent"; +import { CardDetailModal } from "@/components/card-detail/CardDetailModal"; import type { Board } from "@/types/board"; function findColumnByCardId(board: Board, cardId: string) { @@ -35,6 +36,7 @@ export function BoardView() { const moveCard = useBoardStore((s) => s.moveCard); const moveColumn = useBoardStore((s) => s.moveColumn); + const [selectedCardId, setSelectedCardId] = useState(null); const [addingColumn, setAddingColumn] = useState(false); const [newColumnTitle, setNewColumnTitle] = useState(""); const inputRef = useRef(null); @@ -233,6 +235,7 @@ export function BoardView() { const columnIds = board.columns.map((c) => c.id); return ( + <>
{board.columns.map((column) => ( - + ))} {/* Add column button / inline input */} @@ -307,5 +314,11 @@ export function BoardView() { ) : null} + + setSelectedCardId(null)} + /> + ); } diff --git a/src/components/board/CardThumbnail.tsx b/src/components/board/CardThumbnail.tsx index 8c027a8..cd9d1e9 100644 --- a/src/components/board/CardThumbnail.tsx +++ b/src/components/board/CardThumbnail.tsx @@ -9,9 +9,10 @@ interface CardThumbnailProps { card: Card; boardLabels: Label[]; columnId: string; + onCardClick?: (cardId: string) => void; } -export function CardThumbnail({ card, boardLabels, columnId }: CardThumbnailProps) { +export function CardThumbnail({ card, boardLabels, columnId, onCardClick }: CardThumbnailProps) { const { attributes, listeners, @@ -35,8 +36,7 @@ export function CardThumbnail({ card, boardLabels, columnId }: CardThumbnailProp const overdue = dueDate != null && isPast(dueDate) && !isToday(dueDate); function handleClick() { - // Card detail modal will be wired in Task 11 - console.log("Card clicked:", card.id); + onCardClick?.(card.id); } return ( diff --git a/src/components/board/KanbanColumn.tsx b/src/components/board/KanbanColumn.tsx index ce7c10b..d63de43 100644 --- a/src/components/board/KanbanColumn.tsx +++ b/src/components/board/KanbanColumn.tsx @@ -23,9 +23,10 @@ const WIDTH_MAP = { interface KanbanColumnProps { column: Column; + onCardClick?: (cardId: string) => void; } -export function KanbanColumn({ column }: KanbanColumnProps) { +export function KanbanColumn({ column, onCardClick }: KanbanColumnProps) { const [showAddCard, setShowAddCard] = useState(false); const board = useBoardStore((s) => s.board); @@ -85,6 +86,7 @@ export function KanbanColumn({ column }: KanbanColumnProps) { card={card} boardLabels={board?.labels ?? []} columnId={column.id} + onCardClick={onCardClick} /> ); })} diff --git a/src/components/card-detail/AttachmentSection.tsx b/src/components/card-detail/AttachmentSection.tsx new file mode 100644 index 0000000..d086351 --- /dev/null +++ b/src/components/card-detail/AttachmentSection.tsx @@ -0,0 +1,69 @@ +import { FileIcon, X, Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useBoardStore } from "@/stores/board-store"; +import type { Attachment } from "@/types/board"; + +interface AttachmentSectionProps { + cardId: string; + attachments: Attachment[]; + attachmentMode: "link" | "copy"; +} + +export function AttachmentSection({ + cardId, + attachments, +}: AttachmentSectionProps) { + const removeAttachment = useBoardStore((s) => s.removeAttachment); + + function handleAdd() { + // Placeholder: Tauri file dialog will be wired in a later task + console.log("Add attachment (file dialog not yet wired)"); + } + + return ( +
+ {/* Header */} +
+

+ Attachments +

+ +
+ + {/* Attachment list */} + {attachments.length > 0 ? ( +
+ {attachments.map((att) => ( +
+ + + {att.name} + + +
+ ))} +
+ ) : ( +

+ No attachments +

+ )} +
+ ); +} diff --git a/src/components/card-detail/CardDetailModal.tsx b/src/components/card-detail/CardDetailModal.tsx new file mode 100644 index 0000000..eeac037 --- /dev/null +++ b/src/components/card-detail/CardDetailModal.tsx @@ -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 ( + !isOpen && onClose()}> + + {card && cardId && ( + <> + {/* Hidden accessible description */} + + Card detail editor + + +
+ {/* Left panel: Title + Markdown (60%) */} +
+ + + + + +
+ + {/* Vertical separator */} + + + {/* Right sidebar (40%) */} +
+ + + + + + + + + + + + + +
+
+ + )} +
+
+ ); +} + +/* ---------- 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(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 ( + 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 ( + setEditing(true)} + className="cursor-pointer font-heading text-xl text-pylon-text transition-colors hover:text-pylon-accent" + > + {title} + + ); +} diff --git a/src/components/card-detail/ChecklistSection.tsx b/src/components/card-detail/ChecklistSection.tsx new file mode 100644 index 0000000..f8f51e4 --- /dev/null +++ b/src/components/card-detail/ChecklistSection.tsx @@ -0,0 +1,156 @@ +import { useState, useRef } from "react"; +import { X } from "lucide-react"; +import { useBoardStore } from "@/stores/board-store"; +import type { ChecklistItem } from "@/types/board"; + +interface ChecklistSectionProps { + cardId: string; + checklist: ChecklistItem[]; +} + +export function ChecklistSection({ cardId, checklist }: ChecklistSectionProps) { + const toggleChecklistItem = useBoardStore((s) => s.toggleChecklistItem); + const updateChecklistItem = useBoardStore((s) => s.updateChecklistItem); + const deleteChecklistItem = useBoardStore((s) => s.deleteChecklistItem); + const addChecklistItem = useBoardStore((s) => s.addChecklistItem); + + const [newItemText, setNewItemText] = useState(""); + const inputRef = useRef(null); + + const checked = checklist.filter((item) => item.checked).length; + + function handleAddItem() { + const trimmed = newItemText.trim(); + if (trimmed) { + addChecklistItem(cardId, trimmed); + setNewItemText(""); + inputRef.current?.focus(); + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") { + e.preventDefault(); + handleAddItem(); + } + } + + return ( +
+ {/* Header */} +
+

+ Checklist +

+ {checklist.length > 0 && ( + + {checked}/{checklist.length} + + )} +
+ + {/* Items */} +
+ {checklist.map((item) => ( + toggleChecklistItem(cardId, item.id)} + onUpdate={(text) => updateChecklistItem(cardId, item.id, text)} + onDelete={() => deleteChecklistItem(cardId, item.id)} + /> + ))} +
+ + {/* Add item */} +
+ setNewItemText(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Add item..." + className="h-7 flex-1 rounded-md bg-pylon-column px-2 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60 focus:ring-1 focus:ring-pylon-accent" + /> +
+
+ ); +} + +interface ChecklistRowProps { + cardId: string; + item: ChecklistItem; + onToggle: () => void; + onUpdate: (text: string) => void; + onDelete: () => void; +} + +function ChecklistRow({ item, onToggle, onUpdate, onDelete }: ChecklistRowProps) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(item.text); + + function handleSave() { + const trimmed = draft.trim(); + if (trimmed && trimmed !== item.text) { + onUpdate(trimmed); + } else { + setDraft(item.text); + } + setEditing(false); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") { + e.preventDefault(); + handleSave(); + } else if (e.key === "Escape") { + setDraft(item.text); + setEditing(false); + } + } + + return ( +
+ + + {editing ? ( + setDraft(e.target.value)} + onBlur={handleSave} + onKeyDown={handleKeyDown} + className="h-6 flex-1 rounded bg-pylon-surface px-1 text-sm text-pylon-text outline-none focus:ring-1 focus:ring-pylon-accent" + /> + ) : ( + { + setDraft(item.text); + setEditing(true); + }} + className={`flex-1 cursor-pointer text-sm ${ + item.checked + ? "line-through text-pylon-text-secondary" + : "text-pylon-text" + }`} + > + {item.text} + + )} + + +
+ ); +} diff --git a/src/components/card-detail/DueDatePicker.tsx b/src/components/card-detail/DueDatePicker.tsx new file mode 100644 index 0000000..02ec36d --- /dev/null +++ b/src/components/card-detail/DueDatePicker.tsx @@ -0,0 +1,82 @@ +import { format, formatDistanceToNow, isPast, isToday } from "date-fns"; +import { Button } from "@/components/ui/button"; +import { useBoardStore } from "@/stores/board-store"; + +interface DueDatePickerProps { + cardId: string; + dueDate: string | null; +} + +export function DueDatePicker({ cardId, dueDate }: DueDatePickerProps) { + const updateCard = useBoardStore((s) => s.updateCard); + + const dateObj = dueDate ? new Date(dueDate) : null; + const overdue = dateObj != null && isPast(dateObj) && !isToday(dateObj); + + function handleChange(e: React.ChangeEvent) { + const val = e.target.value; + updateCard(cardId, { dueDate: val || null }); + } + + function handleClear() { + updateCard(cardId, { dueDate: null }); + } + + // Format the date value for the HTML date input (YYYY-MM-DD) + const inputValue = dateObj + ? format(dateObj, "yyyy-MM-dd") + : ""; + + return ( +
+ {/* Header */} +

+ Due Date +

+ + {/* Current date display */} + {dateObj && ( +
+ + {format(dateObj, "MMM d, yyyy")} + + + {overdue + ? `overdue by ${formatDistanceToNow(dateObj)}` + : isToday(dateObj) + ? "today" + : `in ${formatDistanceToNow(dateObj)}`} + +
+ )} + + {/* Date input + clear */} +
+ + {dueDate && ( + + )} +
+
+ ); +} diff --git a/src/components/card-detail/LabelPicker.tsx b/src/components/card-detail/LabelPicker.tsx new file mode 100644 index 0000000..ff453b7 --- /dev/null +++ b/src/components/card-detail/LabelPicker.tsx @@ -0,0 +1,186 @@ +import { useState } from "react"; +import { Plus, Check } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useBoardStore } from "@/stores/board-store"; +import type { Label } from "@/types/board"; + +interface LabelPickerProps { + cardId: string; + cardLabelIds: string[]; + boardLabels: Label[]; +} + +const COLOR_SWATCHES = [ + "#ef4444", // red + "#f97316", // orange + "#eab308", // yellow + "#22c55e", // green + "#06b6d4", // cyan + "#3b82f6", // blue + "#8b5cf6", // violet + "#ec4899", // pink +]; + +export function LabelPicker({ + cardId, + cardLabelIds, + boardLabels, +}: LabelPickerProps) { + const toggleCardLabel = useBoardStore((s) => s.toggleCardLabel); + const addLabel = useBoardStore((s) => s.addLabel); + + const [newLabelName, setNewLabelName] = useState(""); + const [newLabelColor, setNewLabelColor] = useState(COLOR_SWATCHES[0]); + const [showCreate, setShowCreate] = useState(false); + + const currentLabels = boardLabels.filter((l) => cardLabelIds.includes(l.id)); + + function handleCreateLabel() { + const trimmed = newLabelName.trim(); + if (trimmed) { + addLabel(trimmed, newLabelColor); + setNewLabelName(""); + setShowCreate(false); + } + } + + function handleCreateKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") { + e.preventDefault(); + handleCreateLabel(); + } + } + + return ( +
+ {/* Header */} +
+

+ Labels +

+ + + + + +
+

+ Toggle labels +

+ + {/* Existing labels */} + {boardLabels.length > 0 && ( +
+ {boardLabels.map((label) => { + const isSelected = cardLabelIds.includes(label.id); + return ( + + ); + })} +
+ )} + + {/* Create new label */} + {showCreate ? ( +
+ setNewLabelName(e.target.value)} + onKeyDown={handleCreateKeyDown} + placeholder="Label name..." + className="h-7 rounded-md bg-pylon-column px-2 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60 focus:ring-1 focus:ring-pylon-accent" + /> +
+ {COLOR_SWATCHES.map((color) => ( +
+
+ + +
+
+ ) : ( + + )} +
+
+
+
+ + {/* Current labels display */} + {currentLabels.length > 0 ? ( +
+ {currentLabels.map((label) => ( + + {label.name} + + ))} +
+ ) : ( +

+ No labels +

+ )} +
+ ); +} diff --git a/src/components/card-detail/MarkdownEditor.tsx b/src/components/card-detail/MarkdownEditor.tsx new file mode 100644 index 0000000..a982691 --- /dev/null +++ b/src/components/card-detail/MarkdownEditor.tsx @@ -0,0 +1,121 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Button } from "@/components/ui/button"; +import { useBoardStore } from "@/stores/board-store"; + +interface MarkdownEditorProps { + cardId: string; + value: string; +} + +export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) { + const [mode, setMode] = useState<"edit" | "preview">("preview"); + const [draft, setDraft] = useState(value); + const textareaRef = useRef(null); + const updateCard = useBoardStore((s) => s.updateCard); + const debounceRef = useRef | null>(null); + + // Sync draft when value changes externally (e.g. undo) + useEffect(() => { + setDraft(value); + }, [value]); + + // Auto-focus textarea when switching to edit mode + useEffect(() => { + if (mode === "edit" && textareaRef.current) { + textareaRef.current.focus(); + } + }, [mode]); + + const save = useCallback( + (text: string) => { + if (text !== value) { + updateCard(cardId, { description: text }); + } + }, + [cardId, value, updateCard] + ); + + function handleChange(e: React.ChangeEvent) { + const text = e.target.value; + setDraft(text); + + // Debounced auto-save + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + save(text); + }, 300); + } + + function handleBlur() { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + save(draft); + } + + return ( +
+ {/* Mode toggle */} +
+ + +
+ + {/* Editor / Preview */} + {mode === "edit" ? ( +