Add JSON import/export for full encounter state

Export and import encounter, undo/redo history, and player characters
as a downloadable .json file. Export/import actions are in the action
bar overflow menu. Import validates using existing rehydration functions
and shows a confirmation dialog when replacing a non-empty encounter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-27 14:28:39 +01:00
parent f6766b729d
commit fba83bebd6
19 changed files with 1339 additions and 2 deletions

View File

@@ -1,6 +1,7 @@
import type { PlayerCharacter } from "@initiative/domain";
import {
Check,
Download,
Eye,
EyeOff,
Import,
@@ -8,13 +9,15 @@ import {
Minus,
Plus,
Settings,
Upload,
Users,
} from "lucide-react";
import React, { type RefObject, useCallback, useState } from "react";
import React, { type RefObject, useCallback, useRef, useState } from "react";
import type { SearchResult } from "../contexts/bestiary-context.js";
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
import {
creatureKey,
type QueuedCreature,
@@ -23,9 +26,16 @@ import {
} from "../hooks/use-action-bar-state.js";
import { useLongPress } from "../hooks/use-long-press.js";
import { cn } from "../lib/utils.js";
import {
assembleExportBundle,
readImportFile,
triggerDownload,
} from "../persistence/export-import.js";
import { D20Icon } from "./d20-icon.js";
import { ImportConfirmDialog } from "./import-confirm-prompt.js";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
import { RollModeMenu } from "./roll-mode-menu.js";
import { Toast } from "./toast.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
@@ -345,6 +355,8 @@ function buildOverflowItems(opts: {
bestiaryLoaded: boolean;
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
onExportEncounter: () => void;
onImportEncounter: () => void;
onOpenSettings?: () => void;
}): OverflowMenuItem[] {
const items: OverflowMenuItem[] = [];
@@ -370,6 +382,16 @@ function buildOverflowItems(opts: {
disabled: opts.bulkImportDisabled,
});
}
items.push({
icon: <Download className="h-4 w-4" />,
label: "Export Encounter",
onClick: opts.onExportEncounter,
});
items.push({
icon: <Upload className="h-4 w-4" />,
label: "Import Encounter",
onClick: opts.onImportEncounter,
});
if (opts.onOpenSettings) {
items.push({
icon: <Settings className="h-4 w-4" />,
@@ -413,6 +435,78 @@ export function ActionBar({
} = useActionBarState();
const { state: bulkImportState } = useBulkImportContext();
const {
encounter,
undoRedoState,
isEmpty: encounterIsEmpty,
setEncounter,
setUndoRedoState,
} = useEncounterContext();
const { characters: playerCharacters, replacePlayerCharacters } =
usePlayerCharactersContext();
const importFileRef = useRef<HTMLInputElement>(null);
const [importError, setImportError] = useState<string | null>(null);
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 applyImport = useCallback(
(bundle: import("@initiative/domain").ExportBundle) => {
setEncounter(bundle.encounter);
setUndoRedoState({
undoStack: bundle.undoStack,
redoStack: bundle.redoStack,
});
replacePlayerCharacters([...bundle.playerCharacters]);
},
[setEncounter, setUndoRedoState, replacePlayerCharacters],
);
const handleImportFile = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (importFileRef.current) importFileRef.current.value = "";
setImportError(null);
const result = await readImportFile(file);
if (typeof result === "string") {
setImportError(result);
return;
}
if (encounterIsEmpty) {
applyImport(result);
} else {
pendingBundleRef.current = result;
setShowImportConfirm(true);
}
},
[encounterIsEmpty, applyImport],
);
const handleImportConfirm = useCallback(() => {
if (pendingBundleRef.current) {
applyImport(pendingBundleRef.current);
pendingBundleRef.current = null;
}
setShowImportConfirm(false);
}, [applyImport]);
const handleImportCancel = useCallback(() => {
pendingBundleRef.current = null;
setShowImportConfirm(false);
}, []);
const overflowItems = buildOverflowItems({
onManagePlayers,
@@ -420,6 +514,8 @@ export function ActionBar({
bestiaryLoaded,
onBulkImport: showBulkImport,
bulkImportDisabled: bulkImportState.status === "loading",
onExportEncounter: handleExport,
onImportEncounter: () => importFileRef.current?.click(),
onOpenSettings,
});
@@ -501,6 +597,25 @@ export function ActionBar({
<RollAllButton />
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
</form>
<input
ref={importFileRef}
type="file"
accept=".json"
className="hidden"
onChange={handleImportFile}
/>
{!!importError && (
<Toast
message={importError}
onDismiss={() => setImportError(null)}
autoDismissMs={5000}
/>
)}
<ImportConfirmDialog
open={showImportConfirm}
onConfirm={handleImportConfirm}
onCancel={handleImportCancel}
/>
</div>
);
}