From 4969ed069bdc86b284b72e0d271e44c6df50c1fa Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 27 Mar 2026 14:43:22 +0100 Subject: [PATCH] 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) --- apps/web/src/components/action-bar.tsx | 46 ++++-- .../src/components/import-confirm-prompt.tsx | 2 +- .../src/components/import-method-dialog.tsx | 153 ++++++++++++++++++ specs/007-json-import-export/spec.md | 16 +- 4 files changed, 200 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/import-method-dialog.tsx diff --git a/apps/web/src/components/action-bar.tsx b/apps/web/src/components/action-bar.tsx index 2a6cb64..ec86bfd 100644 --- a/apps/web/src/components/action-bar.tsx +++ b/apps/web/src/components/action-bar.tsx @@ -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(null); const [importError, setImportError] = useState(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) => { - 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) => { + 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} /> )} + importFileRef.current?.click()} + onSubmitClipboard={handleImportClipboard} + onClose={() => setShowImportMethod(false)} + />

Replace current encounter?

diff --git a/apps/web/src/components/import-method-dialog.tsx b/apps/web/src/components/import-method-dialog.tsx new file mode 100644 index 0000000..eb9a628 --- /dev/null +++ b/apps/web/src/components/import-method-dialog.tsx @@ -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) { + const dialogRef = useRef(null); + const textareaRef = useRef(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 ( +

+
+

Import Encounter

+ +
+ {mode === "pick" && ( +
+ + +
+ )} + {mode === "paste" && ( +
+