From 7277bbdc215b2a37f2cf984ef76365a8cbf10764 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 16 Feb 2026 14:55:58 +0200 Subject: [PATCH] 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 --- src/components/board/VersionHistoryDialog.tsx | 119 +++++++++++++++ src/components/boards/BoardCard.tsx | 117 ++++++++++++++- src/components/boards/NewBoardDialog.tsx | 65 ++++++++- src/components/layout/TopBar.tsx | 14 ++ src/lib/board-factory.ts | 24 ++++ src/lib/storage.ts | 136 ++++++++++++++++++ src/types/template.ts | 15 ++ 7 files changed, 480 insertions(+), 10 deletions(-) create mode 100644 src/components/board/VersionHistoryDialog.tsx create mode 100644 src/types/template.ts diff --git a/src/components/board/VersionHistoryDialog.tsx b/src/components/board/VersionHistoryDialog.tsx new file mode 100644 index 0000000..7f00f77 --- /dev/null +++ b/src/components/board/VersionHistoryDialog.tsx @@ -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([]); + const [confirmRestore, setConfirmRestore] = useState(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 ( + <> + + + + + Version History + + + Browse and restore previous versions of this board. + + + + {backups.length > 0 ? ( +
+ {backups.map((backup) => ( +
+
+ + {formatDistanceToNow(new Date(backup.timestamp), { addSuffix: true })} + + + {backup.cardCount} cards, {backup.columnCount} columns + +
+ +
+ ))} +
+ ) : ( +

+ No backups yet. Backups are created automatically as you work. +

+ )} +
+
+
+ + {/* Restore confirmation */} + setConfirmRestore(null)}> + + + + Restore Version + + + This will replace the current board with the selected version. Your current state will be backed up first. + + + + + + + + + + ); +} diff --git a/src/components/boards/BoardCard.tsx b/src/components/boards/BoardCard.tsx index aec1106..556194e 100644 --- a/src/components/boards/BoardCard.tsx +++ b/src/components/boards/BoardCard.tsx @@ -1,8 +1,10 @@ import { useState } from "react"; import { formatDistanceToNow } from "date-fns"; 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 { Trash2, Copy } from "lucide-react"; +import { Trash2, Copy, FileDown, FileSpreadsheet, Bookmark } from "lucide-react"; import { ContextMenu, ContextMenuContent, @@ -23,15 +25,30 @@ import type { BoardMeta } from "@/types/board"; import { useAppStore } from "@/stores/app-store"; import { useBoardStore } from "@/stores/board-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 { board: BoardMeta; + sortable?: boolean; } -export function BoardCard({ board }: BoardCardProps) { +export function BoardCard({ board, sortable = false }: BoardCardProps) { const [confirmDelete, setConfirmDelete] = useState(false); const prefersReducedMotion = useReducedMotion(); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: board.id, + disabled: !sortable, + }); const addToast = useToastStore((s) => s.addToast); const setView = useAppStore((s) => s.setView); @@ -72,14 +89,95 @@ export function BoardCard({ board }: BoardCardProps) { 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 ( +
+ {/* Invisible clone to maintain grid row height */} +
+
+
+

 

+

 

+

 

+
+
+
+
+ ); + } + return ( @@ -118,6 +216,19 @@ export function BoardCard({ board }: BoardCardProps) { Duplicate + + + Save as Template + + + + + Export as JSON + + + + Export as CSV + ("blank"); + const [selectedUserTemplate, setSelectedUserTemplate] = useState(null); + const [userTemplates, setUserTemplates] = useState([]); 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) { -
+
{(["blank", "kanban", "sprint"] as const).map((t) => ( ))} + {userTemplates.map((ut) => ( +
+ + +
+ ))}
diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index a05ce7f..15dce3d 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -11,13 +11,16 @@ import { import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { VersionHistoryDialog } from "@/components/board/VersionHistoryDialog"; import { useAppStore } from "@/stores/app-store"; import { useBoardStore } from "@/stores/board-store"; import { WindowControls } from "@/components/layout/WindowControls"; @@ -32,6 +35,7 @@ export function TopBar() { const isBoardView = view.type === "board"; + const [showVersionHistory, setShowVersionHistory] = useState(false); const [editing, setEditing] = useState(false); const [editValue, setEditValue] = useState(""); const inputRef = useRef(null); @@ -240,6 +244,10 @@ export function TopBar() { + + setShowVersionHistory(true)}> + Version History + )} @@ -279,6 +287,12 @@ export function TopBar() {
+ {isBoardView && ( + + )} ); } diff --git a/src/lib/board-factory.ts b/src/lib/board-factory.ts index 8fab7ed..a523fdf 100644 --- a/src/lib/board-factory.ts +++ b/src/lib/board-factory.ts @@ -1,5 +1,6 @@ import { ulid } from "ulid"; import type { Board, ColumnWidth } from "@/types/board"; +import type { BoardTemplate } from "@/types/template"; type Template = "blank" | "kanban" | "sprint"; @@ -45,3 +46,26 @@ export function createBoard( 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 }, + }; +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index a8a901c..64f3472 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -12,6 +12,7 @@ import { invoke } from "@tauri-apps/api/core"; import { boardSchema, appSettingsSchema } from "./schemas"; import type { Board, BoardMeta } from "@/types/board"; import type { AppSettings } from "@/types/settings"; +import type { BoardTemplate } from "@/types/template"; // --------------------------------------------------------------------------- // Path helpers — portable: all data lives next to the exe @@ -36,6 +37,16 @@ async function getSettingsPath(): Promise { return join(base, "settings.json"); } +async function getTemplatesDir(): Promise { + const base = await getBaseDir(); + return join(base, "templates"); +} + +async function getBackupsDir(boardId: string): Promise { + const base = await getBaseDir(); + return join(base, "backups", boardId); +} + function boardFilePath(boardsDir: string, boardId: string): Promise { return join(boardsDir, `${boardId}.json`); } @@ -63,6 +74,16 @@ export async function ensureDataDirs(): Promise { if (!(await exists(attachmentsDir))) { 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 { color: board.color, cardCount: Object.keys(board.cards).length, columnCount: board.columns.length, + createdAt: board.createdAt, updatedAt: board.updatedAt, }); } catch { @@ -160,6 +182,14 @@ export async function saveBoard(board: Board): Promise { try { const previous = await readTextFile(filePath); 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 { // If we can't create a backup, continue saving anyway } @@ -274,6 +304,112 @@ export async function searchAllBoards(query: string): Promise { // Attachment helpers // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Templates +// --------------------------------------------------------------------------- + +export async function listTemplates(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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( boardId: string, sourcePath: string, diff --git a/src/types/template.ts b/src/types/template.ts new file mode 100644 index 0000000..9175970 --- /dev/null +++ b/src/types/template.ts @@ -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; +}