Add import/export for camera group layouts and per-camera streaming settings (#24025)

* add import/export for camera group layouts and streaming settings

Camera group layouts and per-camera streaming settings are stored in the browser's IndexedDB, so they are tied to a single browser on a single device. Users with more than one device have to rebuild every group layout and re-pick every camera's stream settings by hand, and clearing browser data loses the work.

Add a Backup & Restore card to Settings > UI Settings that exports these settings to a JSON file and imports that file on another device. Import shows a confirmation dialog with per-section counts, switches for layouts, streaming settings, and UI preferences, and warnings about camera groups or cameras in the file that are not on this server.

Server-side storage is deliberately avoided. These are per-device presentation settings: a layout arranged for a desktop is wrong on a tablet, and continuous full-resolution streams that are free on a wired LAN are not on a phone. An explicit file moves settings only when the user chooses to move them.

Implementation notes:

- web/src/utils/uiSettingsTransfer.ts owns a registry of transferable IndexedDB keys. Each entry records whether the key is user-namespaced, matching which persistence hook wrote it, plus a zod schema for its value.
- Only registry-known keys are ever written, and only when their value passes that schema. The file format deliberately lets unknown keys survive parsing, so this filter is what prevents a hand-edited file from writing arbitrary storage keys or out-of-range values.
- Export falls back to the legacy un-namespaced key, because the username migration runs lazily on first mount of each owning hook.
- Streaming settings merge per group rather than replacing the whole map, so groups configured only on the receiving device survive.
- Import writes storage and then reloads, because useUserPersistence reads a key only on mount and StreamingSettingsProvider would otherwise write its stale in-memory state back over the import.
- playbackBandwidthEstimate, frigate-search-history, and live-layout are excluded: the first two are measurements and user data rather than preferences, and live-layout's default is derived from the device.

* merge imported streaming settings per camera instead of per group
This commit is contained in:
Josh Hawkins
2026-08-22 11:40:42 -05:00
parent 07ba2357e6
commit 199bea081c
6 changed files with 1351 additions and 5 deletions
@@ -0,0 +1,212 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { LuTriangleAlert } from "react-icons/lu";
import FilterSwitch from "@/components/filter/FilterSwitch";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import {
ImportSummary,
TransferSection,
UiSettingsFile,
} from "@/utils/uiSettingsTransfer";
type ImportUiSettingsDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string;
file: UiSettingsFile;
summary: ImportSummary;
onConfirm: (sections: Record<TransferSection, boolean>) => Promise<void>;
};
export default function ImportUiSettingsDialog({
open,
onOpenChange,
fileName,
file,
summary,
onConfirm,
}: ImportUiSettingsDialogProps) {
const { t } = useTranslation(["views/settings", "common"]);
const [isImporting, setIsImporting] = useState(false);
const available = useMemo(
() => ({
layouts: summary.layoutGroupCount > 0,
streaming: summary.streamingCameraCount > 0,
preferences: summary.preferenceCount > 0,
}),
[summary],
);
const [sections, setSections] =
useState<Record<TransferSection, boolean>>(available);
// a new file can be chosen while this component stays mounted, so the
// toggles reset to what the current file actually offers each time it
// opens
useEffect(() => {
if (open) {
setSections(available);
setIsImporting(false);
}
}, [open, available]);
const canImport = useMemo(
() =>
!isImporting &&
(Object.keys(sections) as TransferSection[]).some(
(section) => sections[section] && available[section],
),
[isImporting, sections, available],
);
// warn only about what the enabled sections will actually write
const visibleUnknownGroups = useMemo(() => {
const groups = new Set<string>();
if (sections.layouts) {
summary.unknownLayoutGroups.forEach((group) => groups.add(group));
}
if (sections.streaming) {
summary.unknownStreamingGroups.forEach((group) => groups.add(group));
}
return Array.from(groups).sort();
}, [sections, summary]);
const visibleUnknownCameras = useMemo(
() => (sections.streaming ? summary.unknownCameras : []),
[sections.streaming, summary.unknownCameras],
);
const handleConfirm = useCallback(async () => {
setIsImporting(true);
await onConfirm(sections);
setIsImporting(false);
}, [onConfirm, sections]);
const exportedDate = useMemo(() => {
const parsed = new Date(file.exported_at);
return isNaN(parsed.getTime()) ? file.exported_at : parsed.toLocaleString();
}, [file.exported_at]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="scrollbar-container max-h-[80dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{t("general.backupRestore.importDialog.title")}
</DialogTitle>
<DialogDescription>
{t("general.backupRestore.importDialog.desc")}
</DialogDescription>
</DialogHeader>
<div className="space-y-0.5">
<p className="break-all text-base text-primary-variant">{fileName}</p>
<p className="text-sm text-muted-foreground">
{t("general.backupRestore.importDialog.exportedFrom", {
date: exportedDate,
version: file.frigate_version,
})}
</p>
</div>
<div className="space-y-3">
<FilterSwitch
label={t("general.backupRestore.importDialog.layouts", {
count: summary.layoutGroupCount,
})}
isChecked={sections.layouts}
disabled={!available.layouts || isImporting}
onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, layouts: checked }))
}
/>
<FilterSwitch
label={t("general.backupRestore.importDialog.streaming", {
count: summary.streamingCameraCount,
})}
isChecked={sections.streaming}
disabled={!available.streaming || isImporting}
onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, streaming: checked }))
}
/>
<FilterSwitch
label={t("general.backupRestore.importDialog.preferences", {
count: summary.preferenceCount,
})}
isChecked={sections.preferences}
disabled={!available.preferences || isImporting}
onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, preferences: checked }))
}
/>
</div>
{(visibleUnknownGroups.length > 0 ||
visibleUnknownCameras.length > 0) && (
<Alert variant="warning">
<LuTriangleAlert className="size-5" />
<AlertDescription className="space-y-2">
{visibleUnknownGroups.length > 0 && (
<p>
{t("general.backupRestore.importDialog.unknownGroups", {
count: visibleUnknownGroups.length,
groups: visibleUnknownGroups.join(", "),
})}
</p>
)}
{visibleUnknownCameras.length > 0 && (
<p>
{t("general.backupRestore.importDialog.unknownCameras", {
count: visibleUnknownCameras.length,
cameras: visibleUnknownCameras.join(", "),
})}
</p>
)}
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={() => onOpenChange(false)}
disabled={isImporting}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("general.backupRestore.importDialog.confirm")}
onClick={handleConfirm}
disabled={!canImport}
>
{isImporting ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("general.backupRestore.importDialog.confirm")}</span>
</div>
) : (
t("general.backupRestore.importDialog.confirm")
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+459
View File
@@ -0,0 +1,459 @@
import { get as getData, set as setData } from "idb-keyval";
import { z } from "zod";
import { getUserNamespacedKey } from "@/hooks/use-user-persistence";
export const UI_SETTINGS_FILE_TYPE = "frigate-ui-settings";
export const UI_SETTINGS_FILE_VERSION = 1;
export type TransferSection = "layouts" | "streaming" | "preferences";
const cameraStreamingSettingsSchema = z.object({
streamName: z.string(),
streamType: z.enum(["no-streaming", "smart", "continuous"]),
compatibilityMode: z.boolean(),
playAudio: z.boolean(),
volume: z.number(),
});
const allGroupsStreamingSettingsSchema = z.record(
z.string(),
z.record(z.string(), cameraStreamingSettingsSchema),
);
type TransferKey = {
key: string;
section: Exclude<TransferSection, "layouts">;
namespaced: boolean;
// preference values are validated individually on import: an out-of-range
// value would otherwise be written and crash the view that reads it
schema: z.ZodType;
};
// Single source of truth for which browser-stored settings move between
// devices. Layout keys are derived per camera group at export time and so
// are not listed here. `namespaced` mirrors which persistence hook wrote
// the key: useUserPersistence namespaces by username, usePersistence does
// not.
export const TRANSFER_KEYS: TransferKey[] = [
{
key: "streaming-settings",
section: "streaming",
namespaced: true,
schema: allGroupsStreamingSettingsSchema,
},
{
key: "autoLiveView",
section: "preferences",
namespaced: true,
schema: z.boolean(),
},
{
key: "displayCameraNames",
section: "preferences",
namespaced: true,
schema: z.boolean(),
},
{
key: "alertVideos",
section: "preferences",
namespaced: true,
schema: z.boolean(),
},
{
key: "liveFallbackTimeout",
section: "preferences",
namespaced: true,
schema: z.number().int().min(1).max(60),
},
{
key: "playbackRate",
section: "preferences",
namespaced: true,
schema: z.number().positive().max(64),
},
{
key: "weekStartsOn",
section: "preferences",
namespaced: true,
schema: z.union([z.literal(0), z.literal(1)]),
},
{
key: "showReviewed",
section: "preferences",
namespaced: true,
schema: z.boolean(),
},
{
key: "exploreGridColumns",
section: "preferences",
namespaced: true,
schema: z.number().int().min(1).max(12),
},
{
key: "exploreDefaultView",
section: "preferences",
namespaced: true,
schema: z.enum(["summary", "grid"]),
},
{
key: "detailStreamActiveExpanded",
section: "preferences",
namespaced: true,
schema: z.boolean(),
},
{
key: "hlsPlayerMuted",
section: "preferences",
namespaced: true,
schema: z.boolean(),
},
{
key: "recordingQuality",
section: "preferences",
namespaced: false,
schema: z.enum(["auto", "main", "sub"]),
},
{
key: "chat-show-stats",
section: "preferences",
namespaced: false,
schema: z.enum(["while_generating", "always"]),
},
{
key: "chat-auto-scroll",
section: "preferences",
namespaced: false,
schema: z.boolean(),
},
{
key: "chat-thinking-enabled",
section: "preferences",
namespaced: false,
schema: z.boolean(),
},
];
export function layoutKeyForGroup(group: string): string {
return `${group}-draggable-layout`;
}
function storageKey(
entry: { key: string; namespaced: boolean },
username: string | undefined,
): string {
return entry.namespaced
? getUserNamespacedKey(entry.key, username)
: entry.key;
}
// useUserPersistence migrates legacy un-namespaced keys lazily, on first
// mount of the hook that owns each key, so a value from a view the user
// has not opened since upgrading still lives under the bare key.
async function readTransferable(
key: string,
namespaced: boolean,
username: string | undefined,
) {
if (!namespaced) {
return getData(key);
}
const namespacedKey = getUserNamespacedKey(key, username);
const value = await getData(namespacedKey);
if (value !== undefined || namespacedKey === key) {
return value;
}
return getData(key);
}
// Layout items carry optional react-grid-layout fields (minW, static,
// moved, and others) that vary by version, so unknown keys pass through
// rather than failing validation.
const layoutItemSchema = z
.object({
i: z.string(),
x: z.number(),
y: z.number(),
w: z.number(),
h: z.number(),
})
.passthrough();
export const uiSettingsFileSchema = z.object({
type: z.literal(UI_SETTINGS_FILE_TYPE),
version: z.number().int().positive(),
exported_at: z.string(),
frigate_version: z.string(),
sections: z.object({
layouts: z.record(z.string(), z.array(layoutItemSchema)),
streaming: allGroupsStreamingSettingsSchema,
preferences: z.record(z.string(), z.unknown()),
}),
});
export type UiSettingsFile = z.infer<typeof uiSettingsFileSchema>;
export async function buildExportPayload(
groupNames: string[],
frigateVersion: string,
username: string | undefined,
): Promise<UiSettingsFile> {
const layouts: UiSettingsFile["sections"]["layouts"] = {};
let streaming: UiSettingsFile["sections"]["streaming"] = {};
const preferences: UiSettingsFile["sections"]["preferences"] = {};
await Promise.all(
groupNames.map(async (group) => {
const value = await readTransferable(
layoutKeyForGroup(group),
true,
username,
);
if (value !== undefined) {
layouts[group] = value;
}
}),
);
await Promise.all(
TRANSFER_KEYS.map(async (entry) => {
const value = await readTransferable(
entry.key,
entry.namespaced,
username,
);
if (value === undefined) {
return;
}
if (entry.section === "streaming") {
// this key holds the entire group -> camera map, so its value is
// the section payload rather than one entry within it
streaming = value;
return;
}
preferences[entry.key] = value;
}),
);
return {
type: UI_SETTINGS_FILE_TYPE,
version: UI_SETTINGS_FILE_VERSION,
exported_at: new Date().toISOString(),
frigate_version: frigateVersion,
sections: { layouts, streaming, preferences },
};
}
export function exportFileName(now: Date): string {
// local date rather than toISOString: a user west of UTC exporting in
// the evening would otherwise get tomorrow's date in the filename
const month = `${now.getMonth() + 1}`.padStart(2, "0");
const day = `${now.getDate()}`.padStart(2, "0");
return `frigate-ui-settings-${now.getFullYear()}-${month}-${day}.json`;
}
export function downloadJson(payload: unknown, fileName: string): void {
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const element = document.createElement("a");
element.href = url;
element.download = fileName;
element.style.display = "none";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
URL.revokeObjectURL(url);
}
export type ParseError =
| "invalid_json"
| "wrong_type"
| "unsupported_version"
| "invalid_schema";
export type ParseResult =
| { ok: true; file: UiSettingsFile }
| { ok: false; error: ParseError };
export function parseUiSettingsFile(text: string): ParseResult {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
return { ok: false, error: "invalid_json" };
}
if (
typeof raw !== "object" ||
raw === null ||
(raw as { type?: unknown }).type !== UI_SETTINGS_FILE_TYPE
) {
return { ok: false, error: "wrong_type" };
}
const version = (raw as { version?: unknown }).version;
if (typeof version === "number" && version > UI_SETTINGS_FILE_VERSION) {
return { ok: false, error: "unsupported_version" };
}
const parsed = uiSettingsFileSchema.safeParse(raw);
if (!parsed.success) {
return { ok: false, error: "invalid_schema" };
}
return { ok: true, file: parsed.data };
}
export type ImportSummary = {
layoutGroupCount: number;
streamingCameraCount: number;
preferenceCount: number;
// unknown groups are split by the section that named them so the dialog
// can warn only about sections the user is actually importing
unknownLayoutGroups: string[];
unknownStreamingGroups: string[];
unknownCameras: string[];
};
// the only place preference entries are accepted: the import counts and the
// writes must agree, or the dialog reports settings it will not apply
function validPreferenceEntries(
preferences: UiSettingsFile["sections"]["preferences"],
): { entry: TransferKey; value: unknown }[] {
const accepted: { entry: TransferKey; value: unknown }[] = [];
TRANSFER_KEYS.forEach((entry) => {
if (entry.section !== "preferences") {
return;
}
// hasOwnProperty rather than `in`, which walks the prototype chain
if (!Object.prototype.hasOwnProperty.call(preferences, entry.key)) {
return;
}
const parsed = entry.schema.safeParse(preferences[entry.key]);
if (parsed.success) {
accepted.push({ entry, value: parsed.data });
}
});
return accepted;
}
export function summarizeImport(
file: UiSettingsFile,
knownGroups: string[],
knownCameras: string[],
): ImportSummary {
const layoutGroups = Object.keys(file.sections.layouts);
const streamingGroups = Object.keys(file.sections.streaming);
const streamingCameras = new Set<string>();
Object.values(file.sections.streaming).forEach((group) =>
Object.keys(group).forEach((camera) => streamingCameras.add(camera)),
);
const knownGroupSet = new Set(knownGroups);
const knownCameraSet = new Set(knownCameras);
return {
layoutGroupCount: layoutGroups.length,
streamingCameraCount: streamingCameras.size,
preferenceCount: validPreferenceEntries(file.sections.preferences).length,
unknownLayoutGroups: layoutGroups
.filter((group) => !knownGroupSet.has(group))
.sort(),
unknownStreamingGroups: streamingGroups
.filter((group) => !knownGroupSet.has(group))
.sort(),
unknownCameras: Array.from(streamingCameras)
.filter((camera) => !knownCameraSet.has(camera))
.sort(),
};
}
export function hasImportableContent(summary: ImportSummary): boolean {
return (
summary.layoutGroupCount > 0 ||
summary.streamingCameraCount > 0 ||
summary.preferenceCount > 0
);
}
export async function applyImportPayload(
file: UiSettingsFile,
sections: Record<TransferSection, boolean>,
username: string | undefined,
): Promise<void> {
const writes: Promise<void>[] = [];
if (sections.layouts) {
Object.entries(file.sections.layouts).forEach(([group, layout]) => {
writes.push(
setData(
getUserNamespacedKey(layoutKeyForGroup(group), username),
layout,
),
);
});
}
const streamingEntry = TRANSFER_KEYS.find(
(entry) => entry.section === "streaming",
);
if (
sections.streaming &&
streamingEntry &&
Object.keys(file.sections.streaming).length > 0
) {
writes.push(
(async () => {
// one key holds every group and every camera within it, so merge
// at camera level: a group or a camera configured only on this
// device must survive an import that does not mention it
const existing =
(await readTransferable(
streamingEntry.key,
streamingEntry.namespaced,
username,
)) ?? {};
const merged: UiSettingsFile["sections"]["streaming"] = {
...existing,
};
Object.entries(file.sections.streaming).forEach(([group, cameras]) => {
merged[group] = { ...(existing[group] ?? {}), ...cameras };
});
await setData(storageKey(streamingEntry, username), merged);
})(),
);
}
if (sections.preferences) {
validPreferenceEntries(file.sections.preferences).forEach(
({ entry, value }) => {
writes.push(setData(storageKey(entry, username), value));
},
);
}
await Promise.all(writes);
}
+198 -1
View File
@@ -1,6 +1,14 @@
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { ReactNode, useCallback, useContext, useEffect } from "react";
import {
ChangeEvent,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import useSWR from "swr";
@@ -26,8 +34,23 @@ import {
CONTROL_COLUMN_CLASS_NAME,
} from "@/components/card/SettingsGroupCard";
import Heading from "@/components/ui/heading";
import ImportUiSettingsDialog from "@/components/overlay/dialog/ImportUiSettingsDialog";
import {
applyImportPayload,
buildExportPayload,
downloadJson,
exportFileName,
hasImportableContent,
ImportSummary,
ParseError,
parseUiSettingsFile,
summarizeImport,
TransferSection,
UiSettingsFile,
} from "@/utils/uiSettingsTransfer";
const WEEK_STARTS_ON = ["Sunday", "Monday"];
const IMPORT_FAILED_FLAG = "frigate-ui-settings-import-failed";
type SwitchSettingRowProps = {
id: string;
@@ -168,10 +191,132 @@ export default function UiSettingsView() {
});
}, [config, t, username]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingImport, setPendingImport] = useState<{
name: string;
file: UiSettingsFile;
summary: ImportSummary;
} | null>(null);
const importErrorMessage = useCallback(
(error: ParseError) => {
// literal keys per branch: a template key would be invisible to
// npm run i18n:extract, which CI verifies
switch (error) {
case "invalid_json":
return t("general.toast.error.importInvalidJson");
case "wrong_type":
return t("general.toast.error.importWrongType");
case "unsupported_version":
return t("general.toast.error.importUnsupportedVersion");
case "invalid_schema":
return t("general.toast.error.importInvalidSchema");
}
},
[t],
);
const handleExport = useCallback(async () => {
if (!config || auth.isLoading) {
return;
}
let payload: UiSettingsFile;
try {
payload = await buildExportPayload(
Object.keys(config.camera_groups),
config.version,
username,
);
} catch {
toast.error(t("general.toast.error.exportUiSettingsFailed"), {
position: "top-center",
});
return;
}
downloadJson(payload, exportFileName(new Date()));
toast.success(t("general.toast.success.exportUiSettings"), {
position: "top-center",
});
}, [config, auth.isLoading, username, t]);
const handleFileSelected = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
const selected = event.target.files?.[0];
// reset so choosing the same file again still fires a change event
event.target.value = "";
if (!selected || !config || auth.isLoading) {
return;
}
const result = parseUiSettingsFile(await selected.text());
if (!result.ok) {
toast.error(importErrorMessage(result.error), {
position: "top-center",
});
return;
}
const summary = summarizeImport(
result.file,
Object.keys(config.camera_groups),
Object.keys(config.cameras),
);
// a file exported from a browser with nothing stored is structurally
// valid, and would open a dialog with every switch disabled
if (!hasImportableContent(summary)) {
toast.error(t("general.toast.error.importNothingToApply"), {
position: "top-center",
});
return;
}
setPendingImport({ name: selected.name, file: result.file, summary });
},
[config, auth.isLoading, importErrorMessage, t],
);
const handleImportConfirm = useCallback(
async (sections: Record<TransferSection, boolean>) => {
if (!pendingImport || auth.isLoading) {
return;
}
try {
await applyImportPayload(pendingImport.file, sections, username);
} catch {
// writes are already in flight when one rejects, so reload anyway:
// staying mounted lets the persistence providers write their stale
// state back over whatever did land
sessionStorage.setItem(IMPORT_FAILED_FLAG, "1");
}
window.location.reload();
},
[pendingImport, auth.isLoading, username],
);
useEffect(() => {
document.title = t("documentTitle.general");
}, [t]);
useEffect(() => {
if (!sessionStorage.getItem(IMPORT_FAILED_FLAG)) {
return;
}
sessionStorage.removeItem(IMPORT_FAILED_FLAG);
toast.error(t("general.toast.error.importUiSettingsFailed"), {
position: "top-center",
});
}, [t]);
const [autoLive, setAutoLive] = useUserPersistence("autoLiveView", true);
const [cameraNames, setCameraName] = useUserPersistence(
"displayCameraNames",
@@ -300,6 +445,43 @@ export default function UiSettingsView() {
</div>
</SettingsGroupCard>
<SettingsGroupCard title={t("general.backupRestore.title")}>
<ValueSettingRow
id="ui-settings-transfer"
label={t("general.backupRestore.transfer.label")}
description={t("general.backupRestore.transfer.desc")}
control={
<div className="flex flex-col gap-2 md:flex-row">
<Button
id="ui-settings-export"
aria-label={t("general.backupRestore.transfer.export")}
className="w-full md:w-auto"
disabled={auth.isLoading || !config}
onClick={handleExport}
>
{t("general.backupRestore.transfer.export")}
</Button>
<Button
id="ui-settings-import"
aria-label={t("general.backupRestore.transfer.import")}
className="w-full md:w-auto"
disabled={auth.isLoading || !config}
onClick={() => fileInputRef.current?.click()}
>
{t("general.backupRestore.transfer.import")}
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={handleFileSelected}
/>
</div>
}
/>
</SettingsGroupCard>
<SettingsGroupCard title={t("general.recordingsViewer.title")}>
<ValueSettingRow
id="default-playback-rate"
@@ -376,6 +558,21 @@ export default function UiSettingsView() {
</SettingsGroupCard>
</div>
</div>
{pendingImport && (
<ImportUiSettingsDialog
open={pendingImport != null}
onOpenChange={(open) => {
if (!open) {
setPendingImport(null);
}
}}
fileName={pendingImport.name}
file={pendingImport.file}
summary={pendingImport.summary}
onConfirm={handleImportConfirm}
/>
)}
</div>
);
}