Files
frigate/web/src/pages/Settings.tsx
T

2013 lines
65 KiB
TypeScript
Raw Normal View History

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";
2026-02-27 09:55:36 -06:00
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import useOptimisticState from "@/hooks/use-optimistic-state";
2025-10-08 14:59:21 -05:00
import { isMobile } from "react-device-detect";
import { FaVideo } from "react-icons/fa";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
2026-04-26 16:09:35 -06:00
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
import useSWR from "swr";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
import { PolygonType } from "@/types/canvas";
2025-10-13 11:52:08 -05:00
import CameraManagementView from "@/views/settings/CameraManagementView";
2024-05-29 08:01:39 -06:00
import MotionTunerView from "@/views/settings/MotionTunerView";
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
import UsersView from "@/views/settings/UsersView";
import RolesView from "@/views/settings/RolesView";
2024-10-15 18:25:59 -06:00
import UiSettingsView from "@/views/settings/UiSettingsView";
2026-03-19 09:47:57 -05:00
import ProfilesView from "@/views/settings/ProfilesView";
2025-03-17 13:44:57 -05:00
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
import MediaSyncSettingsView from "@/views/settings/MediaSyncSettingsView";
import RegionGridSettingsView from "@/views/settings/RegionGridSettingsView";
2026-03-19 11:33:42 -05:00
import Go2RtcStreamsSettingsView from "@/views/settings/Go2RtcStreamsSettingsView";
2026-02-27 09:55:36 -06:00
import SystemDetectionModelSettingsView from "@/views/settings/SystemDetectionModelSettingsView";
import {
SingleSectionPage,
type SettingsPageProps,
type SectionStatus,
} from "@/views/settings/SingleSectionPage";
2025-02-10 10:42:35 -06:00
import { useSearchEffect } from "@/hooks/use-overlay-state";
2025-10-08 14:59:21 -05:00
import { useNavigate, useSearchParams } from "react-router-dom";
2025-03-03 09:30:52 -06:00
import { useInitialCameraState } from "@/api/ws";
2025-03-08 10:01:08 -06:00
import { useIsAdmin } from "@/hooks/use-is-admin";
import { useTranslation } from "react-i18next";
2026-02-27 09:55:36 -06:00
import { useAllCameraOverrides } from "@/hooks/use-config-override";
2025-07-07 09:03:57 -05:00
import TriggerView from "@/views/settings/TriggerView";
2025-11-07 22:02:06 +08:00
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
2025-10-08 14:59:21 -05:00
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";
2026-02-27 09:55:36 -06:00
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,
2026-04-26 16:09:35 -06:00
flattenOverrides,
2026-03-19 09:47:57 -05:00
parseProfileFromSectionPath,
2026-02-27 09:55:36 -06:00
prepareSectionSavePayload,
2026-03-19 09:47:57 -05:00
PROFILE_ELIGIBLE_SECTIONS,
2026-02-27 09:55:36 -06:00
} from "@/utils/configUtil";
2026-03-19 09:47:57 -05:00
import type { ProfileState, ProfilesApiResponse } from "@/types/profile";
import { getProfileColor } from "@/utils/profileColors";
import { ProfileSectionDropdown } from "@/components/settings/ProfileSectionDropdown";
2026-02-27 09:55:36 -06:00
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 = [
2026-03-19 09:47:57 -05:00
"uiSettings",
"profiles",
2026-02-27 09:55:36 -06:00
"globalDetect",
"globalRecording",
"globalSnapshots",
"globalFfmpeg",
"globalMotion",
"globalObjects",
"globalReview",
"globalAudioEvents",
"globalLivePlayback",
"globalTimestampStyle",
"systemDatabase",
"systemTls",
"systemAuthentication",
"systemNetworking",
"systemProxy",
"systemUi",
"systemLogging",
"systemEnvironmentVariables",
"systemTelemetry",
"systemBirdseye",
"systemDetectorHardware",
"systemDetectionModel",
"systemMqtt",
2026-03-19 11:33:42 -05:00
"systemGo2rtcStreams",
2026-02-27 09:55:36 -06:00
"integrationSemanticSearch",
"integrationGenerativeAi",
"integrationFaceRecognition",
"integrationLpr",
"integrationObjectClassification",
"integrationAudioTranscription",
"cameraDetect",
"cameraFfmpeg",
"cameraRecording",
"cameraSnapshots",
"cameraMotion",
"cameraObjects",
2025-10-13 11:52:08 -05:00
"cameraReview",
2026-02-27 09:55:36 -06:00
"cameraAudioEvents",
"cameraAudioTranscription",
"cameraNotifications",
"cameraLivePlayback",
"cameraBirdseye",
"cameraFaceRecognition",
"cameraLpr",
"cameraMqttConfig",
"cameraOnvif",
"cameraUi",
"cameraTimestampStyle",
"cameraManagement",
"masksAndZones",
"motionTuner",
2026-02-27 09:55:36 -06:00
"enrichments",
2025-07-07 09:03:57 -05:00
"triggers",
"debug",
"users",
"roles",
"notifications",
2025-03-17 13:44:57 -05:00
"frigateplus",
"mediaSync",
"regionGrid",
] as const;
type SettingsType = (typeof allSettingsViews)[number];
2026-02-27 09:55:36 -06:00
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 createSectionPage = (
sectionKey: string,
level: "global" | "camera",
options?: { showOverrideIndicator?: boolean },
) => {
return (props: SettingsPageProps) => (
<SingleSectionPage
sectionKey={sectionKey}
level={level}
showOverrideIndicator={options?.showOverrideIndicator}
{...props}
/>
);
};
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 = SystemDetectionModelSettingsView;
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",
);
2025-10-08 14:59:21 -05:00
const settingsGroups = [
{
2025-10-08 16:36:23 -05:00
label: "general",
2026-03-19 09:47:57 -05:00
items: [{ key: "uiSettings", component: UiSettingsView }],
2026-02-27 09:55:36 -06:00
},
{
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,
},
],
2025-10-08 14:59:21 -05:00
},
{
2025-10-08 16:36:23 -05:00
label: "cameras",
2025-10-08 14:59:21 -05:00
items: [
2026-03-19 09:47:57 -05:00
{ key: "profiles", component: ProfilesView },
2025-10-13 11:52:08 -05:00
{ key: "cameraManagement", component: CameraManagementView },
2026-02-27 09:55:36 -06:00
{ key: "cameraDetect", component: CameraDetectSettingsPage },
{ key: "cameraObjects", component: CameraObjectsSettingsPage },
{ key: "cameraMotion", component: CameraMotionSettingsPage },
2025-10-08 14:59:21 -05:00
{ key: "motionTuner", component: MotionTunerView },
2026-02-27 09:55:36 -06:00
{ 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,
},
2025-10-08 14:59:21 -05:00
],
},
{
2025-10-08 16:36:23 -05:00
label: "enrichments",
2026-02-27 09:55:36 -06:00
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: [
2026-03-19 11:33:42 -05:00
{
key: "systemGo2rtcStreams",
component: Go2RtcStreamsSettingsView,
},
2026-02-27 09:55:36 -06:00
{
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 },
],
2025-10-08 14:59:21 -05:00
},
{
2025-10-08 16:36:23 -05:00
label: "users",
2025-10-08 14:59:21 -05:00
items: [
{ key: "users", component: UsersView },
{ key: "roles", component: RolesView },
],
},
{
2025-10-08 16:36:23 -05:00
label: "notifications",
2026-02-27 09:55:36 -06:00
items: [{ key: "notifications", component: NotificationsSettingsPage }],
2025-10-08 14:59:21 -05:00
},
{
2025-10-08 16:36:23 -05:00
label: "frigateplus",
2025-10-08 14:59:21 -05:00
items: [{ key: "frigateplus", component: FrigatePlusSettingsView }],
},
2026-01-06 09:20:19 -06:00
{
label: "maintenance",
items: [
{ key: "mediaSync", component: MediaSyncSettingsView },
{ key: "regionGrid", component: RegionGridSettingsView },
],
2026-01-06 09:20:19 -06:00
},
2025-10-08 14:59:21 -05:00
];
2025-10-13 11:52:08 -05:00
const CAMERA_SELECT_BUTTON_PAGES = [
"debug",
2026-02-27 09:55:36 -06:00
"cameraDetect",
"cameraFfmpeg",
"cameraRecording",
"cameraSnapshots",
"cameraMotion",
"cameraObjects",
2025-10-13 11:52:08 -05:00
"cameraReview",
2026-02-27 09:55:36 -06:00
"cameraAudioEvents",
"cameraAudioTranscription",
"cameraNotifications",
"cameraLivePlayback",
"cameraBirdseye",
"cameraFaceRecognition",
"cameraLpr",
"cameraMqttConfig",
"cameraOnvif",
"cameraUi",
"cameraTimestampStyle",
2025-10-13 11:52:08 -05:00
"masksAndZones",
"motionTuner",
"triggers",
"regionGrid",
2025-10-13 11:52:08 -05:00
];
2026-03-19 09:47:57 -05:00
const ALLOWED_VIEWS_FOR_VIEWER = ["uiSettings", "notifications"];
2026-02-27 09:55:36 -06:00
// keys for camera sections
const CAMERA_SECTION_MAPPING: Record<string, SettingsType> = {
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",
};
2026-03-19 09:47:57 -05:00
// Reverse mapping: page key → config section key
const REVERSE_CAMERA_SECTION_MAPPING: Record<string, string> =
Object.fromEntries(
Object.entries(CAMERA_SECTION_MAPPING).map(([section, page]) => [
page,
section,
]),
);
// masksAndZones is a composite page, not in CAMERA_SECTION_MAPPING
REVERSE_CAMERA_SECTION_MAPPING["masksAndZones"] = "masksAndZones";
// Pages where the profile dropdown should appear
const PROFILE_DROPDOWN_PAGES = new Set(
Object.entries(REVERSE_CAMERA_SECTION_MAPPING)
.filter(
([, sectionKey]) =>
PROFILE_ELIGIBLE_SECTIONS.has(sectionKey) ||
sectionKey === "masksAndZones",
)
.map(([pageKey]) => pageKey),
);
2026-02-27 09:55:36 -06:00
// keys for global sections
const GLOBAL_SECTION_MAPPING: Record<string, SettingsType> = {
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<string, SettingsType> = {
semantic_search: "integrationSemanticSearch",
genai: "integrationGenerativeAi",
face_recognition: "integrationFaceRecognition",
lpr: "integrationLpr",
classification: "integrationObjectClassification",
audio_transcription: "integrationAudioTranscription",
};
const SYSTEM_SECTION_MAPPING: Record<string, SettingsType> = {
2026-03-19 11:33:42 -05:00
go2rtc_streams: "systemGo2rtcStreams",
2026-02-27 09:55:36 -06:00
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<SettingsType>(
Object.values(CAMERA_SECTION_MAPPING),
);
2025-10-08 14:59:21 -05:00
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,
2026-02-27 09:55:36 -06:00
label,
2025-10-08 14:59:21 -05:00
}: {
item: { key: string };
onSelect: (key: string) => void;
onClose?: () => void;
className?: string;
2026-02-27 09:55:36 -06:00
label?: ReactNode;
2025-10-08 14:59:21 -05:00
}) {
const { t } = useTranslation(["views/settings"]);
return (
2025-11-05 08:49:31 -06:00
<div
className={cn(
"inline-flex h-10 w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md px-4 py-2 pr-2 text-sm font-medium text-primary-variant disabled:pointer-events-none disabled:opacity-50",
className,
)}
2025-10-08 14:59:21 -05:00
onClick={() => {
onSelect(item.key);
onClose?.();
}}
>
2026-02-27 09:55:36 -06:00
<div className="w-full">
{label ?? <div>{t("menu." + item.key)}</div>}
</div>
2025-10-08 14:59:21 -05:00
<LuChevronRight className="size-4" />
2025-11-05 08:49:31 -06:00
</div>
2025-10-08 14:59:21 -05:00
);
}
export default function Settings() {
const { t } = useTranslation(["views/settings"]);
2026-03-19 09:47:57 -05:00
const [page, setPage] = useState<SettingsType>("uiSettings");
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
2025-10-08 14:59:21 -05:00
const [contentMobileOpen, setContentMobileOpen] = useState(false);
2026-02-27 09:55:36 -06:00
const [sectionStatusByKey, setSectionStatusByKey] = useState<
Partial<Record<SettingsType, SectionStatus>>
>({});
const { data: config } = useSWR<FrigateConfig>("config");
2026-03-19 09:47:57 -05:00
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
2025-02-15 07:56:45 -06:00
const [searchParams] = useSearchParams();
2025-03-08 10:01:08 -06:00
// auth and roles
const isAdmin = useIsAdmin();
const visibleSettingsViews = !isAdmin
2025-10-13 11:52:08 -05:00
? ALLOWED_VIEWS_FOR_VIEWER
2025-03-08 10:01:08 -06:00
: allSettingsViews;
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
2026-02-27 09:55:36 -06:00
// Store pending form data keyed by "sectionKey" or "cameraName::sectionKey"
const [pendingDataBySection, setPendingDataBySection] = useState<
2026-03-19 09:47:57 -05:00
Record<string, ConfigSectionData>
2026-02-27 09:55:36 -06:00
>({});
2026-03-19 09:47:57 -05:00
// Profile editing state
const [editingProfile, setEditingProfile] = useState<
Record<string, string | null>
>({});
const [profilesUIEnabled, setProfilesUIEnabled] = useState(false);
const allProfileNames = useMemo(() => {
if (!config?.profiles) return [];
return Object.keys(config.profiles).sort();
}, [config]);
const profileFriendlyNames = useMemo(() => {
const map = new Map<string, string>();
if (profilesData?.profiles) {
profilesData.profiles.forEach((p) => map.set(p.name, p.friendly_name));
}
return map;
}, [profilesData]);
2025-10-08 14:59:21 -05:00
const navigate = useNavigate();
const cameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
2025-03-03 09:30:52 -06:00
.filter((conf) => conf.ui.dashboard && conf.enabled_in_config)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const [selectedCamera, setSelectedCamera] = useState<string>("");
2026-02-27 09:55:36 -06:00
// Get all camera overrides for the selected camera
const cameraOverrides = useAllCameraOverrides(config, selectedCamera);
2025-03-03 09:30:52 -06:00
const { payload: allCameraStates } = useInitialCameraState(
cameras.length > 0 ? cameras[0].name : "",
true,
);
const cameraEnabledStates = useMemo(() => {
const states: Record<string, boolean> = {};
if (allCameraStates) {
Object.entries(allCameraStates).forEach(([camName, state]) => {
states[camName] = state.config?.enabled ?? false;
});
}
// fallback to config if ws data isnt available yet
cameras.forEach((cam) => {
if (!(cam.name in states)) {
states[cam.name] = cam.enabled;
}
});
return states;
}, [allCameraStates, cameras]);
const [filterZoneMask, setFilterZoneMask] = useState<PolygonType[]>();
2026-02-27 09:55:36 -06:00
// Save All state
const [isSavingAll, setIsSavingAll] = useState(false);
2026-03-26 13:47:24 -05:00
const [isAnySectionSaving, setIsAnySectionSaving] = useState(false);
2026-02-27 09:55:36 -06:00
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const { send: sendRestart } = useRestart();
const { data: fullSchema } = useSWR<RJSFSchema>("config/schema.json");
const hasPendingChanges = Object.keys(pendingDataBySection).length > 0;
const hasPendingValidationErrors = useMemo(
() =>
Object.values(sectionStatusByKey).some(
(status) => !!status && status.hasChanges && status.hasValidationErrors,
),
[sectionStatusByKey],
);
const pendingChangesPreview = useMemo<SaveAllPreviewItem[]>(() => {
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);
2026-03-19 09:47:57 -05:00
const { isProfile, profileName, actualSection } =
parseProfileFromSectionPath(sectionPath);
2026-02-27 09:55:36 -06:00
const flattened = flattenOverrides(payload.sanitizedOverrides);
2026-03-19 09:47:57 -05:00
const displaySection = isProfile ? actualSection : sectionPath;
2026-02-27 09:55:36 -06:00
flattened.forEach(({ path, value }) => {
2026-03-19 09:47:57 -05:00
const fieldPath = path ? `${displaySection}.${path}` : displaySection;
items.push({
scope,
cameraName,
profileName: isProfile
? (profileFriendlyNames.get(profileName!) ?? profileName)
: undefined,
fieldPath,
value,
});
2026-02-27 09:55:36 -06:00
});
},
);
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);
});
2026-03-19 09:47:57 -05:00
}, [config, fullSchema, pendingDataBySection, profileFriendlyNames]);
2026-02-27 09:55:36 -06:00
// 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";
}
2026-03-19 09:47:57 -05:00
// For profile keys like "profiles.armed.detect", extract the actual section
const { actualSection } = parseProfileFromSectionPath(sectionPath);
2026-02-27 09:55:36 -06:00
if (level === "camera") {
2026-03-19 09:47:57 -05:00
return CAMERA_SECTION_MAPPING[actualSection] as
| SettingsType
| undefined;
2026-02-27 09:55:36 -06:00
}
return (
2026-03-19 09:47:57 -05:00
(GLOBAL_SECTION_MAPPING[actualSection] as SettingsType | undefined) ??
(ENRICHMENTS_SECTION_MAPPING[actualSection] as
2026-02-27 09:55:36 -06:00
| SettingsType
| undefined) ??
2026-03-19 09:47:57 -05:00
(SYSTEM_SECTION_MAPPING[actualSection] as SettingsType | undefined)
2026-02-27 09:55:36 -06:00
);
},
[],
);
2026-04-07 08:16:19 -05:00
// Show save/undo all buttons only when changes span multiple sections
// or the single changed section is not the one currently being viewed
const showSaveAllButtons = useMemo(() => {
const pendingKeys = Object.keys(pendingDataBySection);
if (pendingKeys.length === 0) return false;
if (pendingKeys.length >= 2) return true;
// Exactly one pending section — check if it matches the current view
const key = pendingKeys[0];
const menuKey = pendingKeyToMenuKey(key);
if (menuKey !== pageToggle) return true;
// For camera-scoped keys, also check if the camera matches
if (key.includes("::")) {
const cameraName = key.slice(0, key.indexOf("::"));
return cameraName !== selectedCamera;
}
return false;
}, [pendingDataBySection, pendingKeyToMenuKey, pageToggle, selectedCamera]);
2026-02-27 09:55:36 -06:00
const handleSaveAll = useCallback(async () => {
if (
!config ||
!fullSchema ||
!hasPendingChanges ||
hasPendingValidationErrors
)
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];
2026-03-19 09:47:57 -05:00
2026-02-27 09:55:36 -06:00
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,
});
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,
hasValidationErrors: 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: (
<a onClick={() => setRestartDialogOpen(true)}>
<Button>
{t("restart.button", { ns: "components/dialog" })}
</Button>
</a>
),
},
);
} 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,
hasPendingValidationErrors,
pendingDataBySection,
pendingKeyToMenuKey,
t,
]);
const handleUndoAll = useCallback(() => {
const pendingKeys = Object.keys(pendingDataBySection);
if (pendingKeys.length === 0) return;
setPendingDataBySection({});
setUnsavedChanges(false);
2026-03-19 09:47:57 -05:00
setEditingProfile({});
2026-02-27 09:55:36 -06:00
setSectionStatusByKey((prev) => {
const updated = { ...prev };
for (const key of pendingKeys) {
const menuKey = pendingKeyToMenuKey(key);
if (menuKey && updated[menuKey]) {
updated[menuKey] = {
...updated[menuKey],
hasChanges: false,
hasValidationErrors: false,
};
}
}
return updated;
});
}, [pendingDataBySection, pendingKeyToMenuKey]);
const handleDialog = useCallback(
(save: boolean) => {
if (unsavedChanges && save) {
2026-02-27 09:55:36 -06:00
handleSaveAll();
}
setConfirmationDialogOpen(false);
setUnsavedChanges(false);
},
2026-02-27 09:55:36 -06:00
[unsavedChanges, handleSaveAll],
);
useEffect(() => {
2025-03-03 09:30:52 -06:00
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);
2025-10-08 14:59:21 -05:00
} else if (
!cameraEnabledStates[selectedCamera] &&
2025-10-13 11:52:08 -05:00
pageToggle !== "cameraReview"
2025-10-08 14:59:21 -05:00
) {
2025-03-03 09:30:52 -06:00
// 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);
}
}
}
2025-10-08 14:59:21 -05:00
}, [cameras, selectedCamera, cameraEnabledStates, pageToggle]);
2024-04-19 12:17:23 -05:00
2025-02-10 10:42:35 -06:00
useSearchEffect("page", (page: string) => {
if (allSettingsViews.includes(page as SettingsType)) {
2025-03-08 10:01:08 -06:00
// Restrict viewer to UI settings
2025-10-13 11:52:08 -05:00
if (
!isAdmin &&
!ALLOWED_VIEWS_FOR_VIEWER.includes(page as SettingsType)
) {
2026-03-19 09:47:57 -05:00
setPageToggle("uiSettings");
2025-03-08 10:01:08 -06:00
} else {
2025-10-08 14:59:21 -05:00
setPageToggle(page as SettingsType);
2025-03-08 10:01:08 -06:00
}
2025-11-05 08:49:31 -06:00
if (isMobile) {
setContentMobileOpen(true);
}
2025-02-10 10:42:35 -06:00
}
2025-02-15 07:56:45 -06:00
// don't clear url params if we're creating a new object mask
2025-07-07 09:03:57 -05:00
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
2025-02-10 10:42:35 -06:00
});
useSearchEffect("camera", (camera: string) => {
const cameraNames = cameras.map((c) => c.name);
if (cameraNames.includes(camera)) {
setSelectedCamera(camera);
2025-11-05 08:49:31 -06:00
if (isMobile) {
setContentMobileOpen(true);
}
2025-02-10 10:42:35 -06:00
}
2025-07-07 09:03:57 -05:00
// don't clear url params if we're creating a new object mask or trigger
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
2025-02-10 10:42:35 -06:00
});
2024-04-27 12:02:01 -05:00
useEffect(() => {
2025-10-08 14:59:21 -05:00
if (!contentMobileOpen) {
document.title = t("documentTitle.default");
}
}, [t, contentMobileOpen]);
2026-03-19 09:47:57 -05:00
// Profile state handlers
const handleSelectProfile = useCallback(
(camera: string, _section: string, profile: string | null) => {
setEditingProfile((prev) => {
if (profile === null) {
const { [camera]: _, ...rest } = prev;
return rest;
}
return { ...prev, [camera]: profile };
});
},
[],
);
const handleDeleteProfileSection = useCallback(
async (camera: string, section: string, profile: string) => {
try {
await axios.put("config/set", {
requires_restart: 0,
config_data: {
cameras: {
[camera]: {
profiles: {
[profile]: {
[section]: "",
},
},
},
},
},
});
await mutate("config");
// Switch back to base config
handleSelectProfile(camera, section, null);
toast.success(
t("profiles.deleteSectionSuccess", {
ns: "views/settings",
section: t(`configForm.sections.${section}`, {
ns: "views/settings",
defaultValue: section,
}),
profile: profileFriendlyNames.get(profile) ?? profile,
}),
);
} catch {
toast.error(t("toast.save.error.title", { ns: "common" }));
}
},
[handleSelectProfile, profileFriendlyNames, t],
);
const profileState: ProfileState = useMemo(
() => ({
editingProfile,
allProfileNames,
profileFriendlyNames,
onSelectProfile: handleSelectProfile,
onDeleteProfileSection: handleDeleteProfileSection,
}),
[
editingProfile,
allProfileNames,
profileFriendlyNames,
handleSelectProfile,
handleDeleteProfileSection,
],
);
// Header profile dropdown: derive section key from current page
const currentSectionKey = useMemo(
() => REVERSE_CAMERA_SECTION_MAPPING[pageToggle] ?? null,
[pageToggle],
);
const headerEditingProfile = useMemo(() => {
if (!selectedCamera || !currentSectionKey) return null;
return editingProfile[selectedCamera] ?? null;
}, [selectedCamera, currentSectionKey, editingProfile]);
const showProfileDropdown =
PROFILE_DROPDOWN_PAGES.has(pageToggle) &&
!!selectedCamera &&
(allProfileNames.length > 0 || profilesUIEnabled);
const headerHasProfileData = useCallback(
(profileName: string): boolean => {
if (!config || !selectedCamera || !currentSectionKey) return false;
const profileData =
config.cameras[selectedCamera]?.profiles?.[profileName];
if (!profileData) return false;
if (currentSectionKey === "masksAndZones") {
const hasZones =
profileData.zones && Object.keys(profileData.zones).length > 0;
const hasMotionMasks =
profileData.motion?.mask &&
Object.keys(profileData.motion.mask).length > 0;
const hasObjectMasks =
(profileData.objects?.mask &&
Object.keys(profileData.objects.mask).length > 0) ||
(profileData.objects?.filters &&
Object.values(profileData.objects.filters).some(
(f) => f.mask && Object.keys(f.mask).length > 0,
));
return !!(hasZones || hasMotionMasks || hasObjectMasks);
}
return !!profileData[currentSectionKey as keyof typeof profileData];
},
[config, selectedCamera, currentSectionKey],
);
const handleDeleteProfileForCurrentSection = useCallback(
async (profileName: string) => {
if (!selectedCamera || !currentSectionKey) return;
if (currentSectionKey === "masksAndZones") {
try {
const profileData =
config?.cameras?.[selectedCamera]?.profiles?.[profileName];
if (!profileData) return;
// Build a targeted delete payload that only removes mask-related
// sub-keys, not the entire motion/objects sections
const deletePayload: JsonObject = {};
if (profileData.zones !== undefined) {
deletePayload.zones = "";
}
if (profileData.motion?.mask !== undefined) {
deletePayload.motion = { mask: "" };
}
if (profileData.objects) {
const objDelete: JsonObject = {};
if (profileData.objects.mask !== undefined) {
objDelete.mask = "";
}
if (profileData.objects.filters) {
const filtersDelete: JsonObject = {};
for (const [filterName, filterVal] of Object.entries(
profileData.objects.filters,
)) {
if (filterVal.mask !== undefined) {
filtersDelete[filterName] = { mask: "" };
}
}
if (Object.keys(filtersDelete).length > 0) {
objDelete.filters = filtersDelete;
}
}
if (Object.keys(objDelete).length > 0) {
deletePayload.objects = objDelete;
}
}
if (Object.keys(deletePayload).length === 0) return;
await axios.put("config/set", {
requires_restart: 0,
config_data: {
cameras: {
[selectedCamera]: {
profiles: {
[profileName]: deletePayload,
},
},
},
},
});
await mutate("config");
handleSelectProfile(selectedCamera, "masksAndZones", null);
toast.success(
t("profiles.deleteSectionSuccess", {
ns: "views/settings",
section: t("configForm.sections.masksAndZones", {
ns: "views/settings",
}),
profile: profileFriendlyNames.get(profileName) ?? profileName,
}),
);
} catch {
toast.error(t("toast.save.error.title", { ns: "common" }));
}
} else {
await handleDeleteProfileSection(
selectedCamera,
currentSectionKey,
profileName,
);
}
},
[
selectedCamera,
currentSectionKey,
config,
handleSelectProfile,
handleDeleteProfileSection,
profileFriendlyNames,
t,
],
);
2026-02-27 09:55:36 -06:00
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,
};
});
},
[],
);
2026-03-26 13:47:24 -05:00
const handleSectionSavingChange = useCallback((saving: boolean) => {
setIsAnySectionSaving(saving);
}, []);
2026-03-19 09:47:57 -05:00
// The active profile being edited for the selected camera
const activeEditingProfile = selectedCamera
? (editingProfile[selectedCamera] ?? null)
: null;
// Profile color for the active editing profile
const activeProfileColor = useMemo(
() =>
activeEditingProfile
? getProfileColor(activeEditingProfile, allProfileNames)
: undefined,
[activeEditingProfile, allProfileNames],
);
2026-02-27 09:55:36 -06:00
// Initialize override status for all camera sections
useEffect(() => {
if (!selectedCamera || !cameraOverrides) return;
const overrideMap: Partial<
2026-03-19 09:47:57 -05:00
Record<
SettingsType,
Pick<SectionStatus, "hasChanges" | "isOverridden" | "overrideSource">
>
2026-02-27 09:55:36 -06:00
> = {};
2026-03-19 09:47:57 -05:00
// Build a set of menu keys that have pending changes for this camera
const pendingMenuKeys = new Set<string>();
const cameraPrefix = `${selectedCamera}::`;
for (const key of Object.keys(pendingDataBySection)) {
if (key.startsWith(cameraPrefix)) {
const menuKey = pendingKeyToMenuKey(key);
if (menuKey) pendingMenuKeys.add(menuKey);
}
}
// Get profile data if a profile is being edited
const profileData = activeEditingProfile
? config?.cameras?.[selectedCamera]?.profiles?.[activeEditingProfile]
: undefined;
2026-02-27 09:55:36 -06:00
// Set override status for all camera sections using the shared mapping
Object.entries(CAMERA_SECTION_MAPPING).forEach(
([sectionKey, settingsKey]) => {
2026-03-19 09:47:57 -05:00
const globalOverridden = cameraOverrides.includes(sectionKey);
// Check if the active profile overrides this section
const profileOverrides = profileData
? !!profileData[sectionKey as keyof typeof profileData]
: false;
2026-02-27 09:55:36 -06:00
overrideMap[settingsKey] = {
2026-03-19 09:47:57 -05:00
hasChanges: pendingMenuKeys.has(settingsKey),
isOverridden: profileOverrides || globalOverridden,
overrideSource: profileOverrides
? "profile"
: globalOverridden
? "global"
: undefined,
2026-02-27 09:55:36 -06:00
};
},
);
setSectionStatusByKey((prev) => {
// Merge and update both hasChanges and isOverridden for camera sections
const merged = { ...prev };
Object.entries(overrideMap).forEach(([key, status]) => {
const existingStatus = merged[key as SettingsType];
merged[key as SettingsType] = {
hasChanges: status.hasChanges,
isOverridden: status.isOverridden,
2026-03-19 09:47:57 -05:00
overrideSource: status.overrideSource,
2026-02-27 09:55:36 -06:00
hasValidationErrors: existingStatus?.hasValidationErrors ?? false,
};
});
return merged;
});
2026-03-19 09:47:57 -05:00
}, [
selectedCamera,
cameraOverrides,
pendingDataBySection,
pendingKeyToMenuKey,
activeEditingProfile,
config,
]);
2026-02-27 09:55:36 -06:00
const renderMenuItemLabel = useCallback(
(key: SettingsType) => {
const status = sectionStatusByKey[key];
const showOverrideDot =
CAMERA_SECTION_KEYS.has(key) && status?.isOverridden;
const showUnsavedDot = status?.hasChanges;
2026-03-19 09:47:57 -05:00
const dotColor =
status?.overrideSource === "profile" && activeProfileColor
? activeProfileColor.dot
: "bg-selected";
2026-02-27 09:55:36 -06:00
return (
<div className="flex w-full min-w-0 items-center justify-between pr-4 md:pr-0">
<div className="min-w-0 flex-1 whitespace-normal break-words">
{t("menu." + key)}
</div>
2026-02-27 09:55:36 -06:00
{(showOverrideDot || showUnsavedDot) && (
<div className="ml-2 flex shrink-0 items-center gap-2">
2026-02-27 09:55:36 -06:00
{showOverrideDot && (
2026-03-19 09:47:57 -05:00
<span
className={cn("inline-block size-2 rounded-full", dotColor)}
/>
2026-02-27 09:55:36 -06:00
)}
{showUnsavedDot && (
2026-04-22 10:19:30 -05:00
<span className="inline-block size-2 rounded-full bg-unsaved" />
2026-02-27 09:55:36 -06:00
)}
</div>
)}
</div>
);
},
2026-03-19 09:47:57 -05:00
[sectionStatusByKey, t, activeProfileColor],
2026-02-27 09:55:36 -06:00
);
2025-10-08 14:59:21 -05:00
if (isMobile) {
return (
<>
2026-02-27 09:55:36 -06:00
<Toaster position="top-center" />
2025-10-08 14:59:21 -05:00
{!contentMobileOpen && (
2026-02-27 09:55:36 -06:00
<div
key={`mobile-menu-${selectedCamera}`}
className="flex size-full flex-col"
>
2025-10-08 14:59:21 -05:00
<div className="sticky -top-2 z-50 mb-2 bg-background p-4">
2026-02-27 09:55:36 -06:00
<div className="relative flex w-full items-center justify-center">
2025-10-08 14:59:21 -05:00
<Logo className="h-8" />
2026-02-27 09:55:36 -06:00
<div className="absolute right-0">
<CameraSelectButton
allCameras={cameras}
selectedCamera={selectedCamera}
setSelectedCamera={setSelectedCamera}
cameraEnabledStates={cameraEnabledStates}
currentPage={page}
/>
</div>
2025-10-08 14:59:21 -05:00
</div>
2026-03-19 09:47:57 -05:00
<div className="flex flex-row items-center">
2025-10-09 07:23:03 -05:00
<h2 className="ml-2 text-lg">
2025-10-08 16:36:23 -05:00
{t("menu.settings", { ns: "common" })}
2025-10-08 14:59:21 -05:00
</h2>
</div>
</div>
<div className="scrollbar-container overflow-y-auto px-4">
{settingsGroups.map((group) => {
const filteredItems = group.items.filter((item) =>
visibleSettingsViews.includes(item.key as SettingsType),
);
if (filteredItems.length === 0) return null;
return (
<div key={group.label} className="mb-3">
{filteredItems.length > 1 && (
<h3 className="mb-2 ml-2 text-sm font-medium text-secondary-foreground">
2026-02-27 09:55:36 -06:00
<div>{t("menu." + group.label)}</div>
2025-10-08 14:59:21 -05:00
</h3>
)}
{filteredItems.map((item) => (
<MobileMenuItem
key={item.key}
item={item}
className={cn(filteredItems.length == 1 && "pl-2")}
2026-02-27 09:55:36 -06:00
label={renderMenuItemLabel(item.key as SettingsType)}
2025-10-08 14:59:21 -05:00
onSelect={(key) => {
if (
!isAdmin &&
2025-10-13 11:52:08 -05:00
!ALLOWED_VIEWS_FOR_VIEWER.includes(
key as SettingsType,
)
2025-10-08 14:59:21 -05:00
) {
2026-03-19 09:47:57 -05:00
setPageToggle("uiSettings");
2025-10-08 14:59:21 -05:00
} else {
setPageToggle(key as SettingsType);
}
setContentMobileOpen(true);
}}
/>
))}
</div>
);
})}
</div>
2026-04-07 08:16:19 -05:00
{showSaveAllButtons && (
2026-02-27 09:55:36 -06:00
<div className="sticky bottom-0 z-50 mt-2 bg-background p-4">
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
2026-04-22 10:19:30 -05:00
<span className="text-sm text-unsaved">
2026-02-27 09:55:36 -06:00
{t("unsavedChanges", {
ns: "views/settings",
defaultValue: "You have unsaved changes",
})}
</span>
<SaveAllPreviewPopover
items={pendingChangesPreview}
className="h-7 w-7"
align="center"
side="top"
/>
</div>
<Button
onClick={handleUndoAll}
variant="outline"
size="sm"
2026-03-26 13:47:24 -05:00
disabled={isSavingAll || isAnySectionSaving}
2026-02-27 09:55:36 -06:00
className="flex w-full items-center justify-center gap-2"
>
{t("button.undoAll", {
ns: "common",
defaultValue: "Undo All",
})}
</Button>
<Button
onClick={handleSaveAll}
variant="select"
size="sm"
2026-03-26 13:47:24 -05:00
disabled={
isSavingAll ||
isAnySectionSaving ||
hasPendingValidationErrors
}
2026-02-27 09:55:36 -06:00
className="flex w-full items-center justify-center gap-2"
>
{isSavingAll ? (
<>
<ActivityIndicator className="h-4 w-4" />
{t("button.savingAll", { ns: "common" })}
</>
) : (
t("button.saveAll", { ns: "common" })
)}
</Button>
</div>
</div>
)}
2025-10-08 14:59:21 -05:00
</div>
)}
<MobilePage
open={contentMobileOpen}
onOpenChange={setContentMobileOpen}
>
<MobilePageContent
className={cn("px-2", "scrollbar-container overflow-y-auto")}
>
<MobilePageHeader
className="top-0 mb-0"
onClose={() => navigate(-1)}
actions={
2026-02-27 09:55:36 -06:00
<div className="flex items-center gap-2">
{CAMERA_SELECT_BUTTON_PAGES.includes(pageToggle) && (
<>
{pageToggle == "masksAndZones" && (
<ZoneMaskFilterButton
selectedZoneMask={filterZoneMask}
updateZoneMaskFilter={setFilterZoneMask}
/>
)}
2026-03-19 09:47:57 -05:00
{showProfileDropdown && currentSectionKey && (
<ProfileSectionDropdown
allProfileNames={allProfileNames}
profileFriendlyNames={profileFriendlyNames}
editingProfile={headerEditingProfile}
hasProfileData={headerHasProfileData}
onSelectProfile={(profile) =>
handleSelectProfile(
selectedCamera,
currentSectionKey,
profile,
)
}
iconOnly
/>
)}
2026-02-27 09:55:36 -06:00
<CameraSelectButton
allCameras={cameras}
selectedCamera={selectedCamera}
setSelectedCamera={setSelectedCamera}
cameraEnabledStates={cameraEnabledStates}
currentPage={page}
2025-10-08 14:59:21 -05:00
/>
2026-02-27 09:55:36 -06:00
</>
)}
</div>
2025-10-08 14:59:21 -05:00
}
>
<MobilePageTitle>{t("menu." + page)}</MobilePageTitle>
</MobilePageHeader>
<div className="p-2">
{(() => {
const CurrentComponent = getCurrentComponent(page);
if (!CurrentComponent) return null;
return (
<CurrentComponent
selectedCamera={selectedCamera}
setUnsavedChanges={setUnsavedChanges}
selectedZoneMask={filterZoneMask}
2026-02-27 09:55:36 -06:00
onSectionStatusChange={handleSectionStatusChange}
pendingDataBySection={pendingDataBySection}
onPendingDataChange={handlePendingDataChange}
2026-03-19 09:47:57 -05:00
profileState={profileState}
onDeleteProfileSection={
handleDeleteProfileForCurrentSection
}
profilesUIEnabled={profilesUIEnabled}
setProfilesUIEnabled={setProfilesUIEnabled}
2026-03-26 13:47:24 -05:00
isSavingAll={isSavingAll}
onSectionSavingChange={handleSectionSavingChange}
2025-10-08 14:59:21 -05:00
/>
);
})()}
</div>
</MobilePageContent>
</MobilePage>
{confirmationDialogOpen && (
<AlertDialog
open={confirmationDialogOpen}
onOpenChange={() => setConfirmationDialogOpen(false)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("dialog.unsavedChanges.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("dialog.unsavedChanges.desc")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => handleDialog(false)}>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDialog(true)}>
{t("button.save", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
2026-02-27 09:55:36 -06:00
<RestartDialog
isOpen={restartDialogOpen}
onClose={() => setRestartDialogOpen(false)}
onRestart={() => sendRestart("restart")}
/>
2025-10-08 14:59:21 -05:00
</>
);
}
2024-04-27 12:02:01 -05:00
return (
2025-10-08 14:59:21 -05:00
<div className="flex h-full flex-col">
2026-02-27 09:55:36 -06:00
<Toaster position="top-center" />
<div className="flex min-h-16 items-center justify-between border-b border-secondary p-3">
2026-03-19 09:47:57 -05:00
<div className="mr-2 flex w-full items-center justify-between gap-3">
<Heading as="h3" className="mb-0">
{t("menu.settings", { ns: "common" })}
</Heading>
</div>
<div className="flex items-center gap-2">
2026-04-07 08:16:19 -05:00
{showSaveAllButtons && (
2026-02-27 09:55:36 -06:00
<div
className={cn(
"flex flex-row items-center gap-2",
CAMERA_SELECT_BUTTON_PAGES.includes(page) &&
"border-r border-secondary pr-5",
)}
>
<SaveAllPreviewPopover
items={pendingChangesPreview}
className="size-8"
align="end"
side="bottom"
/>
2026-02-27 09:55:36 -06:00
<Button
onClick={handleUndoAll}
variant="outline"
size="sm"
2026-03-26 13:47:24 -05:00
disabled={isSavingAll || isAnySectionSaving}
2026-02-27 09:55:36 -06:00
className="flex items-center justify-center gap-2"
>
{t("button.undoAll", {
ns: "common",
defaultValue: "Undo All",
})}
</Button>
<Button
variant="select"
size="sm"
onClick={handleSaveAll}
2026-03-26 13:47:24 -05:00
disabled={
isSavingAll ||
isAnySectionSaving ||
hasPendingValidationErrors
}
2026-02-27 09:55:36 -06:00
className="flex items-center justify-center gap-2"
>
{isSavingAll ? (
<>
2026-03-19 09:47:57 -05:00
<ActivityIndicator className="mr-2 size-4" />
2026-02-27 09:55:36 -06:00
{t("button.savingAll", { ns: "common" })}
</>
) : (
t("button.saveAll", { ns: "common" })
)}
</Button>
</div>
)}
{CAMERA_SELECT_BUTTON_PAGES.includes(page) && (
<>
{pageToggle == "masksAndZones" && (
<ZoneMaskFilterButton
selectedZoneMask={filterZoneMask}
updateZoneMaskFilter={setFilterZoneMask}
/>
)}
2026-03-19 09:47:57 -05:00
{showProfileDropdown && currentSectionKey && (
<ProfileSectionDropdown
allProfileNames={allProfileNames}
profileFriendlyNames={profileFriendlyNames}
editingProfile={headerEditingProfile}
hasProfileData={headerHasProfileData}
onSelectProfile={(profile) =>
handleSelectProfile(
selectedCamera,
currentSectionKey,
profile,
)
}
/>
)}
2026-02-27 09:55:36 -06:00
<CameraSelectButton
allCameras={cameras}
selectedCamera={selectedCamera}
setSelectedCamera={setSelectedCamera}
cameraEnabledStates={cameraEnabledStates}
currentPage={page}
/>
</>
)}
</div>
</div>
<SidebarProvider className="relative h-full min-h-0 flex-1">
<Sidebar variant="inset" className="absolute h-full pl-0 pt-0">
<SidebarContent className="scrollbar-container overflow-y-auto border-r-[1px] border-secondary bg-background py-2">
2025-10-08 14:59:21 -05:00
<SidebarMenu>
{settingsGroups.map((group) => {
const filteredItems = group.items.filter((item) =>
visibleSettingsViews.includes(item.key as SettingsType),
);
if (filteredItems.length === 0) return null;
return (
<SidebarGroup key={group.label} className="py-1">
{filteredItems.length === 1 ? (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
className="ml-0 h-auto min-h-8 py-1.5"
2025-10-08 14:59:21 -05:00
isActive={pageToggle === filteredItems[0].key}
onClick={() => {
if (
!isAdmin &&
2025-10-13 11:52:08 -05:00
!ALLOWED_VIEWS_FOR_VIEWER.includes(
2025-10-08 14:59:21 -05:00
filteredItems[0].key as SettingsType,
)
) {
2026-03-19 09:47:57 -05:00
setPageToggle("uiSettings");
2025-10-08 14:59:21 -05:00
} else {
setPageToggle(
filteredItems[0].key as SettingsType,
);
}
}}
>
2026-02-27 09:55:36 -06:00
{renderMenuItemLabel(
filteredItems[0].key as SettingsType,
)}
2025-10-08 14:59:21 -05:00
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
) : (
<>
<SidebarGroupLabel
className={cn(
2025-10-08 16:02:38 -05:00
"ml-2 cursor-default pl-0 text-sm",
2025-10-08 14:59:21 -05:00
filteredItems.some(
(item) => pageToggle === item.key,
)
? "text-primary"
: "text-sidebar-foreground/80",
)}
>
2026-02-27 09:55:36 -06:00
<div>{t("menu." + group.label)}</div>
2025-10-08 14:59:21 -05:00
</SidebarGroupLabel>
2025-10-08 16:02:38 -05:00
<SidebarMenuSub className="mx-2 border-0">
2025-10-08 14:59:21 -05:00
{filteredItems.map((item) => (
<SidebarMenuSubItem key={item.key}>
<SidebarMenuSubButton
className="h-auto w-full py-1.5"
2025-10-08 14:59:21 -05:00
isActive={pageToggle === item.key}
onClick={() => {
if (
!isAdmin &&
2025-10-13 11:52:08 -05:00
!ALLOWED_VIEWS_FOR_VIEWER.includes(
2025-10-08 14:59:21 -05:00
item.key as SettingsType,
)
) {
2026-03-19 09:47:57 -05:00
setPageToggle("uiSettings");
2025-10-08 14:59:21 -05:00
} else {
setPageToggle(item.key as SettingsType);
}
}}
>
2026-02-27 09:55:36 -06:00
<div className="w-full cursor-pointer">
{renderMenuItemLabel(
item.key as SettingsType,
)}
2025-10-08 14:59:21 -05:00
</div>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</>
)}
</SidebarGroup>
);
})}
</SidebarMenu>
</SidebarContent>
</Sidebar>
<SidebarInset>
2026-02-27 09:55:36 -06:00
<div
className={cn(
"scrollbar-container flex-1 overflow-y-auto pl-2 pr-0 pt-2",
2026-02-27 09:55:36 -06:00
)}
>
2025-10-08 14:59:21 -05:00
{(() => {
const CurrentComponent = getCurrentComponent(page);
if (!CurrentComponent) return null;
return (
<CurrentComponent
selectedCamera={selectedCamera}
setUnsavedChanges={setUnsavedChanges}
selectedZoneMask={filterZoneMask}
2026-02-27 09:55:36 -06:00
onSectionStatusChange={handleSectionStatusChange}
pendingDataBySection={pendingDataBySection}
onPendingDataChange={handlePendingDataChange}
2026-03-19 09:47:57 -05:00
profileState={profileState}
onDeleteProfileSection={handleDeleteProfileForCurrentSection}
profilesUIEnabled={profilesUIEnabled}
setProfilesUIEnabled={setProfilesUIEnabled}
2026-03-26 13:47:24 -05:00
isSavingAll={isSavingAll}
onSectionSavingChange={handleSectionSavingChange}
2025-10-08 14:59:21 -05:00
/>
);
})()}
</div>
</SidebarInset>
{confirmationDialogOpen && (
<AlertDialog
open={confirmationDialogOpen}
onOpenChange={() => setConfirmationDialogOpen(false)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("dialog.unsavedChanges.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("dialog.unsavedChanges.desc")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => handleDialog(false)}>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDialog(true)}>
{t("button.save", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
2024-10-15 18:25:59 -06:00
)}
2025-10-08 14:59:21 -05:00
</SidebarProvider>
2026-02-27 09:55:36 -06:00
<RestartDialog
isOpen={restartDialogOpen}
onClose={() => setRestartDialogOpen(false)}
onRestart={() => sendRestart("restart")}
/>
</div>
);
}
type CameraSelectButtonProps = {
allCameras: CameraConfig[];
selectedCamera: string;
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
2025-03-03 09:30:52 -06:00
cameraEnabledStates: Record<string, boolean>;
currentPage: SettingsType;
};
function CameraSelectButton({
allCameras,
selectedCamera,
setSelectedCamera,
2025-03-03 09:30:52 -06:00
cameraEnabledStates,
currentPage,
}: CameraSelectButtonProps) {
const { t } = useTranslation(["views/settings"]);
const [open, setOpen] = useState(false);
if (!allCameras.length) {
2025-03-03 09:30:52 -06:00
return null;
}
const trigger = (
<Button
className="flex items-center gap-2 bg-selected smart-capitalize hover:bg-selected"
2024-10-22 17:07:42 -05:00
aria-label="Select a camera"
size="sm"
>
<FaVideo className="text-background dark:text-primary" />
2024-05-14 10:06:44 -05:00
<div className="hidden text-background dark:text-primary md:block">
2025-08-27 01:15:01 +08:00
{selectedCamera == undefined ? (
t("cameraSetting.noCamera")
) : (
<CameraNameLabel camera={selectedCamera} />
)}
</div>
</Button>
);
const content = (
<>
{isMobile && (
<>
<DropdownMenuLabel className="flex justify-center">
{t("cameraSetting.camera")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<div className="scrollbar-container mb-5 h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden p-4 md:mb-1">
<div className="flex flex-col gap-2.5">
2025-03-03 09:30:52 -06:00
{allCameras.map((item) => {
const isEnabled = cameraEnabledStates[item.name];
2025-10-13 11:52:08 -05:00
const isCameraSettingsPage = currentPage === "cameraReview";
2025-03-03 09:30:52 -06:00
return (
<FilterSwitch
key={item.name}
isChecked={item.name === selectedCamera}
2025-08-27 01:15:01 +08:00
label={item.name}
2025-11-07 22:02:06 +08:00
type={"camera"}
2025-03-03 09:30:52 -06:00
onCheckedChange={(isChecked) => {
if (isChecked && (isEnabled || isCameraSettingsPage)) {
setSelectedCamera(item.name);
setOpen(false);
}
}}
disabled={!isEnabled && !isCameraSettingsPage}
/>
);
})}
</div>
</div>
</>
);
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(open: boolean) => {
if (!open) {
setSelectedCamera(selectedCamera);
}
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<DropdownMenu
open={open}
onOpenChange={(open: boolean) => {
if (!open) {
setSelectedCamera(selectedCamera);
}
setOpen(open);
}}
>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>{content}</DropdownMenuContent>
</DropdownMenu>
);
}