Files
openpylon/src/components/boards/NewBoardDialog.tsx

227 lines
7.4 KiB
TypeScript

import { useState, useEffect } from "react";
import { X } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { createBoard, createBoardFromTemplate } from "@/lib/board-factory";
import { saveBoard, listTemplates, deleteTemplate } from "@/lib/storage";
import { useAppStore } from "@/stores/app-store";
import { useBoardStore } from "@/stores/board-store";
import type { BoardTemplate } from "@/types/template";
const PRESET_COLORS = [
"#6366f1", // indigo
"#8b5cf6", // violet
"#ec4899", // pink
"#f43f5e", // rose
"#f97316", // orange
"#eab308", // yellow
"#22c55e", // green
"#06b6d4", // cyan
];
type Template = "blank" | "kanban" | "sprint";
interface NewBoardDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function NewBoardDialog({ open, onOpenChange }: NewBoardDialogProps) {
const [title, setTitle] = useState("");
const [color, setColor] = useState(PRESET_COLORS[0]);
const [template, setTemplate] = useState<Template>("blank");
const [selectedUserTemplate, setSelectedUserTemplate] = useState<BoardTemplate | null>(null);
const [userTemplates, setUserTemplates] = useState<BoardTemplate[]>([]);
const [creating, setCreating] = useState(false);
const refreshBoards = useAppStore((s) => s.refreshBoards);
const setView = useAppStore((s) => s.setView);
const addRecentBoard = useAppStore((s) => s.addRecentBoard);
const openBoard = useBoardStore((s) => s.openBoard);
useEffect(() => {
if (open) {
listTemplates().then(setUserTemplates);
}
}, [open]);
async function handleCreate() {
const trimmed = title.trim();
if (!trimmed || creating) return;
setCreating(true);
try {
const board = selectedUserTemplate
? createBoardFromTemplate(selectedUserTemplate, trimmed)
: createBoard(trimmed, color, template);
if (selectedUserTemplate) {
// Use color from template, but override if user picked a different color
// (we keep template color by default)
} else {
// color already set on board via createBoard
}
await saveBoard(board);
await refreshBoards();
await openBoard(board.id);
setView({ type: "board", boardId: board.id });
addRecentBoard(board.id);
onOpenChange(false);
resetForm();
} finally {
setCreating(false);
}
}
function resetForm() {
setTitle("");
setColor(PRESET_COLORS[0]);
setTemplate("blank");
setSelectedUserTemplate(null);
}
async function handleDeleteTemplate(templateId: string) {
await deleteTemplate(templateId);
setUserTemplates((prev) => prev.filter((t) => t.id !== templateId));
if (selectedUserTemplate?.id === templateId) {
setSelectedUserTemplate(null);
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && title.trim()) {
handleCreate();
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-pylon-surface sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-heading text-pylon-text">
New Board
</DialogTitle>
<DialogDescription className="text-pylon-text-secondary">
Create a new board to organize your work.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
{/* Title input */}
<div>
<label
htmlFor="board-title"
className="mb-1.5 block font-mono text-xs uppercase tracking-widest text-pylon-text-secondary"
>
Title
</label>
<Input
id="board-title"
placeholder="My Board"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
className="bg-pylon-bg text-pylon-text"
/>
</div>
{/* Color picker */}
<div>
<label className="mb-1.5 block font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Color
</label>
<div className="flex gap-2">
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className="size-8 rounded-full transition-transform hover:scale-110"
style={{
backgroundColor: c,
outline:
color === c ? "2px solid currentColor" : "none",
outlineOffset: "2px",
}}
aria-label={`Color ${c}`}
/>
))}
</div>
</div>
{/* Template selector */}
<div>
<label className="mb-1.5 block font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Template
</label>
<div className="flex flex-wrap gap-2">
{(["blank", "kanban", "sprint"] as const).map((t) => (
<Button
key={t}
type="button"
variant={template === t && !selectedUserTemplate ? "default" : "outline"}
size="sm"
onClick={() => { setTemplate(t); setSelectedUserTemplate(null); }}
aria-pressed={template === t && !selectedUserTemplate}
className="capitalize"
>
{t}
</Button>
))}
{userTemplates.map((ut) => (
<div key={ut.id} className="flex items-center gap-0.5">
<Button
type="button"
variant={selectedUserTemplate?.id === ut.id ? "default" : "outline"}
size="sm"
onClick={() => { setSelectedUserTemplate(ut); setColor(ut.color); }}
aria-pressed={selectedUserTemplate?.id === ut.id}
>
<span
className="inline-block size-2 rounded-full shrink-0"
style={{ backgroundColor: ut.color }}
/>
{ut.name}
</Button>
<button
type="button"
onClick={() => handleDeleteTemplate(ut.id)}
className="rounded p-0.5 text-pylon-text-secondary opacity-0 hover:opacity-100 hover:bg-pylon-danger/10 hover:text-pylon-danger transition-opacity"
aria-label="Delete template"
>
<X className="size-3" />
</button>
</div>
))}
</div>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
className="text-pylon-text-secondary"
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={!title.trim() || creating}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}