From 209df13c32910511d3bb8e195f3b894cf1f6b906 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 27 Mar 2026 14:57:31 +0100 Subject: [PATCH] Add export method dialog, extract shared Dialog primitive Add export dialog with download/clipboard options and optional undo/redo history inclusion (default off). Extract shared Dialog component to ui/dialog.tsx, consolidating open/close lifecycle, backdrop click, and escape key handling from all 6 dialog components. Update spec to reflect export method dialog and optional history. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/src/components/action-bar.tsx | 44 +++++++-- .../src/components/create-player-modal.tsx | 39 +------- .../src/components/export-method-dialog.tsx | 94 +++++++++++++++++++ .../src/components/import-confirm-prompt.tsx | 36 +------ .../src/components/import-method-dialog.tsx | 46 ++------- apps/web/src/components/player-management.tsx | 39 +------- apps/web/src/components/settings-modal.tsx | 35 +------ apps/web/src/components/ui/dialog.tsx | 50 ++++++++++ apps/web/src/persistence/export-import.ts | 12 ++- specs/007-json-import-export/spec.md | 16 ++-- 10 files changed, 218 insertions(+), 193 deletions(-) create mode 100644 apps/web/src/components/export-method-dialog.tsx create mode 100644 apps/web/src/components/ui/dialog.tsx diff --git a/apps/web/src/components/action-bar.tsx b/apps/web/src/components/action-bar.tsx index ec86bfd..d30cda9 100644 --- a/apps/web/src/components/action-bar.tsx +++ b/apps/web/src/components/action-bar.tsx @@ -28,11 +28,13 @@ import { useLongPress } from "../hooks/use-long-press.js"; import { cn } from "../lib/utils.js"; import { assembleExportBundle, + bundleToJson, readImportFile, triggerDownload, validateImportBundle, } from "../persistence/export-import.js"; import { D20Icon } from "./d20-icon.js"; +import { ExportMethodDialog } from "./export-method-dialog.js"; import { ImportConfirmDialog } from "./import-confirm-prompt.js"; import { ImportMethodDialog } from "./import-method-dialog.js"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js"; @@ -449,20 +451,38 @@ export function ActionBar({ const importFileRef = useRef(null); const [importError, setImportError] = useState(null); + const [showExportMethod, setShowExportMethod] = useState(false); const [showImportMethod, setShowImportMethod] = useState(false); const [showImportConfirm, setShowImportConfirm] = useState(false); const pendingBundleRef = useRef< import("@initiative/domain").ExportBundle | null >(null); - const handleExport = useCallback(() => { - const bundle = assembleExportBundle( - encounter, - undoRedoState, - playerCharacters, - ); - triggerDownload(bundle); - }, [encounter, undoRedoState, playerCharacters]); + const handleExportDownload = useCallback( + (includeHistory: boolean) => { + const bundle = assembleExportBundle( + encounter, + undoRedoState, + playerCharacters, + includeHistory, + ); + triggerDownload(bundle); + }, + [encounter, undoRedoState, playerCharacters], + ); + + const handleExportClipboard = useCallback( + (includeHistory: boolean) => { + const bundle = assembleExportBundle( + encounter, + undoRedoState, + playerCharacters, + includeHistory, + ); + void navigator.clipboard.writeText(bundleToJson(bundle)); + }, + [encounter, undoRedoState, playerCharacters], + ); const applyImport = useCallback( (bundle: import("@initiative/domain").ExportBundle) => { @@ -536,7 +556,7 @@ export function ActionBar({ bestiaryLoaded, onBulkImport: showBulkImport, bulkImportDisabled: bulkImportState.status === "loading", - onExportEncounter: handleExport, + onExportEncounter: () => setShowExportMethod(true), onImportEncounter: () => setShowImportMethod(true), onOpenSettings, }); @@ -633,6 +653,12 @@ export function ActionBar({ autoDismissMs={5000} /> )} + setShowExportMethod(false)} + /> importFileRef.current?.click()} diff --git a/apps/web/src/components/create-player-modal.tsx b/apps/web/src/components/create-player-modal.tsx index ccbc996..bd8ab1c 100644 --- a/apps/web/src/components/create-player-modal.tsx +++ b/apps/web/src/components/create-player-modal.tsx @@ -1,9 +1,10 @@ import type { PlayerCharacter } from "@initiative/domain"; import { X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { ColorPalette } from "./color-palette"; import { IconGrid } from "./icon-grid"; import { Button } from "./ui/button"; +import { Dialog } from "./ui/dialog"; import { Input } from "./ui/input"; interface CreatePlayerModalProps { @@ -25,7 +26,6 @@ export function CreatePlayerModal({ onSave, playerCharacter, }: Readonly) { - const dialogRef = useRef(null); const [name, setName] = useState(""); const [ac, setAc] = useState("10"); const [maxHp, setMaxHp] = useState("10"); @@ -54,34 +54,6 @@ export function CreatePlayerModal({ } }, [open, playerCharacter]); - useEffect(() => { - const dialog = dialogRef.current; - if (!dialog) return; - if (open && !dialog.open) { - dialog.showModal(); - } else if (!open && dialog.open) { - dialog.close(); - } - }, [open]); - - useEffect(() => { - const dialog = dialogRef.current; - if (!dialog) return; - function handleCancel(e: Event) { - e.preventDefault(); - onClose(); - } - function handleBackdropClick(e: MouseEvent) { - if (e.target === dialog) onClose(); - } - dialog.addEventListener("cancel", handleCancel); - dialog.addEventListener("mousedown", handleBackdropClick); - return () => { - dialog.removeEventListener("cancel", handleCancel); - dialog.removeEventListener("mousedown", handleBackdropClick); - }; - }, [onClose]); - const handleSubmit = (e: React.SubmitEvent) => { e.preventDefault(); const trimmed = name.trim(); @@ -104,10 +76,7 @@ export function CreatePlayerModal({ }; return ( - +

