Add import method dialog with file upload and paste options

Replace direct file picker trigger with a modal offering two import
methods: file upload and paste JSON content. Uses a textarea instead
of navigator.clipboard.readText() to avoid browser permission prompts.
Also centers both import dialogs and updates spec for clipboard import.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-27 14:43:22 +01:00
parent fba83bebd6
commit 4969ed069b
4 changed files with 200 additions and 17 deletions

View File

@@ -30,9 +30,11 @@ import {
assembleExportBundle,
readImportFile,
triggerDownload,
validateImportBundle,
} from "../persistence/export-import.js";
import { D20Icon } from "./d20-icon.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";
import { RollModeMenu } from "./roll-mode-menu.js";
import { Toast } from "./toast.js";
@@ -447,6 +449,7 @@ export function ActionBar({
const importFileRef = useRef<HTMLInputElement>(null);
const [importError, setImportError] = useState<string | null>(null);
const [showImportMethod, setShowImportMethod] = useState(false);
const [showImportConfirm, setShowImportConfirm] = useState(false);
const pendingBundleRef = useRef<
import("@initiative/domain").ExportBundle | null
@@ -473,14 +476,8 @@ export function ActionBar({
[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);
const handleValidatedBundle = useCallback(
(result: import("@initiative/domain").ExportBundle | string) => {
if (typeof result === "string") {
setImportError(result);
return;
@@ -495,6 +492,31 @@ export function ActionBar({
[encounterIsEmpty, applyImport],
);
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);
handleValidatedBundle(await readImportFile(file));
},
[handleValidatedBundle],
);
const handleImportClipboard = useCallback(
(text: string) => {
setImportError(null);
try {
const parsed: unknown = JSON.parse(text);
handleValidatedBundle(validateImportBundle(parsed));
} catch {
setImportError("Invalid file format");
}
},
[handleValidatedBundle],
);
const handleImportConfirm = useCallback(() => {
if (pendingBundleRef.current) {
applyImport(pendingBundleRef.current);
@@ -515,7 +537,7 @@ export function ActionBar({
onBulkImport: showBulkImport,
bulkImportDisabled: bulkImportState.status === "loading",
onExportEncounter: handleExport,
onImportEncounter: () => importFileRef.current?.click(),
onImportEncounter: () => setShowImportMethod(true),
onOpenSettings,
});
@@ -611,6 +633,12 @@ export function ActionBar({
autoDismissMs={5000}
/>
)}
<ImportMethodDialog
open={showImportMethod}
onSelectFile={() => importFileRef.current?.click()}
onSubmitClipboard={handleImportClipboard}
onClose={() => setShowImportMethod(false)}
/>
<ImportConfirmDialog
open={showImportConfirm}
onConfirm={handleImportConfirm}

View File

@@ -42,7 +42,7 @@ export function ImportConfirmDialog({
return (
<dialog
ref={dialogRef}
className="rounded-lg border border-border bg-card p-6 text-foreground shadow-xl backdrop:bg-black/50"
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>
<p className="mb-4 text-muted-foreground text-sm">

View File

@@ -0,0 +1,153 @@
import { ClipboardPaste, FileUp, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "./ui/button.js";
interface ImportMethodDialogProps {
open: boolean;
onSelectFile: () => void;
onSubmitClipboard: (text: string) => void;
onClose: () => void;
}
export function ImportMethodDialog({
open,
onSelectFile,
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(() => {
setMode("pick");
setPasteText("");
}, []);
const handleClose = useCallback(() => {
reset();
onClose();
}, [reset, onClose]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
else if (!open && dialog.open) {
dialog.close();
reset();
}
}, [open, reset]);
useEffect(() => {
if (mode === "paste") {
textareaRef.current?.focus();
}
}, [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"
>
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-lg">Import Encounter</h2>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={handleClose}
className="text-muted-foreground"
>
<X className="h-4 w-4" />
</Button>
</div>
{mode === "pick" && (
<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={() => {
handleClose();
onSelectFile();
}}
>
<FileUp className="h-5 w-5 text-muted-foreground" />
<div>
<div className="font-medium">From file</div>
<div className="text-muted-foreground text-xs">
Upload 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={() => setMode("paste")}
>
<ClipboardPaste className="h-5 w-5 text-muted-foreground" />
<div>
<div className="font-medium">Paste content</div>
<div className="text-muted-foreground text-xs">
Paste JSON content directly
</div>
</div>
</button>
</div>
)}
{mode === "paste" && (
<div className="flex flex-col gap-3">
<textarea
ref={textareaRef}
value={pasteText}
onChange={(e) => setPasteText(e.target.value)}
placeholder="Paste exported JSON here..."
className="h-32 w-full resize-none rounded-md border border-border bg-background px-3 py-2 font-mono text-foreground text-xs placeholder:text-muted-foreground focus:border-accent focus:outline-none"
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={() => {
setMode("pick");
setPasteText("");
}}
>
Back
</Button>
<Button
type="button"
disabled={pasteText.trim().length === 0}
onClick={() => {
const text = pasteText;
handleClose();
onSubmitClipboard(text);
}}
>
Import
</Button>
</div>
</div>
)}
</dialog>
);
}