diff --git a/web/src/components/config-form/sectionExtras/registry.ts b/web/src/components/config-form/sectionExtras/registry.ts index c5dd739a7..d73b5d74d 100644 --- a/web/src/components/config-form/sectionExtras/registry.ts +++ b/web/src/components/config-form/sectionExtras/registry.ts @@ -1,9 +1,15 @@ import type { ComponentType } from "react"; import SemanticSearchReindex from "./SemanticSearchReindex.tsx"; +import CameraReviewSettingsView from "@/views/settings/CameraReviewSettingsView.tsx"; -export type RendererComponent = ComponentType< - Record | undefined ->; +// Props that will be injected into all section renderers +export type SectionRendererProps = { + selectedCamera?: string; + setUnsavedChanges?: (hasChanges: boolean) => void; + [key: string]: unknown; // Allow additional props from uiSchema +}; + +export type RendererComponent = ComponentType; export type SectionRenderers = Record< string, @@ -16,10 +22,26 @@ export type SectionRenderers = Record< // names to React components. These names are referenced from `uiSchema` // descriptors (e.g., `{ "ui:after": { render: "SemanticSearchReindex" } }`) and // are resolved by `FieldTemplate` through `formContext.renderers`. +// +// RUNTIME PROPS INJECTION: +// All renderers automatically receive the following props from BaseSection: +// - selectedCamera?: string - The current camera name (camera-level only) +// - setUnsavedChanges?: (hasChanges: boolean) => void - Callback to signal unsaved state +// +// Additional static props can be passed via uiSchema: +// { "ui:after": { render: "MyRenderer", props: { customProp: "value" } } } +// +// ADDING NEW RENDERERS: +// 1. Create your component accepting SectionRendererProps +// 2. Import and add it to the appropriate section in this registry +// 3. Reference it in your section's uiSchema using the { render: "ComponentName" } syntax export const sectionRenderers: SectionRenderers = { semantic_search: { SemanticSearchReindex, }, + review: { + CameraReviewSettingsView, + }, }; export default sectionRenderers; diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index afa0daf1f..b62914914 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -53,6 +53,7 @@ import { import { applySchemaDefaults } from "@/lib/config-schema"; import { isJsonObject } from "@/lib/utils"; import { ConfigSectionData, JsonObject, JsonValue } from "@/types/configForm"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; export interface SectionConfig { /** Field ordering within the section */ @@ -513,7 +514,7 @@ export function ConfigSection({ const needsRestart = requiresRestartForOverrides(overrides); - await axios.put("config/sett", { + await axios.put("config/set", { requires_restart: needsRestart ? 1 : 0, update_topic: updateTopic, config_data: { @@ -607,7 +608,7 @@ export function ConfigSection({ const configData = level === "global" ? effectiveSchemaDefaults : ""; - await axios.put("config/sett", { + await axios.put("config/set", { requires_restart: requiresRestart ? 0 : 1, update_topic: updateTopic, config_data: { @@ -688,6 +689,42 @@ export function ConfigSection({ ); }, [sectionConfig.customValidate, sectionValidation]); + // Wrap renderers with runtime props (selectedCamera, setUnsavedChanges, etc.) + const wrappedRenderers = useMemo(() => { + const baseRenderers = + sectionConfig?.renderers ?? sectionRenderers?.[sectionPath]; + if (!baseRenderers) return undefined; + + // Create wrapper that injects runtime props + return Object.fromEntries( + Object.entries(baseRenderers).map(([key, RendererComponent]) => [ + key, + (staticProps: Record = {}) => ( + { + // Translate setUnsavedChanges to pending data state + if (hasChanges && !pendingData) { + // Component signaled changes but we don't have pending data yet + // This can happen when the component manages its own state + } else if (!hasChanges && pendingData) { + // Component signaled no changes, clear pending + setPendingData(null); + } + }} + /> + ), + ]), + ); + }, [ + sectionConfig?.renderers, + sectionPath, + cameraName, + pendingData, + setPendingData, + ]); + if (!modifiedSchema) { return null; } @@ -749,8 +786,7 @@ export function ConfigSection({ // section prefix to templates so they can attempt `${section}.${field}` lookups. sectionI18nPrefix: sectionPath, t, - renderers: - sectionConfig?.renderers ?? sectionRenderers?.[sectionPath], + renderers: wrappedRenderers, sectionDocs: sectionConfig.sectionDocs, fieldDocs: sectionConfig.fieldDocs, }} @@ -775,7 +811,7 @@ export function ConfigSection({ onClick={() => setIsResetDialogOpen(true)} variant="outline" disabled={isSaving || disabled} - className="gap-2" + className="flex flex-1 gap-2" > {level === "global" @@ -794,7 +830,7 @@ export function ConfigSection({ onClick={handleReset} variant="outline" disabled={isSaving || disabled} - className="gap-2" + className="flex min-w-36 flex-1 gap-2" > {t("undo", { ns: "common", defaultValue: "Undo" })} @@ -803,12 +839,19 @@ export function ConfigSection({ onClick={handleSave} variant="select" disabled={!hasChanges || isSaving || disabled} - className="gap-2" + className="flex min-w-36 flex-1 gap-2" > - - {isSaving - ? t("saving", { ns: "common", defaultValue: "Saving..." }) - : t("save", { ns: "common", defaultValue: "Save" })} + {isSaving ? ( + <> + + {t("saving", { ns: "common", defaultValue: "Saving..." })} + + ) : ( + <> + + {t("save", { ns: "common", defaultValue: "Save" })} + + )} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index bcf745f07..31dbdde47 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -33,7 +33,6 @@ import useSWR from "swr"; import FilterSwitch from "@/components/filter/FilterSwitch"; import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter"; import { PolygonType } from "@/types/canvas"; -import CameraReviewSettingsView from "@/views/settings/CameraReviewSettingsView"; import CameraManagementView from "@/views/settings/CameraManagementView"; import MotionTunerView from "@/views/settings/MotionTunerView"; import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; @@ -80,6 +79,7 @@ import { MobilePageHeader, MobilePageTitle, } from "@/components/mobile/MobilePage"; +import { Toaster } from "@/components/ui/sonner"; const allSettingsViews = [ "profileSettings", @@ -119,7 +119,7 @@ const allSettingsViews = [ "cameraSnapshots", "cameraMotion", "cameraObjects", - "cameraConfigReview", + "cameraReview", "cameraAudioEvents", "cameraAudioTranscription", "cameraNotifications", @@ -132,7 +132,6 @@ const allSettingsViews = [ "cameraUi", "cameraTimestampStyle", "cameraManagement", - "cameraReview", "masksAndZones", "motionTuner", "enrichments", @@ -224,7 +223,7 @@ const CameraRecordingSettingsPage = createSectionPage("record", "camera"); const CameraSnapshotsSettingsPage = createSectionPage("snapshots", "camera"); const CameraMotionSettingsPage = createSectionPage("motion", "camera"); const CameraObjectsSettingsPage = createSectionPage("objects", "camera"); -const CameraConfigReviewSettingsPage = createSectionPage("review", "camera"); +const CameraReviewSettingsPage = createSectionPage("review", "camera"); const CameraAudioEventsSettingsPage = createSectionPage("audio", "camera"); const CameraAudioTranscriptionSettingsPage = createSectionPage( "audio_transcription", @@ -290,7 +289,7 @@ const settingsGroups = [ { key: "cameraSnapshots", component: CameraSnapshotsSettingsPage }, { key: "cameraMotion", component: CameraMotionSettingsPage }, { key: "cameraObjects", component: CameraObjectsSettingsPage }, - { key: "cameraConfigReview", component: CameraConfigReviewSettingsPage }, + { key: "cameraReview", component: CameraReviewSettingsPage }, { key: "cameraAudioEvents", component: CameraAudioEventsSettingsPage }, { key: "cameraAudioTranscription", @@ -318,7 +317,6 @@ const settingsGroups = [ component: CameraTimestampStyleSettingsPage, }, { key: "cameraManagement", component: CameraManagementView }, - { key: "cameraReview", component: CameraReviewSettingsView }, { key: "masksAndZones", component: MasksAndZonesView }, { key: "motionTuner", component: MotionTunerView }, ], @@ -412,7 +410,7 @@ const CAMERA_SELECT_BUTTON_PAGES = [ "cameraSnapshots", "cameraMotion", "cameraObjects", - "cameraConfigReview", + "cameraReview", "cameraAudioEvents", "cameraAudioTranscription", "cameraNotifications", @@ -424,7 +422,6 @@ const CAMERA_SELECT_BUTTON_PAGES = [ "cameraOnvif", "cameraUi", "cameraTimestampStyle", - "cameraReview", "masksAndZones", "motionTuner", "triggers", @@ -440,7 +437,7 @@ const CAMERA_SECTION_MAPPING: Record = { snapshots: "cameraSnapshots", motion: "cameraMotion", objects: "cameraObjects", - review: "cameraConfigReview", + review: "cameraReview", audio: "cameraAudioEvents", audio_transcription: "cameraAudioTranscription", notifications: "cameraNotifications", @@ -788,6 +785,7 @@ export default function Settings() { if (isMobile) { return ( <> + {!contentMobileOpen && (
@@ -925,7 +923,8 @@ export default function Settings() { return (
-
+ +
{t("menu.settings", { ns: "common" })} diff --git a/web/src/views/settings/CameraReviewSettingsView.tsx b/web/src/views/settings/CameraReviewSettingsView.tsx index 974ed1ed6..9b4b818c5 100644 --- a/web/src/views/settings/CameraReviewSettingsView.tsx +++ b/web/src/views/settings/CameraReviewSettingsView.tsx @@ -43,7 +43,7 @@ import { formatList } from "@/utils/stringUtil"; type CameraReviewSettingsViewProps = { selectedCamera: string; - setUnsavedChanges: React.Dispatch>; + setUnsavedChanges?: (hasChanges: boolean) => void; }; type CameraReviewSettingsValueType = { @@ -249,7 +249,7 @@ export default function CameraReviewSettingsView({ } setChangedValue(false); - setUnsavedChanges(false); + setUnsavedChanges?.(false); removeMessage( "camera_settings", `review_classification_settings_${selectedCamera}`, @@ -746,6 +746,7 @@ export default function CameraReviewSettingsView({
+ ); }