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:
154
apps/web/src/__tests__/export-import.test.ts
Normal file
154
apps/web/src/__tests__/export-import.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
combatantId,
|
||||
type Encounter,
|
||||
type ExportBundle,
|
||||
type PlayerCharacter,
|
||||
playerCharacterId,
|
||||
type UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assembleExportBundle,
|
||||
validateImportBundle,
|
||||
} from "../persistence/export-import.js";
|
||||
|
||||
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
||||
|
||||
const encounter: Encounter = {
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c-1"),
|
||||
name: "Goblin",
|
||||
initiative: 15,
|
||||
maxHp: 7,
|
||||
currentHp: 7,
|
||||
ac: 15,
|
||||
},
|
||||
{
|
||||
id: combatantId("c-2"),
|
||||
name: "Aria",
|
||||
initiative: 18,
|
||||
maxHp: 45,
|
||||
currentHp: 40,
|
||||
ac: 16,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
playerCharacterId: playerCharacterId("pc-1"),
|
||||
},
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 2,
|
||||
};
|
||||
|
||||
const undoRedoState: UndoRedoState = {
|
||||
undoStack: [
|
||||
{
|
||||
combatants: [{ id: combatantId("c-1"), name: "Goblin", initiative: 15 }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const playerCharacters: PlayerCharacter[] = [
|
||||
{
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 45,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
];
|
||||
|
||||
describe("assembleExportBundle", () => {
|
||||
it("returns a bundle with version 1", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.version).toBe(1);
|
||||
});
|
||||
|
||||
it("includes an ISO timestamp", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.exportedAt).toMatch(ISO_TIMESTAMP_RE);
|
||||
});
|
||||
|
||||
it("includes the encounter", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.encounter).toEqual(encounter);
|
||||
});
|
||||
|
||||
it("includes undo and redo stacks", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
|
||||
expect(bundle.redoStack).toEqual(undoRedoState.redoStack);
|
||||
});
|
||||
|
||||
it("includes player characters", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.playerCharacters).toEqual(playerCharacters);
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-trip: export then import", () => {
|
||||
it("produces identical state after round-trip", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
|
||||
const serialized = JSON.parse(JSON.stringify(bundle));
|
||||
const result = validateImportBundle(serialized);
|
||||
|
||||
expect(typeof result).toBe("object");
|
||||
const imported = result as ExportBundle;
|
||||
expect(imported.version).toBe(bundle.version);
|
||||
expect(imported.encounter).toEqual(bundle.encounter);
|
||||
expect(imported.undoStack).toEqual(bundle.undoStack);
|
||||
expect(imported.redoStack).toEqual(bundle.redoStack);
|
||||
expect(imported.playerCharacters).toEqual(bundle.playerCharacters);
|
||||
});
|
||||
|
||||
it("round-trips an empty encounter", () => {
|
||||
const emptyEncounter: Encounter = {
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const emptyUndoRedo: UndoRedoState = {
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
const bundle = assembleExportBundle(emptyEncounter, emptyUndoRedo, []);
|
||||
const serialized = JSON.parse(JSON.stringify(bundle));
|
||||
const result = validateImportBundle(serialized);
|
||||
|
||||
expect(typeof result).toBe("object");
|
||||
const imported = result as ExportBundle;
|
||||
expect(imported.encounter.combatants).toHaveLength(0);
|
||||
expect(imported.undoStack).toHaveLength(0);
|
||||
expect(imported.redoStack).toHaveLength(0);
|
||||
expect(imported.playerCharacters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
249
apps/web/src/__tests__/validate-import-bundle.test.ts
Normal file
249
apps/web/src/__tests__/validate-import-bundle.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import type { ExportBundle } from "@initiative/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateImportBundle } from "../persistence/export-import.js";
|
||||
|
||||
function validBundle(): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
exportedAt: "2026-03-27T12:00:00.000Z",
|
||||
encounter: {
|
||||
combatants: [{ id: "c-1", name: "Goblin", initiative: 15 }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
playerCharacters: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("validateImportBundle", () => {
|
||||
it("accepts a valid bundle", () => {
|
||||
const result = validateImportBundle(validBundle());
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.version).toBe(1);
|
||||
expect(bundle.encounter.combatants).toHaveLength(1);
|
||||
expect(bundle.encounter.combatants[0].name).toBe("Goblin");
|
||||
});
|
||||
|
||||
it("accepts a valid bundle with empty encounter", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
encounter: { combatants: [], activeIndex: 0, roundNumber: 1 },
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.encounter.combatants).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts a bundle with undo/redo stacks", () => {
|
||||
const enc = {
|
||||
combatants: [{ id: "c-1", name: "Orc" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const input = {
|
||||
...validBundle(),
|
||||
undoStack: [enc],
|
||||
redoStack: [enc],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.undoStack).toHaveLength(1);
|
||||
expect(bundle.redoStack).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("accepts a bundle with player characters", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 45,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(1);
|
||||
expect(bundle.playerCharacters[0].name).toBe("Aria");
|
||||
});
|
||||
|
||||
it("rejects non-object input", () => {
|
||||
expect(validateImportBundle(null)).toBe("Invalid file format");
|
||||
expect(validateImportBundle(42)).toBe("Invalid file format");
|
||||
expect(validateImportBundle("string")).toBe("Invalid file format");
|
||||
expect(validateImportBundle([])).toBe("Invalid file format");
|
||||
expect(validateImportBundle(undefined)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects missing version field", () => {
|
||||
const input = validBundle();
|
||||
delete input.version;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects version 0 or negative", () => {
|
||||
expect(validateImportBundle({ ...validBundle(), version: 0 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
expect(validateImportBundle({ ...validBundle(), version: -1 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown version", () => {
|
||||
expect(validateImportBundle({ ...validBundle(), version: 99 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing encounter field", () => {
|
||||
const input = validBundle();
|
||||
delete input.encounter;
|
||||
expect(validateImportBundle(input)).toBe("Invalid encounter data");
|
||||
});
|
||||
|
||||
it("rejects invalid encounter data", () => {
|
||||
expect(
|
||||
validateImportBundle({ ...validBundle(), encounter: "not an object" }),
|
||||
).toBe("Invalid encounter data");
|
||||
});
|
||||
|
||||
it("rejects missing undoStack", () => {
|
||||
const input = validBundle();
|
||||
delete input.undoStack;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects missing redoStack", () => {
|
||||
const input = validBundle();
|
||||
delete input.redoStack;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects missing playerCharacters", () => {
|
||||
const input = validBundle();
|
||||
delete input.playerCharacters;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects non-string exportedAt", () => {
|
||||
expect(validateImportBundle({ ...validBundle(), exportedAt: 12345 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops invalid entries from undo stack", () => {
|
||||
const valid = {
|
||||
combatants: [{ id: "c-1", name: "Orc" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const input = {
|
||||
...validBundle(),
|
||||
undoStack: [valid, "invalid", { bad: true }, valid],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.undoStack).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("drops invalid player characters", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{ id: "pc-1", name: "Valid", ac: 10, maxHp: 20 },
|
||||
{ id: "", name: "Bad ID" },
|
||||
"not an object",
|
||||
{ id: "pc-3", name: "Also Valid", ac: 15, maxHp: 30 },
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects JSON array instead of object", () => {
|
||||
expect(validateImportBundle([1, 2, 3])).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects encounter that fails rehydration (missing combatant fields)", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
encounter: {
|
||||
combatants: [{ noId: true }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
};
|
||||
expect(validateImportBundle(input)).toBe("Invalid encounter data");
|
||||
});
|
||||
|
||||
it("strips invalid color/icon from player characters but keeps the character", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 20,
|
||||
color: "neon-pink",
|
||||
icon: "bazooka",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
// rehydrateCharacter rejects characters with invalid color/icon members
|
||||
// that are not in the valid sets, so this character is dropped
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps player characters with valid optional color and icon", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 45,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(1);
|
||||
expect(bundle.playerCharacters[0].color).toBe("blue");
|
||||
expect(bundle.playerCharacters[0].icon).toBe("sword");
|
||||
});
|
||||
|
||||
it("ignores unknown extra fields on the bundle", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
unknownField: "should be ignored",
|
||||
anotherExtra: 42,
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.version).toBe(1);
|
||||
expect("unknownField" in bundle).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,9 @@ function mockContext(overrides: Partial<Encounter> = {}) {
|
||||
redo: vi.fn(),
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
undoRedoState: { undoStack: [], redoStack: [] },
|
||||
setEncounter: vi.fn(),
|
||||
setUndoRedoState: vi.fn(),
|
||||
events: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
62
apps/web/src/components/import-confirm-prompt.tsx
Normal file
62
apps/web/src/components/import-confirm-prompt.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
interface ImportConfirmDialogProps {
|
||||
open: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ImportConfirmDialog({
|
||||
open,
|
||||
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="rounded-lg border border-border bg-card p-6 text-foreground shadow-xl backdrop:bg-black/50"
|
||||
>
|
||||
<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
|
||||
player characters. This cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={onConfirm}>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -444,6 +444,7 @@ export function useEncounter() {
|
||||
|
||||
return {
|
||||
encounter,
|
||||
undoRedoState,
|
||||
events,
|
||||
isEmpty,
|
||||
hasTempHp,
|
||||
@@ -469,6 +470,8 @@ export function useEncounter() {
|
||||
addFromPlayerCharacter,
|
||||
undo: undoAction,
|
||||
redo: redoAction,
|
||||
setEncounter,
|
||||
setUndoRedoState,
|
||||
makeStore,
|
||||
withUndo,
|
||||
} as const;
|
||||
|
||||
@@ -103,6 +103,7 @@ export function usePlayerCharacters() {
|
||||
createCharacter,
|
||||
editCharacter,
|
||||
deleteCharacter,
|
||||
replacePlayerCharacters: setCharacters,
|
||||
makeStore,
|
||||
} as const;
|
||||
}
|
||||
|
||||
108
apps/web/src/persistence/export-import.ts
Normal file
108
apps/web/src/persistence/export-import.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type {
|
||||
Encounter,
|
||||
ExportBundle,
|
||||
PlayerCharacter,
|
||||
UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
import { rehydrateEncounter } from "./encounter-storage.js";
|
||||
import { rehydrateCharacter } from "./player-character-storage.js";
|
||||
|
||||
function rehydrateStack(raw: unknown[]): Encounter[] {
|
||||
const result: Encounter[] = [];
|
||||
for (const entry of raw) {
|
||||
const rehydrated = rehydrateEncounter(entry);
|
||||
if (rehydrated !== null) {
|
||||
result.push(rehydrated);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function rehydrateCharacters(raw: unknown[]): PlayerCharacter[] {
|
||||
const result: PlayerCharacter[] = [];
|
||||
for (const entry of raw) {
|
||||
const rehydrated = rehydrateCharacter(entry);
|
||||
if (rehydrated !== null) {
|
||||
result.push(rehydrated);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function validateImportBundle(data: unknown): ExportBundle | string {
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||
return "Invalid file format";
|
||||
}
|
||||
|
||||
const obj = data as Record<string, unknown>;
|
||||
|
||||
if (typeof obj.version !== "number" || obj.version !== 1) {
|
||||
return "Invalid file format";
|
||||
}
|
||||
if (typeof obj.exportedAt !== "string") {
|
||||
return "Invalid file format";
|
||||
}
|
||||
if (!Array.isArray(obj.undoStack) || !Array.isArray(obj.redoStack)) {
|
||||
return "Invalid file format";
|
||||
}
|
||||
if (!Array.isArray(obj.playerCharacters)) {
|
||||
return "Invalid file format";
|
||||
}
|
||||
|
||||
const encounter = rehydrateEncounter(obj.encounter);
|
||||
if (encounter === null) {
|
||||
return "Invalid encounter data";
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
exportedAt: obj.exportedAt,
|
||||
encounter,
|
||||
undoStack: rehydrateStack(obj.undoStack),
|
||||
redoStack: rehydrateStack(obj.redoStack),
|
||||
playerCharacters: rehydrateCharacters(obj.playerCharacters),
|
||||
};
|
||||
}
|
||||
|
||||
export function assembleExportBundle(
|
||||
encounter: Encounter,
|
||||
undoRedoState: UndoRedoState,
|
||||
playerCharacters: readonly PlayerCharacter[],
|
||||
): ExportBundle {
|
||||
return {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
encounter,
|
||||
undoStack: undoRedoState.undoStack,
|
||||
redoStack: undoRedoState.redoStack,
|
||||
playerCharacters: [...playerCharacters],
|
||||
};
|
||||
}
|
||||
|
||||
export function triggerDownload(bundle: ExportBundle): void {
|
||||
const json = JSON.stringify(bundle, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
const filename = `initiative-export-${date}.json`;
|
||||
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function readImportFile(
|
||||
file: File,
|
||||
): Promise<ExportBundle | string> {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
return validateImportBundle(parsed);
|
||||
} catch {
|
||||
return "Invalid file format";
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ function isValidOptionalMember(
|
||||
return value === undefined || (typeof value === "string" && valid.has(value));
|
||||
}
|
||||
|
||||
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
||||
export function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
||||
return null;
|
||||
const entry = raw as Record<string, unknown>;
|
||||
|
||||
Reference in New Issue
Block a user