add global sections, camera config overrides, and reset button

This commit is contained in:
Josh Hawkins
2026-02-27 09:37:57 -06:00
parent 260237bf1a
commit 7692c89234
15 changed files with 682 additions and 60 deletions
@@ -16,11 +16,11 @@ export interface ConfigFormProps {
/** JSON Schema for the form */
schema: RJSFSchema;
/** Current form data */
formData?: Record<string, unknown>;
formData?: unknown;
/** Called when form data changes */
onChange?: (data: Record<string, unknown>) => void;
onChange?: (data: unknown) => void;
/** Called when form is submitted */
onSubmit?: (data: Record<string, unknown>) => void;
onSubmit?: (data: unknown) => void;
/** Called when form has errors on submit */
onError?: (errors: unknown[]) => void;
/** Additional uiSchema overrides */
@@ -0,0 +1,17 @@
// Audio Transcription Section Component
// Global and camera-level audio transcription settings
import { createConfigSection } from "./BaseSection";
export const AudioTranscriptionSection = createConfigSection({
sectionPath: "audio_transcription",
i18nNamespace: "config/audio_transcription",
defaultConfig: {
fieldOrder: ["enabled", "language", "device", "model_size", "live_enabled"],
hiddenFields: ["enabled_in_config"],
advancedFields: ["language", "device", "model_size"],
overrideFields: ["enabled", "live_enabled"],
},
});
export default AudioTranscriptionSection;
@@ -1,7 +1,7 @@
// Base Section Component for config form sections
// Used as a foundation for reusable section components
import { useMemo, useCallback, useState } from "react";
import { useMemo, useCallback, useState, useEffect, useRef } from "react";
import useSWR from "swr";
import axios from "axios";
import { toast } from "sonner";
@@ -43,6 +43,8 @@ export interface SectionConfig {
hiddenFields?: string[];
/** Fields to show in advanced section */
advancedFields?: string[];
/** Fields to compare for override detection */
overrideFields?: string[];
/** Additional uiSchema overrides */
uiSchema?: UiSchema;
}
@@ -98,6 +100,17 @@ export function createConfigSection({
review: "review",
audio: "audio",
notifications: "notifications",
live: "live",
timestamp_style: "timestamp_style",
audio_transcription: "audio_transcription",
birdseye: "birdseye",
face_recognition: "face_recognition",
ffmpeg: "ffmpeg",
lpr: "lpr",
semantic_search: "semantic_search",
mqtt: "mqtt",
onvif: "onvif",
ui: "ui",
};
const ConfigSection = function ConfigSection({
@@ -120,6 +133,8 @@ export function createConfigSection({
unknown
> | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [formKey, setFormKey] = useState(0);
const isResettingRef = useRef(false);
const updateTopic =
level === "camera" && cameraName
@@ -143,6 +158,7 @@ export function createConfigSection({
config,
cameraName: level === "camera" ? cameraName : undefined,
sectionPath,
compareFields: sectionConfig.overrideFields,
});
// Get current form data
@@ -193,6 +209,18 @@ export function createConfigSection({
return applySchemaDefaults(sectionSchema, {});
}, [sectionSchema]);
// Clear pendingData whenever formData changes (e.g., from server refresh)
// This prevents RJSF's initial onChange call from being treated as a user edit
useEffect(() => {
setPendingData(null);
}, [formData]);
useEffect(() => {
if (isResettingRef.current) {
isResettingRef.current = false;
}
}, [formKey]);
const buildOverrides = useCallback(
(
current: unknown,
@@ -266,8 +294,18 @@ export function createConfigSection({
// Handle form data change
const handleChange = useCallback(
(data: Record<string, unknown>) => {
const sanitizedData = sanitizeSectionData(data);
(data: unknown) => {
if (isResettingRef.current) {
setPendingData(null);
return;
}
if (!data || typeof data !== "object") {
setPendingData(null);
return;
}
const sanitizedData = sanitizeSectionData(
data as Record<string, unknown>,
);
if (isEqual(formData, sanitizedData)) {
setPendingData(null);
return;
@@ -277,6 +315,12 @@ export function createConfigSection({
[formData, sanitizeSectionData],
);
const handleReset = useCallback(() => {
isResettingRef.current = true;
setPendingData(null);
setFormKey((prev) => prev + 1);
}, []);
// Handle save button click
const handleSave = useCallback(async () => {
if (!pendingData) return;
@@ -417,6 +461,7 @@ export function createConfigSection({
const sectionContent = (
<div className="space-y-6">
<ConfigForm
key={formKey}
schema={sectionSchema}
formData={pendingData || formData}
onChange={handleChange}
@@ -454,16 +499,28 @@ export function createConfigSection({
</span>
)}
</div>
<Button
onClick={handleSave}
disabled={!hasChanges || isSaving || disabled}
className="gap-2"
>
<LuSave className="h-4 w-4" />
{isSaving
? t("saving", { ns: "common", defaultValue: "Saving..." })
: t("save", { ns: "common", defaultValue: "Save" })}
</Button>
<div className="flex items-center gap-2">
{hasChanges && (
<Button
onClick={handleReset}
variant="outline"
disabled={isSaving || disabled}
className="gap-2"
>
{t("reset", { ns: "common", defaultValue: "Reset" })}
</Button>
)}
<Button
onClick={handleSave}
disabled={!hasChanges || isSaving || disabled}
className="gap-2"
>
<LuSave className="h-4 w-4" />
{isSaving
? t("saving", { ns: "common", defaultValue: "Saving..." })
: t("save", { ns: "common", defaultValue: "Save" })}
</Button>
</div>
</div>
</div>
);
@@ -0,0 +1,17 @@
// Birdseye Section Component
// Camera-level birdseye settings
import { createConfigSection } from "./BaseSection";
export const BirdseyeSection = createConfigSection({
sectionPath: "birdseye",
i18nNamespace: "config/birdseye",
defaultConfig: {
fieldOrder: ["enabled", "mode", "order"],
hiddenFields: [],
advancedFields: [],
overrideFields: ["enabled", "mode"],
},
});
export default BirdseyeSection;
@@ -0,0 +1,25 @@
// Camera MQTT Section Component
// Camera-specific MQTT image publishing settings
import { createConfigSection } from "./BaseSection";
export const CameraMqttSection = createConfigSection({
sectionPath: "mqtt",
i18nNamespace: "config/camera_mqtt",
defaultConfig: {
fieldOrder: [
"enabled",
"timestamp",
"bounding_box",
"crop",
"height",
"required_zones",
"quality",
],
hiddenFields: [],
advancedFields: ["height", "quality"],
overrideFields: [],
},
});
export default CameraMqttSection;
@@ -0,0 +1,17 @@
// Camera UI Section Component
// Camera UI display settings
import { createConfigSection } from "./BaseSection";
export const CameraUiSection = createConfigSection({
sectionPath: "ui",
i18nNamespace: "config/camera_ui",
defaultConfig: {
fieldOrder: ["dashboard", "order"],
hiddenFields: [],
advancedFields: [],
overrideFields: [],
},
});
export default CameraUiSection;
@@ -0,0 +1,17 @@
// Face Recognition Section Component
// Camera-level face recognition settings
import { createConfigSection } from "./BaseSection";
export const FaceRecognitionSection = createConfigSection({
sectionPath: "face_recognition",
i18nNamespace: "config/face_recognition",
defaultConfig: {
fieldOrder: ["enabled", "min_area"],
hiddenFields: [],
advancedFields: ["min_area"],
overrideFields: ["enabled", "min_area"],
},
});
export default FaceRecognitionSection;
@@ -0,0 +1,44 @@
// FFmpeg Section Component
// Global and camera-level FFmpeg settings
import { createConfigSection } from "./BaseSection";
export const FfmpegSection = createConfigSection({
sectionPath: "ffmpeg",
i18nNamespace: "config/ffmpeg",
defaultConfig: {
fieldOrder: [
"inputs",
"path",
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
hiddenFields: [],
advancedFields: [
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
overrideFields: [
"path",
"global_args",
"hwaccel_args",
"input_args",
"output_args",
"retry_interval",
"apple_compatibility",
"gpu",
],
},
});
export default FfmpegSection;
@@ -0,0 +1,17 @@
// License Plate Recognition Section Component
// Camera-level LPR settings
import { createConfigSection } from "./BaseSection";
export const LprSection = createConfigSection({
sectionPath: "lpr",
i18nNamespace: "config/lpr",
defaultConfig: {
fieldOrder: ["enabled", "expire_time", "min_area", "enhancement"],
hiddenFields: [],
advancedFields: ["expire_time", "min_area", "enhancement"],
overrideFields: ["enabled", "min_area", "enhancement"],
},
});
export default LprSection;
@@ -0,0 +1,25 @@
// ONVIF Section Component
// Camera-level ONVIF and autotracking settings
import { createConfigSection } from "./BaseSection";
export const OnvifSection = createConfigSection({
sectionPath: "onvif",
i18nNamespace: "config/onvif",
defaultConfig: {
fieldOrder: [
"host",
"port",
"user",
"password",
"tls_insecure",
"autotracking",
"ignore_time_mismatch",
],
hiddenFields: ["autotracking.enabled_in_config"],
advancedFields: ["tls_insecure", "autotracking", "ignore_time_mismatch"],
overrideFields: [],
},
});
export default OnvifSection;
@@ -0,0 +1,17 @@
// Semantic Search Section Component
// Camera-level semantic search trigger settings
import { createConfigSection } from "./BaseSection";
export const SemanticSearchSection = createConfigSection({
sectionPath: "semantic_search",
i18nNamespace: "config/semantic_search",
defaultConfig: {
fieldOrder: ["triggers"],
hiddenFields: [],
advancedFields: [],
overrideFields: [],
},
});
export default SemanticSearchSection;
@@ -15,6 +15,15 @@ export { MotionSection } from "./MotionSection";
export { ObjectsSection } from "./ObjectsSection";
export { ReviewSection } from "./ReviewSection";
export { AudioSection } from "./AudioSection";
export { AudioTranscriptionSection } from "./AudioTranscriptionSection";
export { BirdseyeSection } from "./BirdseyeSection";
export { CameraMqttSection } from "./CameraMqttSection";
export { CameraUiSection } from "./CameraUiSection";
export { FaceRecognitionSection } from "./FaceRecognitionSection";
export { FfmpegSection } from "./FfmpegSection";
export { LprSection } from "./LprSection";
export { NotificationsSection } from "./NotificationsSection";
export { OnvifSection } from "./OnvifSection";
export { LiveSection } from "./LiveSection";
export { SemanticSearchSection } from "./SemanticSearchSection";
export { TimestampSection } from "./TimestampSection";