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:
@@ -28,11 +28,13 @@ import { useLongPress } from "../hooks/use-long-press.js";
|
|||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
import {
|
import {
|
||||||
assembleExportBundle,
|
assembleExportBundle,
|
||||||
|
bundleToJson,
|
||||||
readImportFile,
|
readImportFile,
|
||||||
triggerDownload,
|
triggerDownload,
|
||||||
validateImportBundle,
|
validateImportBundle,
|
||||||
} from "../persistence/export-import.js";
|
} from "../persistence/export-import.js";
|
||||||
import { D20Icon } from "./d20-icon.js";
|
import { D20Icon } from "./d20-icon.js";
|
||||||
|
import { ExportMethodDialog } from "./export-method-dialog.js";
|
||||||
import { ImportConfirmDialog } from "./import-confirm-prompt.js";
|
import { ImportConfirmDialog } from "./import-confirm-prompt.js";
|
||||||
import { ImportMethodDialog } from "./import-method-dialog.js";
|
import { ImportMethodDialog } from "./import-method-dialog.js";
|
||||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.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 importFileRef = useRef<HTMLInputElement>(null);
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
const [showExportMethod, setShowExportMethod] = useState(false);
|
||||||
const [showImportMethod, setShowImportMethod] = useState(false);
|
const [showImportMethod, setShowImportMethod] = useState(false);
|
||||||
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||||
const pendingBundleRef = useRef<
|
const pendingBundleRef = useRef<
|
||||||
import("@initiative/domain").ExportBundle | null
|
import("@initiative/domain").ExportBundle | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const handleExport = useCallback(() => {
|
const handleExportDownload = useCallback(
|
||||||
const bundle = assembleExportBundle(
|
(includeHistory: boolean) => {
|
||||||
encounter,
|
const bundle = assembleExportBundle(
|
||||||
undoRedoState,
|
encounter,
|
||||||
playerCharacters,
|
undoRedoState,
|
||||||
);
|
playerCharacters,
|
||||||
triggerDownload(bundle);
|
includeHistory,
|
||||||
}, [encounter, undoRedoState, playerCharacters]);
|
);
|
||||||
|
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(
|
const applyImport = useCallback(
|
||||||
(bundle: import("@initiative/domain").ExportBundle) => {
|
(bundle: import("@initiative/domain").ExportBundle) => {
|
||||||
@@ -536,7 +556,7 @@ export function ActionBar({
|
|||||||
bestiaryLoaded,
|
bestiaryLoaded,
|
||||||
onBulkImport: showBulkImport,
|
onBulkImport: showBulkImport,
|
||||||
bulkImportDisabled: bulkImportState.status === "loading",
|
bulkImportDisabled: bulkImportState.status === "loading",
|
||||||
onExportEncounter: handleExport,
|
onExportEncounter: () => setShowExportMethod(true),
|
||||||
onImportEncounter: () => setShowImportMethod(true),
|
onImportEncounter: () => setShowImportMethod(true),
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
});
|
});
|
||||||
@@ -633,6 +653,12 @@ export function ActionBar({
|
|||||||
autoDismissMs={5000}
|
autoDismissMs={5000}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ExportMethodDialog
|
||||||
|
open={showExportMethod}
|
||||||
|
onDownload={handleExportDownload}
|
||||||
|
onCopyToClipboard={handleExportClipboard}
|
||||||
|
onClose={() => setShowExportMethod(false)}
|
||||||
|
/>
|
||||||
<ImportMethodDialog
|
<ImportMethodDialog
|
||||||
open={showImportMethod}
|
open={showImportMethod}
|
||||||
onSelectFile={() => importFileRef.current?.click()}
|
onSelectFile={() => importFileRef.current?.click()}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { PlayerCharacter } from "@initiative/domain";
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ColorPalette } from "./color-palette";
|
import { ColorPalette } from "./color-palette";
|
||||||
import { IconGrid } from "./icon-grid";
|
import { IconGrid } from "./icon-grid";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
import { Dialog } from "./ui/dialog";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
interface CreatePlayerModalProps {
|
interface CreatePlayerModalProps {
|
||||||
@@ -25,7 +26,6 @@ export function CreatePlayerModal({
|
|||||||
onSave,
|
onSave,
|
||||||
playerCharacter,
|
playerCharacter,
|
||||||
}: Readonly<CreatePlayerModalProps>) {
|
}: Readonly<CreatePlayerModalProps>) {
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [ac, setAc] = useState("10");
|
const [ac, setAc] = useState("10");
|
||||||
const [maxHp, setMaxHp] = useState("10");
|
const [maxHp, setMaxHp] = useState("10");
|
||||||
@@ -54,34 +54,6 @@ export function CreatePlayerModal({
|
|||||||
}
|
}
|
||||||
}, [open, playerCharacter]);
|
}, [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>) => {
|
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
@@ -104,10 +76,7 @@ export function CreatePlayerModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dialog
|
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="font-semibold text-foreground text-lg">
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
{isEdit ? "Edit Player" : "Create Player"}
|
{isEdit ? "Edit Player" : "Create Player"}
|
||||||
@@ -187,6 +156,6 @@ export function CreatePlayerModal({
|
|||||||
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
94
apps/web/src/components/export-method-dialog.tsx
Normal file
94
apps/web/src/components/export-method-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
|
import { Dialog } from "./ui/dialog.js";
|
||||||
|
|
||||||
interface ImportConfirmDialogProps {
|
interface ImportConfirmDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -12,38 +12,8 @@ export function ImportConfirmDialog({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: Readonly<ImportConfirmDialogProps>) {
|
}: 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 (
|
return (
|
||||||
<dialog
|
<Dialog open={open} onClose={onCancel}>
|
||||||
ref={dialogRef}
|
|
||||||
className="m-auto 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>
|
<h2 className="mb-2 font-semibold text-lg">Replace current encounter?</h2>
|
||||||
<p className="mb-4 text-muted-foreground text-sm">
|
<p className="mb-4 text-muted-foreground text-sm">
|
||||||
Importing will replace your current encounter, undo/redo history, and
|
Importing will replace your current encounter, undo/redo history, and
|
||||||
@@ -57,6 +27,6 @@ export function ImportConfirmDialog({
|
|||||||
Import
|
Import
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ClipboardPaste, FileUp, X } from "lucide-react";
|
import { ClipboardPaste, FileUp, X } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
|
import { Dialog } from "./ui/dialog.js";
|
||||||
|
|
||||||
interface ImportMethodDialogProps {
|
interface ImportMethodDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -15,30 +16,22 @@ export function ImportMethodDialog({
|
|||||||
onSubmitClipboard,
|
onSubmitClipboard,
|
||||||
onClose,
|
onClose,
|
||||||
}: Readonly<ImportMethodDialogProps>) {
|
}: Readonly<ImportMethodDialogProps>) {
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const [mode, setMode] = useState<"pick" | "paste">("pick");
|
const [mode, setMode] = useState<"pick" | "paste">("pick");
|
||||||
const [pasteText, setPasteText] = useState("");
|
const [pasteText, setPasteText] = useState("");
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setMode("pick");
|
setMode("pick");
|
||||||
setPasteText("");
|
setPasteText("");
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
reset();
|
|
||||||
onClose();
|
onClose();
|
||||||
}, [reset, onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dialog = dialogRef.current;
|
if (!open) {
|
||||||
if (!dialog) return;
|
setMode("pick");
|
||||||
if (open && !dialog.open) dialog.showModal();
|
setPasteText("");
|
||||||
else if (!open && dialog.open) {
|
|
||||||
dialog.close();
|
|
||||||
reset();
|
|
||||||
}
|
}
|
||||||
}, [open, reset]);
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode === "paste") {
|
if (mode === "paste") {
|
||||||
@@ -46,29 +39,8 @@ export function ImportMethodDialog({
|
|||||||
}
|
}
|
||||||
}, [mode]);
|
}, [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 (
|
return (
|
||||||
<dialog
|
<Dialog open={open} onClose={handleClose} className="w-80">
|
||||||
ref={dialogRef}
|
|
||||||
className="m-auto w-80 rounded-lg border border-border bg-card p-6 text-foreground shadow-xl backdrop:bg-black/50"
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="font-semibold text-lg">Import Encounter</h2>
|
<h2 className="font-semibold text-lg">Import Encounter</h2>
|
||||||
<Button
|
<Button
|
||||||
@@ -148,6 +120,6 @@ export function ImportMethodDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
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 { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ConfirmButton } from "./ui/confirm-button";
|
import { ConfirmButton } from "./ui/confirm-button";
|
||||||
|
import { Dialog } from "./ui/dialog";
|
||||||
|
|
||||||
interface PlayerManagementProps {
|
interface PlayerManagementProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -22,41 +22,8 @@ export function PlayerManagement({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onCreate,
|
onCreate,
|
||||||
}: Readonly<PlayerManagementProps>) {
|
}: 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 (
|
return (
|
||||||
<dialog
|
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="font-semibold text-foreground text-lg">
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
Player Characters
|
Player Characters
|
||||||
@@ -128,6 +95,6 @@ export function PlayerManagement({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { RulesEdition } from "@initiative/domain";
|
import type { RulesEdition } from "@initiative/domain";
|
||||||
import { Monitor, Moon, Sun, X } from "lucide-react";
|
import { Monitor, Moon, Sun, X } from "lucide-react";
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { useThemeContext } from "../contexts/theme-context.js";
|
import { useThemeContext } from "../contexts/theme-context.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
|
import { Dialog } from "./ui/dialog.js";
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -27,40 +27,11 @@ const THEME_OPTIONS: {
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
||||||
const { edition, setEdition } = useRulesEditionContext();
|
const { edition, setEdition } = useRulesEditionContext();
|
||||||
const { preference, setPreference } = useThemeContext();
|
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 (
|
return (
|
||||||
<dialog
|
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-sm">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="font-semibold text-foreground text-lg">Settings</h2>
|
<h2 className="font-semibold text-foreground text-lg">Settings</h2>
|
||||||
<Button
|
<Button
|
||||||
@@ -124,6 +95,6 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
50
apps/web/src/components/ui/dialog.tsx
Normal file
50
apps/web/src/components/ui/dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -68,20 +68,24 @@ export function assembleExportBundle(
|
|||||||
encounter: Encounter,
|
encounter: Encounter,
|
||||||
undoRedoState: UndoRedoState,
|
undoRedoState: UndoRedoState,
|
||||||
playerCharacters: readonly PlayerCharacter[],
|
playerCharacters: readonly PlayerCharacter[],
|
||||||
|
includeHistory = true,
|
||||||
): ExportBundle {
|
): ExportBundle {
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
encounter,
|
encounter,
|
||||||
undoStack: undoRedoState.undoStack,
|
undoStack: includeHistory ? undoRedoState.undoStack : [],
|
||||||
redoStack: undoRedoState.redoStack,
|
redoStack: includeHistory ? undoRedoState.redoStack : [],
|
||||||
playerCharacters: [...playerCharacters],
|
playerCharacters: [...playerCharacters],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function bundleToJson(bundle: ExportBundle): string {
|
||||||
|
return JSON.stringify(bundle, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
export function triggerDownload(bundle: ExportBundle): void {
|
export function triggerDownload(bundle: ExportBundle): void {
|
||||||
const json = JSON.stringify(bundle, null, 2);
|
const blob = new Blob([bundleToJson(bundle)], { type: "application/json" });
|
||||||
const blob = new Blob([json], { type: "application/json" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
const date = new Date().toISOString().slice(0, 10);
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
|
|||||||
@@ -9,17 +9,19 @@
|
|||||||
|
|
||||||
### Story IE-1 — Export encounter to file (Priority: P1)
|
### 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.
|
**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**:
|
**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.
|
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 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.
|
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 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.
|
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
|
### 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-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 file MUST contain the current encounter (combatants and turn/round state), undo/redo stacks, and player character templates.
|
- **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-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-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.
|
- **FR-005**: On import, the system MUST replace the current encounter, undo/redo history, and player characters with the imported data.
|
||||||
|
|||||||
Reference in New Issue
Block a user