227 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|