import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import { Button } from "@/components/ui/button"; import { useCallback, useEffect, useMemo, useState, type ReactNode, } from "react"; import useOptimisticState from "@/hooks/use-optimistic-state"; import { isMobile } from "react-device-detect"; import { FaVideo } from "react-icons/fa"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import type { ConfigSectionData } from "@/types/configForm"; import useSWR from "swr"; import FilterSwitch from "@/components/filter/FilterSwitch"; import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter"; import { PolygonType } from "@/types/canvas"; import CameraManagementView from "@/views/settings/CameraManagementView"; import MotionTunerView from "@/views/settings/MotionTunerView"; import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; import UsersView from "@/views/settings/UsersView"; import RolesView from "@/views/settings/RolesView"; import UiSettingsView from "@/views/settings/UiSettingsView"; import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView"; import MaintenanceSettingsView from "@/views/settings/MaintenanceSettingsView"; import { SingleSectionPage, type SettingsPageProps, type SectionStatus, } from "@/views/settings/SingleSectionPage"; import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useInitialCameraState } from "@/api/ws"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { useTranslation } from "react-i18next"; import { useAllCameraOverrides } from "@/hooks/use-config-override"; import TriggerView from "@/views/settings/TriggerView"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupLabel, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, } from "@/components/ui/sidebar"; import { cn } from "@/lib/utils"; import Heading from "@/components/ui/heading"; import { LuChevronRight } from "react-icons/lu"; import Logo from "@/components/Logo"; import { MobilePage, MobilePageContent, MobilePageHeader, MobilePageTitle, } from "@/components/mobile/MobilePage"; import { Toaster } from "@/components/ui/sonner"; import axios from "axios"; import { toast } from "sonner"; import { mutate } from "swr"; import { RJSFSchema } from "@rjsf/utils"; import { buildConfigDataForPath, 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 = [ "profileSettings", "globalDetect", "globalRecording", "globalSnapshots", "globalFfmpeg", "globalMotion", "globalObjects", "globalReview", "globalAudioEvents", "globalLivePlayback", "globalTimestampStyle", "systemDatabase", "systemTls", "systemAuthentication", "systemNetworking", "systemProxy", "systemUi", "systemLogging", "systemEnvironmentVariables", "systemTelemetry", "systemBirdseye", "systemDetectorHardware", "systemDetectionModel", "systemMqtt", "integrationSemanticSearch", "integrationGenerativeAi", "integrationFaceRecognition", "integrationLpr", "integrationObjectClassification", "integrationAudioTranscription", "cameraDetect", "cameraFfmpeg", "cameraRecording", "cameraSnapshots", "cameraMotion", "cameraObjects", "cameraReview", "cameraAudioEvents", "cameraAudioTranscription", "cameraNotifications", "cameraLivePlayback", "cameraBirdseye", "cameraFaceRecognition", "cameraLpr", "cameraMqttConfig", "cameraOnvif", "cameraUi", "cameraTimestampStyle", "cameraManagement", "masksAndZones", "motionTuner", "enrichments", "triggers", "debug", "users", "roles", "notifications", "frigateplus", "maintenance", ] 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", options?: { showOverrideIndicator?: boolean }, ) => { return (props: SettingsPageProps) => ( ); }; const GlobalDetectSettingsPage = createSectionPage("detect", "global"); const GlobalRecordingSettingsPage = createSectionPage("record", "global"); const GlobalSnapshotsSettingsPage = createSectionPage("snapshots", "global"); const GlobalFfmpegSettingsPage = createSectionPage("ffmpeg", "global"); const GlobalMotionSettingsPage = createSectionPage("motion", "global"); const GlobalObjectsSettingsPage = createSectionPage("objects", "global"); const GlobalReviewSettingsPage = createSectionPage("review", "global"); const GlobalAudioEventsSettingsPage = createSectionPage("audio", "global"); const GlobalLivePlaybackSettingsPage = createSectionPage("live", "global"); const GlobalTimestampStyleSettingsPage = createSectionPage( "timestamp_style", "global", ); const SystemDatabaseSettingsPage = createSectionPage("database", "global"); const SystemTlsSettingsPage = createSectionPage("tls", "global"); const SystemAuthenticationSettingsPage = createSectionPage("auth", "global"); const SystemNetworkingSettingsPage = createSectionPage("networking", "global"); const SystemProxySettingsPage = createSectionPage("proxy", "global"); const SystemUiSettingsPage = createSectionPage("ui", "global"); const SystemLoggingSettingsPage = createSectionPage("logger", "global"); const SystemEnvironmentVariablesSettingsPage = createSectionPage( "environment_vars", "global", ); const SystemTelemetrySettingsPage = createSectionPage("telemetry", "global"); const SystemBirdseyeSettingsPage = createSectionPage("birdseye", "global"); const SystemDetectorHardwareSettingsPage = createSectionPage( "detectors", "global", ); const SystemDetectionModelSettingsPage = createSectionPage("model", "global"); const NotificationsSettingsPage = createSectionPage("notifications", "global"); const SystemMqttSettingsPage = createSectionPage("mqtt", "global"); const IntegrationSemanticSearchSettingsPage = createSectionPage( "semantic_search", "global", ); const IntegrationGenerativeAiSettingsPage = createSectionPage( "genai", "global", ); const IntegrationFaceRecognitionSettingsPage = createSectionPage( "face_recognition", "global", ); const IntegrationLprSettingsPage = createSectionPage("lpr", "global"); const IntegrationObjectClassificationSettingsPage = createSectionPage( "classification", "global", ); const IntegrationAudioTranscriptionSettingsPage = createSectionPage( "audio_transcription", "global", ); const CameraDetectSettingsPage = createSectionPage("detect", "camera"); const CameraFfmpegSettingsPage = createSectionPage("ffmpeg", "camera"); const CameraRecordingSettingsPage = createSectionPage("record", "camera"); const CameraSnapshotsSettingsPage = createSectionPage("snapshots", "camera"); const CameraMotionSettingsPage = createSectionPage("motion", "camera"); const CameraObjectsSettingsPage = createSectionPage("objects", "camera"); const CameraReviewSettingsPage = createSectionPage("review", "camera"); const CameraAudioEventsSettingsPage = createSectionPage("audio", "camera"); const CameraAudioTranscriptionSettingsPage = createSectionPage( "audio_transcription", "camera", ); const CameraNotificationsSettingsPage = createSectionPage( "notifications", "camera", ); const CameraLivePlaybackSettingsPage = createSectionPage("live", "camera"); const CameraBirdseyeSettingsPage = createSectionPage("birdseye", "camera"); const CameraFaceRecognitionSettingsPage = createSectionPage( "face_recognition", "camera", ); const CameraLprSettingsPage = createSectionPage("lpr", "camera"); const CameraMqttConfigSettingsPage = createSectionPage("mqtt", "camera", { showOverrideIndicator: false, }); const CameraOnvifSettingsPage = createSectionPage("onvif", "camera", { showOverrideIndicator: false, }); const CameraUiSettingsPage = createSectionPage("ui", "camera", { showOverrideIndicator: false, }); const CameraTimestampStyleSettingsPage = createSectionPage( "timestamp_style", "camera", ); const settingsGroups = [ { label: "general", items: [{ key: "profileSettings", component: UiSettingsView }], }, { label: "globalConfig", items: [ { key: "globalDetect", component: GlobalDetectSettingsPage }, { key: "globalObjects", component: GlobalObjectsSettingsPage }, { key: "globalMotion", component: GlobalMotionSettingsPage }, { key: "globalFfmpeg", component: GlobalFfmpegSettingsPage }, { key: "globalRecording", component: GlobalRecordingSettingsPage }, { key: "globalSnapshots", component: GlobalSnapshotsSettingsPage }, { key: "globalReview", component: GlobalReviewSettingsPage }, { key: "globalAudioEvents", component: GlobalAudioEventsSettingsPage }, { key: "globalLivePlayback", component: GlobalLivePlaybackSettingsPage, }, { key: "globalTimestampStyle", component: GlobalTimestampStyleSettingsPage, }, ], }, { label: "cameras", items: [ { key: "cameraManagement", component: CameraManagementView }, { key: "cameraDetect", component: CameraDetectSettingsPage }, { key: "cameraObjects", component: CameraObjectsSettingsPage }, { key: "cameraMotion", component: CameraMotionSettingsPage }, { key: "motionTuner", component: MotionTunerView }, { key: "cameraFfmpeg", component: CameraFfmpegSettingsPage }, { key: "cameraRecording", component: CameraRecordingSettingsPage }, { key: "cameraSnapshots", component: CameraSnapshotsSettingsPage }, { key: "masksAndZones", component: MasksAndZonesView }, { key: "cameraReview", component: CameraReviewSettingsPage }, { key: "cameraAudioEvents", component: CameraAudioEventsSettingsPage }, { key: "cameraAudioTranscription", component: CameraAudioTranscriptionSettingsPage, }, { key: "cameraBirdseye", component: CameraBirdseyeSettingsPage }, { key: "cameraLivePlayback", component: CameraLivePlaybackSettingsPage, }, { key: "cameraNotifications", component: CameraNotificationsSettingsPage, }, { key: "cameraFaceRecognition", component: CameraFaceRecognitionSettingsPage, }, { key: "cameraLpr", component: CameraLprSettingsPage }, { key: "cameraOnvif", component: CameraOnvifSettingsPage }, { key: "cameraMqttConfig", component: CameraMqttConfigSettingsPage }, { key: "cameraUi", component: CameraUiSettingsPage }, { key: "cameraTimestampStyle", component: CameraTimestampStyleSettingsPage, }, ], }, { label: "enrichments", items: [ { key: "integrationSemanticSearch", component: IntegrationSemanticSearchSettingsPage, }, { key: "integrationGenerativeAi", component: IntegrationGenerativeAiSettingsPage, }, { key: "integrationFaceRecognition", component: IntegrationFaceRecognitionSettingsPage, }, { key: "integrationLpr", component: IntegrationLprSettingsPage }, { key: "integrationObjectClassification", component: IntegrationObjectClassificationSettingsPage, }, { key: "triggers", component: TriggerView }, { key: "integrationAudioTranscription", component: IntegrationAudioTranscriptionSettingsPage, }, ], }, { label: "system", items: [ { key: "systemDetectorHardware", component: SystemDetectorHardwareSettingsPage, }, { key: "systemDetectionModel", component: SystemDetectionModelSettingsPage, }, { key: "systemDatabase", component: SystemDatabaseSettingsPage }, { key: "systemMqtt", component: SystemMqttSettingsPage }, { key: "systemBirdseye", component: SystemBirdseyeSettingsPage }, { key: "systemTls", component: SystemTlsSettingsPage }, { key: "systemAuthentication", component: SystemAuthenticationSettingsPage, }, { key: "systemNetworking", component: SystemNetworkingSettingsPage }, { key: "systemProxy", component: SystemProxySettingsPage }, { key: "systemUi", component: SystemUiSettingsPage }, { key: "systemLogging", component: SystemLoggingSettingsPage }, { key: "systemEnvironmentVariables", component: SystemEnvironmentVariablesSettingsPage, }, { key: "systemTelemetry", component: SystemTelemetrySettingsPage }, ], }, { label: "users", items: [ { key: "users", component: UsersView }, { key: "roles", component: RolesView }, ], }, { label: "notifications", items: [{ key: "notifications", component: NotificationsSettingsPage }], }, { label: "frigateplus", items: [{ key: "frigateplus", component: FrigatePlusSettingsView }], }, { label: "maintenance", items: [{ key: "maintenance", component: MaintenanceSettingsView }], }, ]; const CAMERA_SELECT_BUTTON_PAGES = [ "debug", "cameraDetect", "cameraFfmpeg", "cameraRecording", "cameraSnapshots", "cameraMotion", "cameraObjects", "cameraReview", "cameraAudioEvents", "cameraAudioTranscription", "cameraNotifications", "cameraLivePlayback", "cameraBirdseye", "cameraFaceRecognition", "cameraLpr", "cameraMqttConfig", "cameraOnvif", "cameraUi", "cameraTimestampStyle", "masksAndZones", "motionTuner", "triggers", ]; const ALLOWED_VIEWS_FOR_VIEWER = ["ui", "debug", "notifications"]; const LARGE_BOTTOM_MARGIN_PAGES = [ "masksAndZones", "motionTuner", "notifications", "frigateplus", "maintenance", ]; // keys for camera sections const CAMERA_SECTION_MAPPING: Record = { detect: "cameraDetect", ffmpeg: "cameraFfmpeg", record: "cameraRecording", snapshots: "cameraSnapshots", motion: "cameraMotion", objects: "cameraObjects", review: "cameraReview", audio: "cameraAudioEvents", audio_transcription: "cameraAudioTranscription", notifications: "cameraNotifications", live: "cameraLivePlayback", birdseye: "cameraBirdseye", face_recognition: "cameraFaceRecognition", lpr: "cameraLpr", mqtt: "cameraMqttConfig", onvif: "cameraOnvif", ui: "cameraUi", timestamp_style: "cameraTimestampStyle", }; // keys for global sections const GLOBAL_SECTION_MAPPING: Record = { detect: "globalDetect", ffmpeg: "globalFfmpeg", record: "globalRecording", snapshots: "globalSnapshots", motion: "globalMotion", objects: "globalObjects", review: "globalReview", audio: "globalAudioEvents", live: "globalLivePlayback", timestamp_style: "globalTimestampStyle", notifications: "notifications", }; const ENRICHMENTS_SECTION_MAPPING: Record = { semantic_search: "integrationSemanticSearch", genai: "integrationGenerativeAi", face_recognition: "integrationFaceRecognition", lpr: "integrationLpr", classification: "integrationObjectClassification", audio_transcription: "integrationAudioTranscription", }; const SYSTEM_SECTION_MAPPING: Record = { database: "systemDatabase", mqtt: "systemMqtt", tls: "systemTls", auth: "systemAuthentication", networking: "systemNetworking", proxy: "systemProxy", ui: "systemUi", logger: "systemLogging", environment_vars: "systemEnvironmentVariables", telemetry: "systemTelemetry", birdseye: "systemBirdseye", detectors: "systemDetectorHardware", model: "systemDetectionModel", }; const CAMERA_SECTION_KEYS = new Set( Object.values(CAMERA_SECTION_MAPPING), ); const getCurrentComponent = (page: SettingsType) => { for (const group of settingsGroups) { for (const item of group.items) { if (item.key === page) { return item.component; } } } return null; }; function MobileMenuItem({ item, onSelect, onClose, className, label, }: { item: { key: string }; onSelect: (key: string) => void; onClose?: () => void; className?: string; label?: ReactNode; }) { const { t } = useTranslation(["views/settings"]); return (
{ onSelect(item.key); onClose?.(); }} >
{label ?? (
{t("menu." + item.key)}
)}
); } export default function Settings() { const { t } = useTranslation(["views/settings"]); const [page, setPage] = useState("profileSettings"); const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); const [contentMobileOpen, setContentMobileOpen] = useState(false); const [sectionStatusByKey, setSectionStatusByKey] = useState< Partial> >({}); const { data: config } = useSWR("config"); const [searchParams] = useSearchParams(); // auth and roles const isAdmin = useIsAdmin(); const visibleSettingsViews = !isAdmin ? ALLOWED_VIEWS_FOR_VIEWER : allSettingsViews; const [unsavedChanges, setUnsavedChanges] = useState(false); const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); // Store pending form data keyed by "sectionKey" or "cameraName::sectionKey" const [pendingDataBySection, setPendingDataBySection] = useState< Record >({}); const navigate = useNavigate(); const cameras = useMemo(() => { if (!config) { return []; } return Object.values(config.cameras) .filter((conf) => conf.ui.dashboard && conf.enabled_in_config) .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); }, [config]); const [selectedCamera, setSelectedCamera] = useState(""); // Get all camera overrides for the selected camera const cameraOverrides = useAllCameraOverrides(config, selectedCamera); const { payload: allCameraStates } = useInitialCameraState( cameras.length > 0 ? cameras[0].name : "", true, ); const cameraEnabledStates = useMemo(() => { const states: Record = {}; if (allCameraStates) { Object.entries(allCameraStates).forEach(([camName, state]) => { states[camName] = state.config?.enabled ?? false; }); } // fallback to config if ws data isn’t available yet cameras.forEach((cam) => { if (!(cam.name in states)) { states[cam.name] = cam.enabled; } }); return states; }, [allCameraStates, cameras]); const [filterZoneMask, setFilterZoneMask] = useState(); // Save All state const [isSavingAll, setIsSavingAll] = useState(false); const [restartDialogOpen, setRestartDialogOpen] = useState(false); const { send: sendRestart } = useRestart(); 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( (pendingDataKey: string): SettingsType | undefined => { let sectionPath: string; let level: "global" | "camera"; if (pendingDataKey.includes("::")) { sectionPath = pendingDataKey.slice(pendingDataKey.indexOf("::") + 2); level = "camera"; } else { sectionPath = pendingDataKey; level = "global"; } if (level === "camera") { return CAMERA_SECTION_MAPPING[sectionPath] as SettingsType | undefined; } return ( (GLOBAL_SECTION_MAPPING[sectionPath] as SettingsType | undefined) ?? (ENRICHMENTS_SECTION_MAPPING[sectionPath] as | SettingsType | undefined) ?? (SYSTEM_SECTION_MAPPING[sectionPath] as SettingsType | undefined) ); }, [], ); const handleSaveAll = useCallback(async () => { if (!config || !fullSchema || !hasPendingChanges) return; setIsSavingAll(true); let successCount = 0; let failCount = 0; let anyNeedsRestart = false; const savedKeys: string[] = []; const pendingKeys = Object.keys(pendingDataBySection); for (const key of pendingKeys) { const pendingData = pendingDataBySection[key]; try { const payload = prepareSectionSavePayload({ pendingDataKey: key, pendingData, config, fullSchema, }); if (!payload) { // No actual overrides — clear the pending entry setPendingDataBySection((prev) => { const { [key]: _, ...rest } = prev; return rest; }); successCount++; continue; } const configData = buildConfigDataForPath( payload.basePath, payload.sanitizedOverrides, ); await axios.put("config/set", { requires_restart: payload.needsRestart ? 1 : 0, update_topic: payload.updateTopic, config_data: configData, }); // eslint-disable-next-line no-console console.log("Save All – saved:", { [payload.basePath]: payload.sanitizedOverrides, update_topic: payload.updateTopic, requires_restart: payload.needsRestart ? 1 : 0, }); if (payload.needsRestart) { anyNeedsRestart = true; } // Clear pending entry on success setPendingDataBySection((prev) => { const { [key]: _, ...rest } = prev; return rest; }); savedKeys.push(key); successCount++; } catch (error) { // eslint-disable-next-line no-console console.error("Save All – error saving", key, error); failCount++; } } // Refresh config from server once await mutate("config"); // Clear hasChanges in sidebar for all successfully saved sections if (savedKeys.length > 0) { setSectionStatusByKey((prev) => { const updated = { ...prev }; for (const key of savedKeys) { const menuKey = pendingKeyToMenuKey(key); if (menuKey && updated[menuKey]) { updated[menuKey] = { ...updated[menuKey], hasChanges: false, }; } } return updated; }); } // Aggregate toast const totalCount = successCount + failCount; if (failCount === 0) { if (anyNeedsRestart) { toast.success( t("toast.saveAllSuccess", { ns: "views/settings", count: successCount, }), { action: ( setRestartDialogOpen(true)}> ), }, ); } else { toast.success( t("toast.saveAllSuccess", { ns: "views/settings", count: successCount, }), ); } } else if (successCount > 0) { toast.warning( t("toast.saveAllPartial", { ns: "views/settings", count: totalCount, successCount, totalCount, failCount, }), ); } else { toast.error(t("toast.saveAllFailure", { ns: "views/settings" })); } setIsSavingAll(false); }, [ config, fullSchema, hasPendingChanges, pendingDataBySection, pendingKeyToMenuKey, t, ]); const handleUndoAll = useCallback(() => { const pendingKeys = Object.keys(pendingDataBySection); if (pendingKeys.length === 0) return; setPendingDataBySection({}); setUnsavedChanges(false); setSectionStatusByKey((prev) => { const updated = { ...prev }; for (const key of pendingKeys) { const menuKey = pendingKeyToMenuKey(key); if (menuKey && updated[menuKey]) { updated[menuKey] = { ...updated[menuKey], hasChanges: false, }; } } return updated; }); }, [pendingDataBySection, pendingKeyToMenuKey]); const handleDialog = useCallback( (save: boolean) => { if (unsavedChanges && save) { handleSaveAll(); } setConfirmationDialogOpen(false); setUnsavedChanges(false); }, [unsavedChanges, handleSaveAll], ); useEffect(() => { if (cameras.length > 0) { if (!selectedCamera) { // Set to first enabled camera initially if no selection const firstEnabledCamera = cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0]; setSelectedCamera(firstEnabledCamera.name); } else if ( !cameraEnabledStates[selectedCamera] && pageToggle !== "cameraReview" ) { // Switch to first enabled camera if current one is disabled, unless on "camera settings" page const firstEnabledCamera = cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0]; if (firstEnabledCamera.name !== selectedCamera) { setSelectedCamera(firstEnabledCamera.name); } } } }, [cameras, selectedCamera, cameraEnabledStates, pageToggle]); useSearchEffect("page", (page: string) => { if (allSettingsViews.includes(page as SettingsType)) { // Restrict viewer to UI settings if ( !isAdmin && !ALLOWED_VIEWS_FOR_VIEWER.includes(page as SettingsType) ) { setPageToggle("profileSettings"); } else { setPageToggle(page as SettingsType); } if (isMobile) { setContentMobileOpen(true); } } // don't clear url params if we're creating a new object mask return !(searchParams.has("object_mask") || searchParams.has("event_id")); }); useSearchEffect("camera", (camera: string) => { const cameraNames = cameras.map((c) => c.name); if (cameraNames.includes(camera)) { setSelectedCamera(camera); if (isMobile) { setContentMobileOpen(true); } } // don't clear url params if we're creating a new object mask or trigger return !(searchParams.has("object_mask") || searchParams.has("event_id")); }); useEffect(() => { if (!contentMobileOpen) { document.title = t("documentTitle.default"); } }, [t, contentMobileOpen]); const handleSectionStatusChange = useCallback( (sectionKey: string, level: "global" | "camera", status: SectionStatus) => { // Map section keys to menu keys based on level let menuKey: string; if (level === "camera") { menuKey = CAMERA_SECTION_MAPPING[sectionKey] || sectionKey; } else { menuKey = GLOBAL_SECTION_MAPPING[sectionKey] || ENRICHMENTS_SECTION_MAPPING[sectionKey] || SYSTEM_SECTION_MAPPING[sectionKey] || sectionKey; } setSectionStatusByKey((prev) => ({ ...prev, [menuKey]: status, })); }, [], ); const handlePendingDataChange = useCallback( ( sectionKey: string, cameraName: string | undefined, data: ConfigSectionData | null, ) => { const pendingDataKey = cameraName ? `${cameraName}::${sectionKey}` : sectionKey; setPendingDataBySection((prev) => { if (data === null) { const { [pendingDataKey]: _, ...rest } = prev; return rest; } return { ...prev, [pendingDataKey]: data, }; }); }, [], ); // Initialize override status for all camera sections useEffect(() => { if (!selectedCamera || !cameraOverrides) return; const overrideMap: Partial> = {}; // Set override status for all camera sections using the shared mapping Object.entries(CAMERA_SECTION_MAPPING).forEach( ([sectionKey, settingsKey]) => { const isOverridden = cameraOverrides.includes(sectionKey); // Check if there are pending changes for this camera and section const pendingDataKey = `${selectedCamera}::${sectionKey}`; const hasChanges = pendingDataKey in pendingDataBySection; overrideMap[settingsKey] = { hasChanges, isOverridden, }; }, ); setSectionStatusByKey((prev) => { // Merge and update both hasChanges and isOverridden for camera sections const merged = { ...prev }; Object.entries(overrideMap).forEach(([key, status]) => { merged[key as SettingsType] = status; }); return merged; }); }, [selectedCamera, cameraOverrides, pendingDataBySection]); const renderMenuItemLabel = useCallback( (key: SettingsType) => { const status = sectionStatusByKey[key]; const showOverrideDot = CAMERA_SECTION_KEYS.has(key) && status?.isOverridden; const showUnsavedDot = status?.hasChanges; return (
{t("menu." + key)}
{(showOverrideDot || showUnsavedDot) && (
{showOverrideDot && ( )} {showUnsavedDot && ( )}
)}
); }, [sectionStatusByKey, t], ); if (isMobile) { return ( <> {!contentMobileOpen && (

{t("menu.settings", { ns: "common" })}

{settingsGroups.map((group) => { const filteredItems = group.items.filter((item) => visibleSettingsViews.includes(item.key as SettingsType), ); if (filteredItems.length === 0) return null; return (
{filteredItems.length > 1 && (

{t("menu." + group.label)}

)} {filteredItems.map((item) => ( { if ( !isAdmin && !ALLOWED_VIEWS_FOR_VIEWER.includes( key as SettingsType, ) ) { setPageToggle("profileSettings"); } else { setPageToggle(key as SettingsType); } setContentMobileOpen(true); }} /> ))}
); })}
{hasPendingChanges && (
{t("unsavedChanges", { ns: "views/settings", defaultValue: "You have unsaved changes", })}
)}
)} navigate(-1)} actions={
{CAMERA_SELECT_BUTTON_PAGES.includes(pageToggle) && ( <> {pageToggle == "masksAndZones" && ( )} )}
} > {t("menu." + page)}
{(() => { const CurrentComponent = getCurrentComponent(page); if (!CurrentComponent) return null; return ( ); })()}
{confirmationDialogOpen && ( setConfirmationDialogOpen(false)} > {t("dialog.unsavedChanges.title")} {t("dialog.unsavedChanges.desc")} handleDialog(false)}> {t("button.cancel", { ns: "common" })} handleDialog(true)}> {t("button.save", { ns: "common" })} )} setRestartDialogOpen(false)} onRestart={() => sendRestart("restart")} /> ); } return (
{t("menu.settings", { ns: "common" })}
{hasPendingChanges && (
)} {CAMERA_SELECT_BUTTON_PAGES.includes(page) && ( <> {pageToggle == "masksAndZones" && ( )} )}
{settingsGroups.map((group) => { const filteredItems = group.items.filter((item) => visibleSettingsViews.includes(item.key as SettingsType), ); if (filteredItems.length === 0) return null; return ( {filteredItems.length === 1 ? ( { if ( !isAdmin && !ALLOWED_VIEWS_FOR_VIEWER.includes( filteredItems[0].key as SettingsType, ) ) { setPageToggle("profileSettings"); } else { setPageToggle( filteredItems[0].key as SettingsType, ); } }} > {renderMenuItemLabel( filteredItems[0].key as SettingsType, )} ) : ( <> pageToggle === item.key, ) ? "text-primary" : "text-sidebar-foreground/80", )} >
{t("menu." + group.label)}
{filteredItems.map((item) => ( { if ( !isAdmin && !ALLOWED_VIEWS_FOR_VIEWER.includes( item.key as SettingsType, ) ) { setPageToggle("profileSettings"); } else { setPageToggle(item.key as SettingsType); } }} >
{renderMenuItemLabel( item.key as SettingsType, )}
))}
)}
); })}
{(() => { const CurrentComponent = getCurrentComponent(page); if (!CurrentComponent) return null; return ( ); })()}
{confirmationDialogOpen && ( setConfirmationDialogOpen(false)} > {t("dialog.unsavedChanges.title")} {t("dialog.unsavedChanges.desc")} handleDialog(false)}> {t("button.cancel", { ns: "common" })} handleDialog(true)}> {t("button.save", { ns: "common" })} )}
setRestartDialogOpen(false)} onRestart={() => sendRestart("restart")} />
); } type CameraSelectButtonProps = { allCameras: CameraConfig[]; selectedCamera: string; setSelectedCamera: React.Dispatch>; cameraEnabledStates: Record; currentPage: SettingsType; }; function CameraSelectButton({ allCameras, selectedCamera, setSelectedCamera, cameraEnabledStates, currentPage, }: CameraSelectButtonProps) { const { t } = useTranslation(["views/settings"]); const [open, setOpen] = useState(false); if (!allCameras.length) { return null; } const trigger = ( ); const content = ( <> {isMobile && ( <> {t("cameraSetting.camera")} )}
{allCameras.map((item) => { const isEnabled = cameraEnabledStates[item.name]; const isCameraSettingsPage = currentPage === "cameraReview"; return ( { if (isChecked && (isEnabled || isCameraSettingsPage)) { setSelectedCamera(item.name); setOpen(false); } }} disabled={!isEnabled && !isCameraSettingsPage} /> ); })}
); if (isMobile) { return ( { if (!open) { setSelectedCamera(selectedCamera); } setOpen(open); }} > {trigger} {content} ); } return ( { if (!open) { setSelectedCamera(selectedCamera); } setOpen(open); }} > {trigger} {content} ); }