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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user