- 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
307 lines
11 KiB
TypeScript
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`;
|
|
}
|