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,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 (
<div className="flex flex-col gap-2">
{/* Header */}
<div className="flex items-center justify-between">
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Labels
</h4>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="text-pylon-text-secondary hover:text-pylon-text"
>
<Plus className="size-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-64 bg-pylon-surface p-3">
<div className="flex flex-col gap-2">
<p className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Toggle labels
</p>
{/* Existing labels */}
{boardLabels.length > 0 && (
<div className="flex max-h-40 flex-col gap-1 overflow-y-auto">
{boardLabels.map((label) => {
const isSelected = cardLabelIds.includes(label.id);
return (
<button
key={label.id}
onClick={() => toggleCardLabel(cardId, label.id)}
className="flex items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors hover:bg-pylon-column"
>
<span
className="size-3 shrink-0 rounded-full"
style={{ backgroundColor: label.color }}
/>
<span className="flex-1 truncate text-pylon-text">
{label.name}
</span>
{isSelected && (
<Check className="size-3.5 shrink-0 text-pylon-accent" />
)}
</button>
);
})}
</div>
)}
{/* Create new label */}
{showCreate ? (
<div className="flex flex-col gap-2 border-t border-pylon-text-secondary/20 pt-2">
<input
autoFocus
value={newLabelName}
onChange={(e) => 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"
/>
<div className="flex flex-wrap gap-1.5">
{COLOR_SWATCHES.map((color) => (
<button
key={color}
onClick={() => setNewLabelColor(color)}
className="size-5 rounded-full transition-transform hover:scale-110"
style={{
backgroundColor: color,
outline:
newLabelColor === color
? "2px solid var(--pylon-accent)"
: "none",
outlineOffset: "1px",
}}
/>
))}
</div>
<div className="flex gap-1">
<Button size="xs" onClick={handleCreateLabel}>
Add
</Button>
<Button
variant="ghost"
size="xs"
onClick={() => {
setShowCreate(false);
setNewLabelName("");
}}
>
Cancel
</Button>
</div>
</div>
) : (
<button
onClick={() => setShowCreate(true)}
className="border-t border-pylon-text-secondary/20 pt-2 text-left text-xs text-pylon-text-secondary transition-colors hover:text-pylon-text"
>
+ Create label
</button>
)}
</div>
</PopoverContent>
</Popover>
</div>
{/* Current labels display */}
{currentLabels.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{currentLabels.map((label) => (
<span
key={label.id}
className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium text-white"
style={{ backgroundColor: label.color }}
>
{label.name}
</span>
))}
</div>
) : (
<p className="text-xs italic text-pylon-text-secondary/60">
No labels
</p>
)}
</div>
);
}