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

@@ -0,0 +1,119 @@
import { useState, useEffect } from "react";
import { formatDistanceToNow } from "date-fns";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { listBackups, restoreBackupFile, saveBoard, type BackupEntry } from "@/lib/storage";
import { useBoardStore } from "@/stores/board-store";
interface VersionHistoryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function VersionHistoryDialog({ open, onOpenChange }: VersionHistoryDialogProps) {
const board = useBoardStore((s) => s.board);
const [backups, setBackups] = useState<BackupEntry[]>([]);
const [confirmRestore, setConfirmRestore] = useState<BackupEntry | null>(null);
useEffect(() => {
if (open && board) {
listBackups(board.id).then(setBackups);
}
}, [open, board]);
async function handleRestore(backup: BackupEntry) {
if (!board) return;
// Back up current state before restoring
await saveBoard(board);
const restored = await restoreBackupFile(board.id, backup.filename);
await saveBoard(restored);
// Reload
await useBoardStore.getState().openBoard(board.id);
setConfirmRestore(null);
onOpenChange(false);
}
return (
<>
<Dialog open={open && !confirmRestore} onOpenChange={onOpenChange}>
<DialogContent className="bg-pylon-surface sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-heading text-pylon-text">
Version History
</DialogTitle>
<DialogDescription className="text-pylon-text-secondary">
Browse and restore previous versions of this board.
</DialogDescription>
</DialogHeader>
<OverlayScrollbarsComponent
className="max-h-[300px]"
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { x: "hidden" } }}
defer
>
{backups.length > 0 ? (
<div className="flex flex-col gap-1">
{backups.map((backup) => (
<div
key={backup.filename}
className="flex items-center justify-between rounded px-3 py-2 hover:bg-pylon-column/60"
>
<div className="flex flex-col">
<span className="text-sm text-pylon-text">
{formatDistanceToNow(new Date(backup.timestamp), { addSuffix: true })}
</span>
<span className="font-mono text-xs text-pylon-text-secondary">
{backup.cardCount} cards, {backup.columnCount} columns
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmRestore(backup)}
className="text-pylon-accent"
>
Restore
</Button>
</div>
))}
</div>
) : (
<p className="px-3 py-6 text-center text-sm text-pylon-text-secondary">
No backups yet. Backups are created automatically as you work.
</p>
)}
</OverlayScrollbarsComponent>
</DialogContent>
</Dialog>
{/* Restore confirmation */}
<Dialog open={confirmRestore != null} onOpenChange={() => setConfirmRestore(null)}>
<DialogContent className="bg-pylon-surface sm:max-w-sm">
<DialogHeader>
<DialogTitle className="font-heading text-pylon-text">
Restore Version
</DialogTitle>
<DialogDescription className="text-pylon-text-secondary">
This will replace the current board with the selected version. Your current state will be backed up first.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfirmRestore(null)} className="text-pylon-text-secondary">
Cancel
</Button>
<Button onClick={() => confirmRestore && handleRestore(confirmRestore)}>
Restore
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,8 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { motion, useReducedMotion } from "framer-motion"; import { motion, useReducedMotion } from "framer-motion";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { fadeSlideUp, springs, subtleHover } from "@/lib/motion"; import { fadeSlideUp, springs, subtleHover } from "@/lib/motion";
import { Trash2, Copy } from "lucide-react"; import { Trash2, Copy, FileDown, FileSpreadsheet, Bookmark } from "lucide-react";
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@@ -23,15 +25,30 @@ import type { BoardMeta } from "@/types/board";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
import { useToastStore } from "@/stores/toast-store"; import { useToastStore } from "@/stores/toast-store";
import { deleteBoard, loadBoard, saveBoard } from "@/lib/storage"; import { deleteBoard, loadBoard, saveBoard, saveTemplate } from "@/lib/storage";
import { exportBoardAsJson, exportBoardAsCsv } from "@/lib/import-export";
import type { BoardTemplate } from "@/types/template";
interface BoardCardProps { interface BoardCardProps {
board: BoardMeta; board: BoardMeta;
sortable?: boolean;
} }
export function BoardCard({ board }: BoardCardProps) { export function BoardCard({ board, sortable = false }: BoardCardProps) {
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: board.id,
disabled: !sortable,
});
const addToast = useToastStore((s) => s.addToast); const addToast = useToastStore((s) => s.addToast);
const setView = useAppStore((s) => s.setView); const setView = useAppStore((s) => s.setView);
@@ -72,14 +89,95 @@ export function BoardCard({ board }: BoardCardProps) {
addToast(`"${board.title}" duplicated`, "success"); addToast(`"${board.title}" duplicated`, "success");
} }
function downloadBlob(content: string, filename: string, mimeType: string) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async function handleExportJson() {
const full = await loadBoard(board.id);
const json = exportBoardAsJson(full);
const safeName = board.title.replace(/[^a-zA-Z0-9-_]/g, "_");
downloadBlob(json, `${safeName}.json`, "application/json");
addToast("Board exported as JSON", "success");
}
async function handleExportCsv() {
const full = await loadBoard(board.id);
const csv = exportBoardAsCsv(full);
const safeName = board.title.replace(/[^a-zA-Z0-9-_]/g, "_");
downloadBlob(csv, `${safeName}.csv`, "text/csv");
addToast("Board exported as CSV", "success");
}
async function handleSaveAsTemplate() {
const full = await loadBoard(board.id);
const { ulid } = await import("ulid");
const template: BoardTemplate = {
id: ulid(),
name: full.title,
color: full.color,
columns: full.columns.map((c) => ({
title: c.title,
width: c.width,
color: c.color,
wipLimit: c.wipLimit,
})),
labels: full.labels,
settings: full.settings,
};
await saveTemplate(template);
addToast(`Template "${full.title}" saved`, "success");
}
if (isDragging) {
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
className="relative"
{...attributes}
{...listeners}
>
{/* Invisible clone to maintain grid row height */}
<div className="invisible flex flex-col rounded-lg p-4">
<div className="h-1" />
<div className="flex flex-col gap-2">
<h3 className="font-heading text-lg">&nbsp;</h3>
<p className="font-mono text-xs">&nbsp;</p>
<p className="font-mono text-xs">&nbsp;</p>
</div>
</div>
<div className="absolute -left-2 top-0 bottom-0 w-[3px] rounded-full bg-pylon-accent shadow-[0_0_6px_var(--pylon-accent),0_0_14px_var(--pylon-accent)]" />
</div>
);
}
return ( return (
<motion.div <motion.div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
variants={fadeSlideUp} variants={fadeSlideUp}
initial={prefersReducedMotion ? false : "hidden"} initial={prefersReducedMotion ? false : "hidden"}
animate="visible" animate="visible"
transition={springs.bouncy} transition={springs.bouncy}
whileHover={subtleHover.hover} whileHover={subtleHover.hover}
whileTap={subtleHover.tap} whileTap={subtleHover.tap}
{...attributes}
{...(sortable ? listeners : {})}
> >
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
@@ -118,6 +216,19 @@ export function BoardCard({ board }: BoardCardProps) {
<Copy className="size-4" /> <Copy className="size-4" />
Duplicate Duplicate
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem onClick={handleSaveAsTemplate}>
<Bookmark className="size-4" />
Save as Template
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleExportJson}>
<FileDown className="size-4" />
Export as JSON
</ContextMenuItem>
<ContextMenuItem onClick={handleExportCsv}>
<FileSpreadsheet className="size-4" />
Export as CSV
</ContextMenuItem>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
variant="destructive" variant="destructive"

View File

@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { X } from "lucide-react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -9,10 +10,11 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { createBoard } from "@/lib/board-factory"; import { createBoard, createBoardFromTemplate } from "@/lib/board-factory";
import { saveBoard } from "@/lib/storage"; import { saveBoard, listTemplates, deleteTemplate } from "@/lib/storage";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
import type { BoardTemplate } from "@/types/template";
const PRESET_COLORS = [ const PRESET_COLORS = [
"#6366f1", // indigo "#6366f1", // indigo
@@ -36,6 +38,8 @@ export function NewBoardDialog({ open, onOpenChange }: NewBoardDialogProps) {
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [color, setColor] = useState(PRESET_COLORS[0]); const [color, setColor] = useState(PRESET_COLORS[0]);
const [template, setTemplate] = useState<Template>("blank"); const [template, setTemplate] = useState<Template>("blank");
const [selectedUserTemplate, setSelectedUserTemplate] = useState<BoardTemplate | null>(null);
const [userTemplates, setUserTemplates] = useState<BoardTemplate[]>([]);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const refreshBoards = useAppStore((s) => s.refreshBoards); const refreshBoards = useAppStore((s) => s.refreshBoards);
@@ -43,13 +47,27 @@ export function NewBoardDialog({ open, onOpenChange }: NewBoardDialogProps) {
const addRecentBoard = useAppStore((s) => s.addRecentBoard); const addRecentBoard = useAppStore((s) => s.addRecentBoard);
const openBoard = useBoardStore((s) => s.openBoard); const openBoard = useBoardStore((s) => s.openBoard);
useEffect(() => {
if (open) {
listTemplates().then(setUserTemplates);
}
}, [open]);
async function handleCreate() { async function handleCreate() {
const trimmed = title.trim(); const trimmed = title.trim();
if (!trimmed || creating) return; if (!trimmed || creating) return;
setCreating(true); setCreating(true);
try { 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 saveBoard(board);
await refreshBoards(); await refreshBoards();
await openBoard(board.id); await openBoard(board.id);
@@ -66,6 +84,15 @@ export function NewBoardDialog({ open, onOpenChange }: NewBoardDialogProps) {
setTitle(""); setTitle("");
setColor(PRESET_COLORS[0]); setColor(PRESET_COLORS[0]);
setTemplate("blank"); 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) { 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"> <label className="mb-1.5 block font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Template Template
</label> </label>
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
{(["blank", "kanban", "sprint"] as const).map((t) => ( {(["blank", "kanban", "sprint"] as const).map((t) => (
<Button <Button
key={t} key={t}
type="button" type="button"
variant={template === t ? "default" : "outline"} variant={template === t && !selectedUserTemplate ? "default" : "outline"}
size="sm" size="sm"
onClick={() => setTemplate(t)} onClick={() => { setTemplate(t); setSelectedUserTemplate(null); }}
className="capitalize" className="capitalize"
> >
{t} {t}
</Button> </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> </div>
</div> </div>

View File

@@ -11,13 +11,16 @@ import {
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { VersionHistoryDialog } from "@/components/board/VersionHistoryDialog";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
import { WindowControls } from "@/components/layout/WindowControls"; import { WindowControls } from "@/components/layout/WindowControls";
@@ -32,6 +35,7 @@ export function TopBar() {
const isBoardView = view.type === "board"; const isBoardView = view.type === "board";
const [showVersionHistory, setShowVersionHistory] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(""); const [editValue, setEditValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -240,6 +244,10 @@ export function TopBar() {
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowVersionHistory(true)}>
Version History
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
@@ -279,6 +287,12 @@ export function TopBar() {
</Tooltip> </Tooltip>
<WindowControls /> <WindowControls />
</div> </div>
{isBoardView && (
<VersionHistoryDialog
open={showVersionHistory}
onOpenChange={setShowVersionHistory}
/>
)}
</header> </header>
); );
} }

View File

@@ -1,5 +1,6 @@
import { ulid } from "ulid"; import { ulid } from "ulid";
import type { Board, ColumnWidth } from "@/types/board"; import type { Board, ColumnWidth } from "@/types/board";
import type { BoardTemplate } from "@/types/template";
type Template = "blank" | "kanban" | "sprint"; type Template = "blank" | "kanban" | "sprint";
@@ -45,3 +46,26 @@ export function createBoard(
return board; return board;
} }
export function createBoardFromTemplate(template: BoardTemplate, title: string): Board {
const ts = new Date().toISOString();
return {
id: ulid(),
title,
color: template.color,
createdAt: ts,
updatedAt: ts,
columns: template.columns.map((c) => ({
id: ulid(),
title: c.title,
cardIds: [],
width: c.width,
color: c.color,
collapsed: false,
wipLimit: c.wipLimit,
})),
cards: {},
labels: template.labels.map((l) => ({ ...l, id: ulid() })),
settings: { ...template.settings },
};
}

View File

@@ -12,6 +12,7 @@ import { invoke } from "@tauri-apps/api/core";
import { boardSchema, appSettingsSchema } from "./schemas"; import { boardSchema, appSettingsSchema } from "./schemas";
import type { Board, BoardMeta } from "@/types/board"; import type { Board, BoardMeta } from "@/types/board";
import type { AppSettings } from "@/types/settings"; import type { AppSettings } from "@/types/settings";
import type { BoardTemplate } from "@/types/template";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Path helpers — portable: all data lives next to the exe // Path helpers — portable: all data lives next to the exe
@@ -36,6 +37,16 @@ async function getSettingsPath(): Promise<string> {
return join(base, "settings.json"); return join(base, "settings.json");
} }
async function getTemplatesDir(): Promise<string> {
const base = await getBaseDir();
return join(base, "templates");
}
async function getBackupsDir(boardId: string): Promise<string> {
const base = await getBaseDir();
return join(base, "backups", boardId);
}
function boardFilePath(boardsDir: string, boardId: string): Promise<string> { function boardFilePath(boardsDir: string, boardId: string): Promise<string> {
return join(boardsDir, `${boardId}.json`); return join(boardsDir, `${boardId}.json`);
} }
@@ -63,6 +74,16 @@ export async function ensureDataDirs(): Promise<void> {
if (!(await exists(attachmentsDir))) { if (!(await exists(attachmentsDir))) {
await mkdir(attachmentsDir, { recursive: true }); await mkdir(attachmentsDir, { recursive: true });
} }
const templatesDir = await getTemplatesDir();
if (!(await exists(templatesDir))) {
await mkdir(templatesDir, { recursive: true });
}
const backupsDir = await join(base, "backups");
if (!(await exists(backupsDir))) {
await mkdir(backupsDir, { recursive: true });
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -124,6 +145,7 @@ export async function listBoards(): Promise<BoardMeta[]> {
color: board.color, color: board.color,
cardCount: Object.keys(board.cards).length, cardCount: Object.keys(board.cards).length,
columnCount: board.columns.length, columnCount: board.columns.length,
createdAt: board.createdAt,
updatedAt: board.updatedAt, updatedAt: board.updatedAt,
}); });
} catch { } catch {
@@ -160,6 +182,14 @@ export async function saveBoard(board: Board): Promise<void> {
try { try {
const previous = await readTextFile(filePath); const previous = await readTextFile(filePath);
await writeTextFile(backupPath, previous); await writeTextFile(backupPath, previous);
// Create timestamped backup (throttled: only if last backup > 5 min ago)
const backups = await listBackups(board.id);
const recentThreshold = new Date(Date.now() - 5 * 60 * 1000).toISOString();
if (backups.length === 0 || backups[0].timestamp < recentThreshold) {
await createBackup(JSON.parse(previous) as Board);
await pruneBackups(board.id);
}
} catch { } catch {
// If we can't create a backup, continue saving anyway // If we can't create a backup, continue saving anyway
} }
@@ -274,6 +304,112 @@ export async function searchAllBoards(query: string): Promise<SearchResult[]> {
// Attachment helpers // Attachment helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Templates
// ---------------------------------------------------------------------------
export async function listTemplates(): Promise<BoardTemplate[]> {
const dir = await getTemplatesDir();
if (!(await exists(dir))) return [];
const entries = await readDir(dir);
const templates: BoardTemplate[] = [];
for (const entry of entries) {
if (!entry.name || !entry.name.endsWith(".json")) continue;
try {
const filePath = await join(dir, entry.name);
const raw = await readTextFile(filePath);
templates.push(JSON.parse(raw));
} catch { continue; }
}
return templates;
}
export async function saveTemplate(template: BoardTemplate): Promise<void> {
const dir = await getTemplatesDir();
const filePath = await join(dir, `${template.id}.json`);
await writeTextFile(filePath, JSON.stringify(template, null, 2));
}
export async function deleteTemplate(templateId: string): Promise<void> {
const dir = await getTemplatesDir();
const filePath = await join(dir, `${templateId}.json`);
if (await exists(filePath)) {
await remove(filePath);
}
}
// ---------------------------------------------------------------------------
// Backups / Version History
// ---------------------------------------------------------------------------
export interface BackupEntry {
filename: string;
timestamp: string;
cardCount: number;
columnCount: number;
}
export async function listBackups(boardId: string): Promise<BackupEntry[]> {
const dir = await getBackupsDir(boardId);
if (!(await exists(dir))) return [];
const entries = await readDir(dir);
const backups: BackupEntry[] = [];
for (const entry of entries) {
if (!entry.name || !entry.name.endsWith(".json")) continue;
try {
const filePath = await join(dir, entry.name);
const raw = await readTextFile(filePath);
const data = JSON.parse(raw);
const board = boardSchema.parse(data);
const isoMatch = entry.name.match(/\d{4}-\d{2}-\d{2}T[\d.]+Z/);
backups.push({
filename: entry.name,
timestamp: isoMatch ? isoMatch[0].replace(/-(?=\d{2}-\d{2}T)/g, "-") : board.updatedAt,
cardCount: Object.keys(board.cards).length,
columnCount: board.columns.length,
});
} catch { continue; }
}
backups.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
return backups;
}
export async function createBackup(board: Board): Promise<void> {
const dir = await getBackupsDir(board.id);
if (!(await exists(dir))) {
await mkdir(dir, { recursive: true });
}
const ts = new Date().toISOString().replace(/:/g, "-");
const filename = `${board.id}-${ts}.json`;
const filePath = await join(dir, filename);
await writeTextFile(filePath, JSON.stringify(board, null, 2));
}
export async function pruneBackups(boardId: string, keep: number = 10): Promise<void> {
const backups = await listBackups(boardId);
if (backups.length <= keep) return;
const dir = await getBackupsDir(boardId);
const toDelete = backups.slice(keep);
for (const backup of toDelete) {
try {
const filePath = await join(dir, backup.filename);
await remove(filePath);
} catch { /* skip */ }
}
}
export async function restoreBackupFile(boardId: string, filename: string): Promise<Board> {
const dir = await getBackupsDir(boardId);
const filePath = await join(dir, filename);
const raw = await readTextFile(filePath);
const data = JSON.parse(raw);
return boardSchema.parse(data) as Board;
}
// ---------------------------------------------------------------------------
// Attachment helpers
// ---------------------------------------------------------------------------
export async function copyAttachment( export async function copyAttachment(
boardId: string, boardId: string,
sourcePath: string, sourcePath: string,

15
src/types/template.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { ColumnWidth, Label, BoardSettings } from "./board";
export interface BoardTemplate {
id: string;
name: string;
color: string;
columns: {
title: string;
width: ColumnWidth;
color: string | null;
wipLimit: number | null;
}[];
labels: Label[];
settings: BoardSettings;
}