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) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-27 14:57:31 +01:00
parent 4969ed069b
commit 209df13c32
10 changed files with 218 additions and 193 deletions

View File

@@ -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<HTMLInputElement>(null);
const [importError, setImportError] = useState<string | null>(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 handleExportDownload = useCallback(
(includeHistory: boolean) => {
const bundle = assembleExportBundle(
encounter,
undoRedoState,
playerCharacters,
includeHistory,
);
triggerDownload(bundle);
}, [encounter, undoRedoState, playerCharacters]);
},
[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}
/>
)}
<ExportMethodDialog
open={showExportMethod}
onDownload={handleExportDownload}
onCopyToClipboard={handleExportClipboard}
onClose={() => setShowExportMethod(false)}
/>
<ImportMethodDialog
open={showImportMethod}
onSelectFile={() => importFileRef.current?.click()}

View File

@@ -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<CreatePlayerModalProps>) {
const dialogRef = useRef<HTMLDialogElement>(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<HTMLFormElement>) => {
e.preventDefault();
const trimmed = name.trim();
@@ -104,10 +76,7 @@ export function CreatePlayerModal({
};
return (
<dialog
ref={dialogRef}
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
>
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-foreground text-lg">
{isEdit ? "Edit Player" : "Create Player"}
@@ -187,6 +156,6 @@ export function CreatePlayerModal({
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
</div>
</form>
</dialog>
</Dialog>
);
}

View File

@@ -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<ExportMethodDialogProps>) {
const [includeHistory, setIncludeHistory] = useState(false);
const [copied, setCopied] = useState(false);
const handleClose = useCallback(() => {
setIncludeHistory(false);
setCopied(false);
onClose();
}, [onClose]);
return (
<Dialog open={open} onClose={handleClose} className="w-80">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-lg">Export Encounter</h2>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={handleClose}
className="text-muted-foreground"
>
<X className="h-4 w-4" />
</Button>
</div>
<label className="mb-4 flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={includeHistory}
onChange={(e) => setIncludeHistory(e.target.checked)}
className="accent-accent"
/>
<span className="text-foreground">Include undo/redo history</span>
</label>
<div className="flex flex-col gap-2">
<button
type="button"
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
onClick={() => {
onDownload(includeHistory);
handleClose();
}}
>
<Download className="h-5 w-5 text-muted-foreground" />
<div>
<div className="font-medium">Download file</div>
<div className="text-muted-foreground text-xs">
Save as a JSON file
</div>
</div>
</button>
<button
type="button"
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
onClick={() => {
onCopyToClipboard(includeHistory);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? (
<Check className="h-5 w-5 text-green-400" />
) : (
<ClipboardCopy className="h-5 w-5 text-muted-foreground" />
)}
<div>
<div className="font-medium">
{copied ? "Copied!" : "Copy to clipboard"}
</div>
<div className="text-muted-foreground text-xs">
Copy JSON to your clipboard
</div>
</div>
</button>
</div>
</Dialog>
);
}

View File

@@ -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<ImportConfirmDialogProps>) {
const dialogRef = useRef<HTMLDialogElement>(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 (
<dialog
ref={dialogRef}
className="m-auto rounded-lg border border-border bg-card p-6 text-foreground shadow-xl backdrop:bg-black/50"
>
<Dialog open={open} onClose={onCancel}>
<h2 className="mb-2 font-semibold text-lg">Replace current encounter?</h2>
<p className="mb-4 text-muted-foreground text-sm">
Importing will replace your current encounter, undo/redo history, and
@@ -57,6 +27,6 @@ export function ImportConfirmDialog({
Import
</Button>
</div>
</dialog>
</Dialog>
);
}

View File

@@ -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<ImportMethodDialogProps>) {
const dialogRef = useRef<HTMLDialogElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(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 (
<dialog
ref={dialogRef}
className="m-auto w-80 rounded-lg border border-border bg-card p-6 text-foreground shadow-xl backdrop:bg-black/50"
>
<Dialog open={open} onClose={handleClose} className="w-80">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-lg">Import Encounter</h2>
<Button
@@ -148,6 +120,6 @@ export function ImportMethodDialog({
</div>
</div>
)}
</dialog>
</Dialog>
);
}

View File

@@ -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<PlayerManagementProps>) {
const dialogRef = useRef<HTMLDialogElement>(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 (
<dialog
ref={dialogRef}
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
>
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-foreground text-lg">
Player Characters
@@ -128,6 +95,6 @@ export function PlayerManagement({
</div>
</div>
)}
</dialog>
</Dialog>
);
}

View File

@@ -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<SettingsModalProps>) {
const dialogRef = useRef<HTMLDialogElement>(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 (
<dialog
ref={dialogRef}
className="card-glow m-auto w-full max-w-sm rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
>
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-sm">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-foreground text-lg">Settings</h2>
<Button
@@ -124,6 +95,6 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
</div>
</div>
</div>
</dialog>
</Dialog>
);
}

View File

@@ -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<HTMLDialogElement>(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 (
<dialog
ref={dialogRef}
className={cn(
"m-auto rounded-lg border border-border bg-card text-foreground shadow-xl backdrop:bg-black/50",
className,
)}
>
<div className="p-6">{children}</div>
</dialog>
);
}

View File

@@ -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);

View File

@@ -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.