{isEdit ? "Edit Player" : "Create Player"} @@ -187,6 +156,6 @@ export function CreatePlayerModal({

-
+
); } diff --git a/apps/web/src/components/export-method-dialog.tsx b/apps/web/src/components/export-method-dialog.tsx new file mode 100644 index 0000000..e177000 --- /dev/null +++ b/apps/web/src/components/export-method-dialog.tsx @@ -0,0 +1,94 @@ +import { Check, ClipboardCopy, Download, X } from "lucide-react"; +import { useCallback, useState } from "react"; +import { Button } from "./ui/button.js"; +import { Dialog } from "./ui/dialog.js"; + +interface ExportMethodDialogProps { + open: boolean; + onDownload: (includeHistory: boolean) => void; + onCopyToClipboard: (includeHistory: boolean) => void; + onClose: () => void; +} + +export function ExportMethodDialog({ + open, + onDownload, + onCopyToClipboard, + onClose, +}: Readonly) { + const [includeHistory, setIncludeHistory] = useState(false); + const [copied, setCopied] = useState(false); + + const handleClose = useCallback(() => { + setIncludeHistory(false); + setCopied(false); + onClose(); + }, [onClose]); + + return ( + +
+

Export Encounter

+ +
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/import-confirm-prompt.tsx b/apps/web/src/components/import-confirm-prompt.tsx index 811d495..1b28714 100644 --- a/apps/web/src/components/import-confirm-prompt.tsx +++ b/apps/web/src/components/import-confirm-prompt.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef } from "react"; import { Button } from "./ui/button.js"; +import { Dialog } from "./ui/dialog.js"; interface ImportConfirmDialogProps { open: boolean; @@ -12,38 +12,8 @@ export function ImportConfirmDialog({ onConfirm, onCancel, }: Readonly) { - const dialogRef = useRef(null); - - useEffect(() => { - const dialog = dialogRef.current; - if (!dialog) return; - if (open && !dialog.open) dialog.showModal(); - else if (!open && dialog.open) dialog.close(); - }, [open]); - - useEffect(() => { - const dialog = dialogRef.current; - if (!dialog) return; - function handleCancel(e: Event) { - e.preventDefault(); - onCancel(); - } - function handleBackdropClick(e: MouseEvent) { - if (e.target === dialog) onCancel(); - } - dialog.addEventListener("cancel", handleCancel); - dialog.addEventListener("mousedown", handleBackdropClick); - return () => { - dialog.removeEventListener("cancel", handleCancel); - dialog.removeEventListener("mousedown", handleBackdropClick); - }; - }, [onCancel]); - return ( - +

Replace current encounter?

Importing will replace your current encounter, undo/redo history, and @@ -57,6 +27,6 @@ export function ImportConfirmDialog({ Import -

+
); } diff --git a/apps/web/src/components/import-method-dialog.tsx b/apps/web/src/components/import-method-dialog.tsx index eb9a628..ee74acf 100644 --- a/apps/web/src/components/import-method-dialog.tsx +++ b/apps/web/src/components/import-method-dialog.tsx @@ -1,6 +1,7 @@ import { ClipboardPaste, FileUp, X } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "./ui/button.js"; +import { Dialog } from "./ui/dialog.js"; interface ImportMethodDialogProps { open: boolean; @@ -15,30 +16,22 @@ export function ImportMethodDialog({ onSubmitClipboard, onClose, }: Readonly) { - const dialogRef = useRef(null); const textareaRef = useRef(null); const [mode, setMode] = useState<"pick" | "paste">("pick"); const [pasteText, setPasteText] = useState(""); - const reset = useCallback(() => { + const handleClose = useCallback(() => { setMode("pick"); setPasteText(""); - }, []); - - const handleClose = useCallback(() => { - reset(); onClose(); - }, [reset, onClose]); + }, [onClose]); useEffect(() => { - const dialog = dialogRef.current; - if (!dialog) return; - if (open && !dialog.open) dialog.showModal(); - else if (!open && dialog.open) { - dialog.close(); - reset(); + if (!open) { + setMode("pick"); + setPasteText(""); } - }, [open, reset]); + }, [open]); useEffect(() => { if (mode === "paste") { @@ -46,29 +39,8 @@ export function ImportMethodDialog({ } }, [mode]); - useEffect(() => { - const dialog = dialogRef.current; - if (!dialog) return; - function handleCancel(e: Event) { - e.preventDefault(); - handleClose(); - } - function handleBackdropClick(e: MouseEvent) { - if (e.target === dialog) handleClose(); - } - dialog.addEventListener("cancel", handleCancel); - dialog.addEventListener("mousedown", handleBackdropClick); - return () => { - dialog.removeEventListener("cancel", handleCancel); - dialog.removeEventListener("mousedown", handleBackdropClick); - }; - }, [handleClose]); - return ( - +

Import Encounter

)} -
+
); } diff --git a/apps/web/src/components/player-management.tsx b/apps/web/src/components/player-management.tsx index eb7fc00..cdf92f6 100644 --- a/apps/web/src/components/player-management.tsx +++ b/apps/web/src/components/player-management.tsx @@ -1,9 +1,9 @@ import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain"; import { Pencil, Plus, Trash2, X } from "lucide-react"; -import { useEffect, useRef } from "react"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; import { Button } from "./ui/button"; import { ConfirmButton } from "./ui/confirm-button"; +import { Dialog } from "./ui/dialog"; interface PlayerManagementProps { open: boolean; @@ -22,41 +22,8 @@ export function PlayerManagement({ onDelete, onCreate, }: Readonly) { - const dialogRef = useRef(null); - - useEffect(() => { - const dialog = dialogRef.current; - if (!dialog) return; - if (open && !dialog.open) { - dialog.showModal(); - } else if (!open && dialog.open) { - dialog.close(); - } - }, [open]); - - useEffect(() => { - const dialog = dialogRef.current; - if (!dialog) return; - function handleCancel(e: Event) { - e.preventDefault(); - onClose(); - } - function handleBackdropClick(e: MouseEvent) { - if (e.target === dialog) onClose(); - } - dialog.addEventListener("cancel", handleCancel); - dialog.addEventListener("mousedown", handleBackdropClick); - return () => { - dialog.removeEventListener("cancel", handleCancel); - dialog.removeEventListener("mousedown", handleBackdropClick); - }; - }, [onClose]); - return ( - +

Player Characters @@ -128,6 +95,6 @@ export function PlayerManagement({

)} -
+
); } diff --git a/apps/web/src/components/settings-modal.tsx b/apps/web/src/components/settings-modal.tsx index 9decccd..d360172 100644 --- a/apps/web/src/components/settings-modal.tsx +++ b/apps/web/src/components/settings-modal.tsx @@ -1,10 +1,10 @@ import type { RulesEdition } from "@initiative/domain"; import { Monitor, Moon, Sun, X } from "lucide-react"; -import { useEffect, useRef } from "react"; import { useRulesEditionContext } from "../contexts/rules-edition-context.js"; import { useThemeContext } from "../contexts/theme-context.js"; import { cn } from "../lib/utils.js"; import { Button } from "./ui/button.js"; +import { Dialog } from "./ui/dialog.js"; interface SettingsModalProps { open: boolean; @@ -27,40 +27,11 @@ const THEME_OPTIONS: { ]; export function SettingsModal({ open, onClose }: Readonly) { - const dialogRef = useRef(null); const { edition, setEdition } = useRulesEditionContext(); const { preference, setPreference } = useThemeContext(); - useEffect(() => { - const dialog = dialogRef.current; - if (!dialog) return; - if (open && !dialog.open) dialog.showModal(); - else if (!open && dialog.open) dialog.close(); - }, [open]); - - useEffect(() => { - const dialog = dialogRef.current; - if (!dialog) return; - function handleCancel(e: Event) { - e.preventDefault(); - onClose(); - } - function handleBackdropClick(e: MouseEvent) { - if (e.target === dialog) onClose(); - } - dialog.addEventListener("cancel", handleCancel); - dialog.addEventListener("mousedown", handleBackdropClick); - return () => { - dialog.removeEventListener("cancel", handleCancel); - dialog.removeEventListener("mousedown", handleBackdropClick); - }; - }, [onClose]); - return ( - +

Settings

-
+
); } diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx new file mode 100644 index 0000000..f90eb96 --- /dev/null +++ b/apps/web/src/components/ui/dialog.tsx @@ -0,0 +1,50 @@ +import { type ReactNode, useEffect, useRef } from "react"; +import { cn } from "../../lib/utils.js"; + +interface DialogProps { + open: boolean; + onClose: () => void; + className?: string; + children: ReactNode; +} + +export function Dialog({ open, onClose, className, children }: DialogProps) { + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (open && !dialog.open) dialog.showModal(); + else if (!open && dialog.open) dialog.close(); + }, [open]); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + function handleCancel(e: Event) { + e.preventDefault(); + onClose(); + } + function handleBackdropClick(e: MouseEvent) { + if (e.target === dialog) onClose(); + } + dialog.addEventListener("cancel", handleCancel); + dialog.addEventListener("mousedown", handleBackdropClick); + return () => { + dialog.removeEventListener("cancel", handleCancel); + dialog.removeEventListener("mousedown", handleBackdropClick); + }; + }, [onClose]); + + return ( + +
{children}
+
+ ); +} diff --git a/apps/web/src/persistence/export-import.ts b/apps/web/src/persistence/export-import.ts index 34ee7ae..e4ee1fd 100644 --- a/apps/web/src/persistence/export-import.ts +++ b/apps/web/src/persistence/export-import.ts @@ -68,20 +68,24 @@ export function assembleExportBundle( encounter: Encounter, undoRedoState: UndoRedoState, playerCharacters: readonly PlayerCharacter[], + includeHistory = true, ): ExportBundle { return { version: 1, exportedAt: new Date().toISOString(), encounter, - undoStack: undoRedoState.undoStack, - redoStack: undoRedoState.redoStack, + undoStack: includeHistory ? undoRedoState.undoStack : [], + redoStack: includeHistory ? undoRedoState.redoStack : [], playerCharacters: [...playerCharacters], }; } +export function bundleToJson(bundle: ExportBundle): string { + return JSON.stringify(bundle, null, 2); +} + export function triggerDownload(bundle: ExportBundle): void { - const json = JSON.stringify(bundle, null, 2); - const blob = new Blob([json], { type: "application/json" }); + const blob = new Blob([bundleToJson(bundle)], { type: "application/json" }); const url = URL.createObjectURL(blob); const date = new Date().toISOString().slice(0, 10); diff --git a/specs/007-json-import-export/spec.md b/specs/007-json-import-export/spec.md index 9a019c6..5ce918e 100644 --- a/specs/007-json-import-export/spec.md +++ b/specs/007-json-import-export/spec.md @@ -9,17 +9,19 @@ ### Story IE-1 — Export encounter to file (Priority: P1) -A DM has set up an encounter (combatants, HP, initiative, conditions) and wants to save it as a file for backup or to share with another DM. They click an export button, and the browser downloads a `.json` file containing the full application state. +A DM has set up an encounter (combatants, HP, initiative, conditions) and wants to save or share it. They click an export button, choose whether to include undo/redo history, and either download a `.json` file or copy the JSON to their clipboard. **Why this priority**: Export is the foundation — without it, import has nothing to work with. It also delivers standalone value as a backup mechanism. -**Independent Test**: Can be fully tested by creating an encounter, exporting, and verifying the downloaded file contains all encounter data, undo/redo history, and player character templates. +**Independent Test**: Can be fully tested by creating an encounter, exporting (via download or clipboard), and verifying the output contains all encounter data and player character templates. **Acceptance Scenarios**: -1. **Given** an encounter with combatants (some with HP, AC, conditions, initiative), **When** the user clicks the export button, **Then** a `.json` file is downloaded containing the encounter state, undo/redo stacks, and player character templates. -2. **Given** an empty encounter with no combatants, **When** the user clicks the export button, **Then** a `.json` file is downloaded containing the empty encounter state and any existing player character templates. -3. **Given** an encounter with player character combatants (color, icon, linked template), **When** the user exports, **Then** the exported file preserves all player character visual properties and template links. +1. **Given** the user clicks the export action, **Then** a dialog appears with an option to include undo/redo history (off by default) and two export methods: download file and copy to clipboard. +2. **Given** an encounter with combatants (some with HP, AC, conditions, initiative), **When** the user exports, **Then** the output contains the encounter state, player character templates, and optionally undo/redo stacks. +3. **Given** an empty encounter with no combatants, **When** the user exports, **Then** the output contains the empty encounter state and any existing player character templates. +4. **Given** an encounter with player character combatants (color, icon, linked template), **When** the user exports, **Then** the exported data preserves all player character visual properties and template links. +5. **Given** the user chooses "Copy to clipboard", **When** the export completes, **Then** the JSON is copied and a visual confirmation is shown. --- @@ -69,8 +71,8 @@ A DM accidentally selects a wrong file (a non-JSON file, a corrupted export, or ### Functional Requirements -- **FR-001**: The system MUST provide an export action accessible from the UI that downloads the full application state as a `.json` file. -- **FR-002**: The exported file MUST contain the current encounter (combatants and turn/round state), undo/redo stacks, and player character templates. +- **FR-001**: The system MUST provide an export action accessible from the UI that lets the user download the application state as a `.json` file or copy it to the clipboard. +- **FR-002**: The exported data MUST contain the current encounter (combatants and turn/round state) and player character templates. Undo/redo stacks MUST be includable via a user option (default: excluded). - **FR-003**: The exported file MUST use a human-readable filename that includes context (e.g., date or encounter info). - **FR-004**: The system MUST provide an import action accessible from the UI that lets the user choose between uploading a `.json` file or pasting from the clipboard. - **FR-005**: On import, the system MUST replace the current encounter, undo/redo history, and player characters with the imported data.