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:
119
src/components/board/VersionHistoryDialog.tsx
Normal file
119
src/components/board/VersionHistoryDialog.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"> </h3>
|
||||||
|
<p className="font-mono text-xs"> </p>
|
||||||
|
<p className="font-mono text-xs"> </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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
15
src/types/template.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user