206 lines
6.2 KiB
TypeScript
206 lines
6.2 KiB
TypeScript
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 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">
|
|
<CoverColorPicker cardId={cardId} coverColor={card.coverColor} />
|
|
|
|
<Separator />
|
|
|
|
<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}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
/* ---------- Cover color picker ---------- */
|
|
|
|
function CoverColorPicker({ cardId, coverColor }: { cardId: string; coverColor: string | null }) {
|
|
const updateCard = useBoardStore((s) => s.updateCard);
|
|
const presets = [
|
|
{ hue: "160", label: "Teal" }, { hue: "240", label: "Blue" },
|
|
{ hue: "300", label: "Purple" }, { hue: "350", label: "Pink" },
|
|
{ hue: "25", label: "Red" }, { hue: "55", label: "Orange" },
|
|
{ hue: "85", label: "Yellow" }, { hue: "130", label: "Lime" },
|
|
{ hue: "200", label: "Cyan" }, { hue: "0", label: "Slate" },
|
|
];
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
|
|
Cover
|
|
</h4>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
<button
|
|
onClick={() => updateCard(cardId, { coverColor: null })}
|
|
className="flex size-6 items-center justify-center rounded-full border border-border text-xs text-pylon-text-secondary hover:bg-pylon-column"
|
|
title="None"
|
|
>
|
|
×
|
|
</button>
|
|
{presets.map(({ hue, label }) => (
|
|
<button
|
|
key={hue}
|
|
onClick={() => updateCard(cardId, { coverColor: hue })}
|
|
className="size-6 rounded-full transition-transform hover:scale-110"
|
|
style={{
|
|
backgroundColor: `oklch(55% 0.12 ${hue})`,
|
|
outline: coverColor === hue ? "2px solid currentColor" : "none",
|
|
outlineOffset: "1px",
|
|
}}
|
|
title={label}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|