Files
openpylon/src/components/layout/TopBar.tsx
Your Name 7277bbdc21 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
2026-02-16 14:55:58 +02:00

307 lines
11 KiB
TypeScript

import { useState, useRef, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { springs } from "@/lib/motion";
import { ArrowLeft, Settings, Search, Undo2, Redo2, SlidersHorizontal, Filter } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
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";
export function TopBar() {
const view = useAppStore((s) => s.view);
const setView = useAppStore((s) => s.setView);
const board = useBoardStore((s) => s.board);
const updateBoardTitle = useBoardStore((s) => s.updateBoardTitle);
const saving = useBoardStore((s) => s.saving);
const lastSaved = useBoardStore((s) => s.lastSaved);
const isBoardView = view.type === "board";
const [showVersionHistory, setShowVersionHistory] = useState(false);
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
const startEditing = useCallback(() => {
if (board) {
setEditValue(board.title);
setEditing(true);
}
}, [board]);
const commitEdit = useCallback(() => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== board?.title) {
updateBoardTitle(trimmed);
}
setEditing(false);
}, [editValue, board?.title, updateBoardTitle]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
commitEdit();
} else if (e.key === "Escape") {
setEditing(false);
}
},
[commitEdit]
);
const savingStatus = saving
? "Saving..."
: lastSaved
? `Saved ${formatTimeAgo(lastSaved)}`
: null;
return (
<header
data-tauri-drag-region
className="flex h-12 shrink-0 items-center gap-2 bg-pylon-surface px-3"
style={{ borderBottom: isBoardView && board ? `2px solid ${board.color}` : '1px solid var(--border)' }}
>
{/* Left section */}
<div data-tauri-drag-region className="flex items-center gap-2">
{isBoardView && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
useBoardStore.getState().closeBoard();
setView({ type: "board-list" });
}}
className="text-pylon-text-secondary hover:text-pylon-text"
>
<ArrowLeft className="size-4" />
<span>Boards</span>
</Button>
</TooltipTrigger>
<TooltipContent>Back to board list</TooltipContent>
</Tooltip>
)}
</div>
{/* Center section */}
<div data-tauri-drag-region className="flex flex-1 items-center justify-center select-none">
{isBoardView && board ? (
editing ? (
<input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={handleKeyDown}
className="h-7 rounded-md border border-border bg-transparent px-2 text-center font-heading text-lg text-pylon-text outline-none focus:border-pylon-accent"
/>
) : (
<button
onClick={startEditing}
className="flex items-center gap-1.5 rounded-md px-2 py-0.5 font-heading text-lg text-pylon-text hover:bg-pylon-column transition-colors"
>
<span
className="inline-block size-2.5 rounded-full shrink-0"
style={{ backgroundColor: board.color }}
/>
{board.title}
</button>
)
) : (
<span className="pointer-events-none font-heading text-lg text-pylon-text">
OpenPylon
</span>
)}
</div>
{/* Right section */}
<div className="flex items-center gap-1">
{isBoardView && (
<>
<AnimatePresence mode="wait">
{savingStatus && (
<motion.span
key={savingStatus}
className="font-mono text-xs text-pylon-text-secondary"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={springs.snappy}
>
{savingStatus}
</motion.span>
)}
</AnimatePresence>
<div className="mx-2 h-4 w-px bg-pylon-text-secondary/20" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-pylon-text-secondary hover:text-pylon-text"
onClick={() => useBoardStore.temporal.getState().undo()}
>
<Undo2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Undo <kbd className="ml-1 font-mono text-[10px] opacity-60">Ctrl+Z</kbd>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-pylon-text-secondary hover:text-pylon-text"
onClick={() => useBoardStore.temporal.getState().redo()}
>
<Redo2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Redo <kbd className="ml-1 font-mono text-[10px] opacity-60">Ctrl+Shift+Z</kbd>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-pylon-text-secondary hover:text-pylon-text"
onClick={() => document.dispatchEvent(new CustomEvent("toggle-filter-bar"))}
>
<Filter className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Filter cards <kbd className="ml-1 font-mono text-[10px] opacity-60">/</kbd>
</TooltipContent>
</Tooltip>
</>
)}
{isBoardView && board && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-pylon-text-secondary hover:text-pylon-text"
>
<SlidersHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuSub>
<DropdownMenuSubTrigger>Background</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={board.settings.background}
onValueChange={(v) => useBoardStore.getState().updateBoardSettings({ ...board.settings, background: v as typeof board.settings.background })}
>
{(["none", "dots", "grid", "gradient"] as const).map((bg) => (
<DropdownMenuRadioItem key={bg} value={bg}>
{bg.charAt(0).toUpperCase() + bg.slice(1)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Attachments</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={board.settings.attachmentMode}
onValueChange={(v) => useBoardStore.getState().updateBoardSettings({ ...board.settings, attachmentMode: v as typeof board.settings.attachmentMode })}
>
<DropdownMenuRadioItem value="link">Link to original</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="copy">Copy into board</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowVersionHistory(true)}>
Version History
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-pylon-text-secondary hover:text-pylon-text"
onClick={() =>
document.dispatchEvent(new CustomEvent("open-command-palette"))
}
>
<Search className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Command palette{" "}
<kbd className="ml-1 font-mono text-[10px] opacity-60">Ctrl+K</kbd>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-pylon-text-secondary hover:text-pylon-text"
onClick={() =>
document.dispatchEvent(new CustomEvent("open-settings-dialog"))
}
>
<Settings className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
<WindowControls />
</div>
{isBoardView && (
<VersionHistoryDialog
open={showVersionHistory}
onOpenChange={setShowVersionHistory}
/>
)}
</header>
);
}
function formatTimeAgo(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
return `${minutes}m ago`;
}