diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 5d827c0ec..bb3891932 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -91,6 +91,23 @@ "desc": "Do you want to save your changes before continuing?" } }, + "saveAllPreview": { + "title": "Changes to be saved", + "triggerLabel": "Review pending changes", + "empty": "No pending changes.", + "scope": { + "label": "Scope", + "global": "Global", + "camera": "Camera: {{cameraName}}" + }, + "field": { + "label": "Field" + }, + "value": { + "label": "New value", + "reset": "Reset" + } + }, "cameraSetting": { "camera": "Camera", "noCamera": "No Camera" diff --git a/web/src/components/overlay/detail/SaveAllPreviewPopover.tsx b/web/src/components/overlay/detail/SaveAllPreviewPopover.tsx new file mode 100644 index 000000000..5917d0816 --- /dev/null +++ b/web/src/components/overlay/detail/SaveAllPreviewPopover.tsx @@ -0,0 +1,142 @@ +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LuInfo, LuX } from "react-icons/lu"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export type SaveAllPreviewItem = { + scope: "global" | "camera"; + cameraName?: string; + fieldPath: string; + value: unknown; +}; + +type SaveAllPreviewPopoverProps = { + items: SaveAllPreviewItem[]; + className?: string; + align?: "start" | "center" | "end"; + side?: "top" | "bottom" | "left" | "right"; +}; + +export default function SaveAllPreviewPopover({ + items, + className, + align = "end", + side = "bottom", +}: SaveAllPreviewPopoverProps) { + const { t } = useTranslation(["views/settings", "common"]); + const [open, setOpen] = useState(false); + const resetLabel = t("saveAllPreview.value.reset", { + ns: "views/settings", + }); + + const formatValue = useCallback( + (value: unknown) => { + if (value === "") return resetLabel; + if (typeof value === "string") return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + }, + [resetLabel], + ); + + return ( + + + + + event.preventDefault()} + > +
+
+ {t("saveAllPreview.title", { ns: "views/settings" })} +
+ +
+ {items.length === 0 ? ( +
+ {t("saveAllPreview.empty", { ns: "views/settings" })} +
+ ) : ( +
+ {items.map((item) => { + const scopeLabel = + item.scope === "global" + ? t("saveAllPreview.scope.global", { + ns: "views/settings", + }) + : t("saveAllPreview.scope.camera", { + ns: "views/settings", + cameraName: item.cameraName, + }); + return ( +
+
+ + {t("saveAllPreview.scope.label", { + ns: "views/settings", + })} + + {scopeLabel} + + {t("saveAllPreview.field.label", { + ns: "views/settings", + })} + + + {item.fieldPath} + + + {t("saveAllPreview.value.label", { + ns: "views/settings", + })} + + + {formatValue(item.value)} + +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 5d2477f7b..4a70744f0 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -87,6 +87,9 @@ import { RJSFSchema } from "@rjsf/utils"; import { prepareSectionSavePayload } from "@/utils/configUtil"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; +import SaveAllPreviewPopover, { + type SaveAllPreviewItem, +} from "@/components/overlay/detail/SaveAllPreviewPopover"; import { useRestart } from "@/api/ws"; const allSettingsViews = [ @@ -152,6 +155,42 @@ const allSettingsViews = [ ] as const; type SettingsType = (typeof allSettingsViews)[number]; +const parsePendingDataKey = (pendingDataKey: string) => { + if (pendingDataKey.includes("::")) { + const idx = pendingDataKey.indexOf("::"); + return { + scope: "camera" as const, + cameraName: pendingDataKey.slice(0, idx), + sectionPath: pendingDataKey.slice(idx + 2), + }; + } + + return { + scope: "global" as const, + cameraName: undefined, + sectionPath: pendingDataKey, + }; +}; + +const flattenOverrides = ( + value: unknown, + path: string[] = [], +): Array<{ path: string; value: unknown }> => { + if (value === undefined) return []; + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return [{ path: path.join("."), value }]; + } + + const entries = Object.entries(value as Record); + if (entries.length === 0) { + return [{ path: path.join("."), value: {} }]; + } + + return entries.flatMap(([key, entryValue]) => + flattenOverrides(entryValue, [...path, key]), + ); +}; + const createSectionPage = ( sectionKey: string, level: "global" | "camera", @@ -620,6 +659,42 @@ export default function Settings() { const { data: fullSchema } = useSWR("config/schema.json"); const hasPendingChanges = Object.keys(pendingDataBySection).length > 0; + const pendingChangesPreview = useMemo(() => { + if (!config || !fullSchema) return []; + + const items: SaveAllPreviewItem[] = []; + Object.entries(pendingDataBySection).forEach( + ([pendingDataKey, pendingData]) => { + const payload = prepareSectionSavePayload({ + pendingDataKey, + pendingData, + config, + fullSchema, + }); + + if (!payload) return; + + const { scope, cameraName, sectionPath } = + parsePendingDataKey(pendingDataKey); + const flattened = flattenOverrides(payload.sanitizedOverrides); + + flattened.forEach(({ path, value }) => { + const fieldPath = path ? `${sectionPath}.${path}` : sectionPath; + items.push({ scope, cameraName, fieldPath, value }); + }); + }, + ); + + return items.sort((left, right) => { + const scopeCompare = left.scope.localeCompare(right.scope); + if (scopeCompare !== 0) return scopeCompare; + const cameraCompare = (left.cameraName ?? "").localeCompare( + right.cameraName ?? "", + ); + if (cameraCompare !== 0) return cameraCompare; + return left.fieldPath.localeCompare(right.fieldPath); + }); + }, [config, fullSchema, pendingDataBySection]); // Map a pendingDataKey to SettingsType menu key for clearing section status const pendingKeyToMenuKey = useCallback( @@ -982,8 +1057,17 @@ export default function Settings() { {!contentMobileOpen && (
-
+
+
+ +

@@ -1035,12 +1119,20 @@ export default function Settings() { {hasPendingChanges && (
- - {t("unsavedChanges", { - ns: "views/settings", - defaultValue: "You have unsaved changes", - })} - +
+ + {t("unsavedChanges", { + ns: "views/settings", + defaultValue: "You have unsaved changes", + })} + + +