feat: Phase 4 - board templates, auto-backup, version history

- BoardTemplate type and storage CRUD (save/list/delete templates)
- createBoardFromTemplate factory function
- "Save as Template" in board card context menu
- User templates shown in NewBoardDialog with delete option
- Auto-backup on save with 5-minute throttle, 10 backup retention
- VersionHistoryDialog with backup list and restore confirmation
- Version History accessible from board settings dropdown
This commit is contained in:
Your Name
2026-02-16 14:55:58 +02:00
parent 6340beb5d0
commit 2e2740427e
7 changed files with 480 additions and 10 deletions

View File

@@ -1,4 +1,5 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { X } from "lucide-react";
import {
Dialog,
DialogContent,
@@ -9,10 +10,11 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { createBoard } from "@/lib/board-factory";
import { saveBoard } from "@/lib/storage";
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
@@ -36,6 +38,8 @@ 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);
@@ -43,13 +47,27 @@ export function NewBoardDialog({ open, onOpenChange }: NewBoardDialogProps) {
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 = createBoard(trimmed, color, template);
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);
@@ -66,6 +84,15 @@ export function NewBoardDialog({ open, onOpenChange }: NewBoardDialogProps) {
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) {
@@ -135,19 +162,43 @@ export function NewBoardDialog({ open, onOpenChange }: NewBoardDialogProps) {
<label className="mb-1.5 block font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Template
</label>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
{(["blank", "kanban", "sprint"] as const).map((t) => (
<Button
key={t}
type="button"
variant={template === t ? "default" : "outline"}
variant={template === t && !selectedUserTemplate ? "default" : "outline"}
size="sm"
onClick={() => setTemplate(t)}
onClick={() => { setTemplate(t); setSelectedUserTemplate(null); }}
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); }}
>
<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"
title="Delete template"
>
<X className="size-3" />
</button>
</div>
))}
</div>
</div>
</div>