2023-12-08 16:33:22 +03:00
|
|
|
|
import {
|
2024-04-19 14:34:07 +03:00
|
|
|
|
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 18:55:36 +03:00
|
|
|
|
import {
|
|
|
|
|
|
useCallback,
|
|
|
|
|
|
useEffect,
|
|
|
|
|
|
useMemo,
|
|
|
|
|
|
useState,
|
|
|
|
|
|
type ReactNode,
|
|
|
|
|
|
} from "react";
|
2024-04-19 14:34:07 +03:00
|
|
|
|
import useOptimisticState from "@/hooks/use-optimistic-state";
|
2025-10-08 22:59:21 +03:00
|
|
|
|
import { isMobile } from "react-device-detect";
|
2024-04-19 14:34:07 +03:00
|
|
|
|
import { FaVideo } from "react-icons/fa";
|
|
|
|
|
|
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
2026-02-27 18:55:36 +03:00
|
|
|
|
import type { ConfigSectionData } from "@/types/configForm";
|
2024-04-19 14:34:07 +03:00
|
|
|
|
import useSWR from "swr";
|
|
|
|
|
|
import FilterSwitch from "@/components/filter/FilterSwitch";
|
|
|
|
|
|
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
|
|
|
|
|
import { PolygonType } from "@/types/canvas";
|
2025-10-13 19:52:08 +03:00
|
|
|
|
import CameraManagementView from "@/views/settings/CameraManagementView";
|
2024-05-29 17:01:39 +03:00
|
|
|
|
import MotionTunerView from "@/views/settings/MotionTunerView";
|
|
|
|
|
|
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
|
2025-09-12 14:19:29 +03:00
|
|
|
|
import UsersView from "@/views/settings/UsersView";
|
|
|
|
|
|
import RolesView from "@/views/settings/RolesView";
|
2024-10-16 03:25:59 +03:00
|
|
|
|
import UiSettingsView from "@/views/settings/UiSettingsView";
|
2025-03-17 21:44:57 +03:00
|
|
|
|
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
|
2026-03-05 19:16:17 +03:00
|
|
|
|
import MediaSyncSettingsView from "@/views/settings/MediaSyncSettingsView";
|
|
|
|
|
|
import RegionGridSettingsView from "@/views/settings/RegionGridSettingsView";
|
2026-02-27 18:55:36 +03:00
|
|
|
|
import SystemDetectionModelSettingsView from "@/views/settings/SystemDetectionModelSettingsView";
|
|
|
|
|
|
import {
|
|
|
|
|
|
SingleSectionPage,
|
|
|
|
|
|
type SettingsPageProps,
|
|
|
|
|
|
type SectionStatus,
|
|
|
|
|
|
} from "@/views/settings/SingleSectionPage";
|
2025-02-10 19:42:35 +03:00
|
|
|
|
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
2025-10-08 22:59:21 +03:00
|
|
|
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
2025-03-03 18:30:52 +03:00
|
|
|
|
import { useInitialCameraState } from "@/api/ws";
|
2025-03-08 19:01:08 +03:00
|
|
|
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
2025-03-16 18:36:20 +03:00
|
|
|
|
import { useTranslation } from "react-i18next";
|
2026-02-27 18:55:36 +03:00
|
|
|
|
import { useAllCameraOverrides } from "@/hooks/use-config-override";
|
2025-07-07 17:03:57 +03:00
|
|
|
|
import TriggerView from "@/views/settings/TriggerView";
|
2025-11-07 17:02:06 +03:00
|
|
|
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
2025-10-08 22:59:21 +03: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 18:55:36 +03: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,
|
|
|
|
|
|
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";
|
2024-07-22 23:39:15 +03:00
|
|
|
|
|
|
|
|
|
|
const allSettingsViews = [
|
2026-02-27 18:55:36 +03:00
|
|
|
|
"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",
|
2025-10-13 19:52:08 +03:00
|
|
|
|
"cameraReview",
|
2026-02-27 18:55:36 +03:00
|
|
|
|
"cameraAudioEvents",
|
|
|
|
|
|
"cameraAudioTranscription",
|
|
|
|
|
|
"cameraNotifications",
|
|
|
|
|
|
"cameraLivePlayback",
|
|
|
|
|
|
"cameraBirdseye",
|
|
|
|
|
|
"cameraFaceRecognition",
|
|
|
|
|
|
"cameraLpr",
|
|
|
|
|
|
"cameraMqttConfig",
|
|
|
|
|
|
"cameraOnvif",
|
|
|
|
|
|
"cameraUi",
|
|
|
|
|
|
"cameraTimestampStyle",
|
|
|
|
|
|
"cameraManagement",
|
2025-03-16 18:36:20 +03:00
|
|
|
|
"masksAndZones",
|
|
|
|
|
|
"motionTuner",
|
2026-02-27 18:55:36 +03:00
|
|
|
|
"enrichments",
|
2025-07-07 17:03:57 +03:00
|
|
|
|
"triggers",
|
2024-07-22 23:39:15 +03:00
|
|
|
|
"debug",
|
|
|
|
|
|
"users",
|
2025-09-12 14:19:29 +03:00
|
|
|
|
"roles",
|
2024-07-22 23:39:15 +03:00
|
|
|
|
"notifications",
|
2025-03-17 21:44:57 +03:00
|
|
|
|
"frigateplus",
|
2026-03-05 19:16:17 +03:00
|
|
|
|
"mediaSync",
|
|
|
|
|
|
"regionGrid",
|
2024-07-22 23:39:15 +03:00
|
|
|
|
] as const;
|
|
|
|
|
|
type SettingsType = (typeof allSettingsViews)[number];
|
2024-04-19 14:34:07 +03:00
|
|
|
|
|
2026-02-27 18:55:36 +03: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 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<string, unknown>);
|
|
|
|
|
|
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) => (
|
|
|
|
|
|
<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 22:59:21 +03:00
|
|
|
|
const settingsGroups = [
|
|
|
|
|
|
{
|
2025-10-09 00:36:23 +03:00
|
|
|
|
label: "general",
|
2026-02-27 18:55:36 +03:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
2025-10-08 22:59:21 +03:00
|
|
|
|
},
|
|
|
|
|
|
{
|
2025-10-09 00:36:23 +03:00
|
|
|
|
label: "cameras",
|
2025-10-08 22:59:21 +03:00
|
|
|
|
items: [
|
2025-10-13 19:52:08 +03:00
|
|
|
|
{ key: "cameraManagement", component: CameraManagementView },
|
2026-02-27 18:55:36 +03:00
|
|
|
|
{ key: "cameraDetect", component: CameraDetectSettingsPage },
|
|
|
|
|
|
{ key: "cameraObjects", component: CameraObjectsSettingsPage },
|
|
|
|
|
|
{ key: "cameraMotion", component: CameraMotionSettingsPage },
|
2025-10-08 22:59:21 +03:00
|
|
|
|
{ key: "motionTuner", component: MotionTunerView },
|
2026-02-27 18:55:36 +03: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 22:59:21 +03:00
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2025-10-09 00:36:23 +03:00
|
|
|
|
label: "enrichments",
|
2026-02-27 18:55:36 +03: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: [
|
|
|
|
|
|
{
|
|
|
|
|
|
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 22:59:21 +03:00
|
|
|
|
},
|
|
|
|
|
|
{
|
2025-10-09 00:36:23 +03:00
|
|
|
|
label: "users",
|
2025-10-08 22:59:21 +03:00
|
|
|
|
items: [
|
|
|
|
|
|
{ key: "users", component: UsersView },
|
|
|
|
|
|
{ key: "roles", component: RolesView },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2025-10-09 00:36:23 +03:00
|
|
|
|
label: "notifications",
|
2026-02-27 18:55:36 +03:00
|
|
|
|
items: [{ key: "notifications", component: NotificationsSettingsPage }],
|
2025-10-08 22:59:21 +03:00
|
|
|
|
},
|
|
|
|
|
|
{
|
2025-10-09 00:36:23 +03:00
|
|
|
|
label: "frigateplus",
|
2025-10-08 22:59:21 +03:00
|
|
|
|
items: [{ key: "frigateplus", component: FrigatePlusSettingsView }],
|
|
|
|
|
|
},
|
2026-01-06 18:20:19 +03:00
|
|
|
|
{
|
|
|
|
|
|
label: "maintenance",
|
2026-03-05 19:16:17 +03:00
|
|
|
|
items: [
|
|
|
|
|
|
{ key: "mediaSync", component: MediaSyncSettingsView },
|
|
|
|
|
|
{ key: "regionGrid", component: RegionGridSettingsView },
|
|
|
|
|
|
],
|
2026-01-06 18:20:19 +03:00
|
|
|
|
},
|
2025-10-08 22:59:21 +03:00
|
|
|
|
];
|
|
|
|
|
|
|
2025-10-13 19:52:08 +03:00
|
|
|
|
const CAMERA_SELECT_BUTTON_PAGES = [
|
|
|
|
|
|
"debug",
|
2026-02-27 18:55:36 +03:00
|
|
|
|
"cameraDetect",
|
|
|
|
|
|
"cameraFfmpeg",
|
|
|
|
|
|
"cameraRecording",
|
|
|
|
|
|
"cameraSnapshots",
|
|
|
|
|
|
"cameraMotion",
|
|
|
|
|
|
"cameraObjects",
|
2025-10-13 19:52:08 +03:00
|
|
|
|
"cameraReview",
|
2026-02-27 18:55:36 +03:00
|
|
|
|
"cameraAudioEvents",
|
|
|
|
|
|
"cameraAudioTranscription",
|
|
|
|
|
|
"cameraNotifications",
|
|
|
|
|
|
"cameraLivePlayback",
|
|
|
|
|
|
"cameraBirdseye",
|
|
|
|
|
|
"cameraFaceRecognition",
|
|
|
|
|
|
"cameraLpr",
|
|
|
|
|
|
"cameraMqttConfig",
|
|
|
|
|
|
"cameraOnvif",
|
|
|
|
|
|
"cameraUi",
|
|
|
|
|
|
"cameraTimestampStyle",
|
2025-10-13 19:52:08 +03:00
|
|
|
|
"masksAndZones",
|
|
|
|
|
|
"motionTuner",
|
|
|
|
|
|
"triggers",
|
2026-03-05 19:16:17 +03:00
|
|
|
|
"regionGrid",
|
2025-10-13 19:52:08 +03:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const ALLOWED_VIEWS_FOR_VIEWER = ["ui", "debug", "notifications"];
|
|
|
|
|
|
|
2026-03-05 19:20:00 +03:00
|
|
|
|
const LARGE_BOTTOM_MARGIN_PAGES = [
|
|
|
|
|
|
"masksAndZones",
|
|
|
|
|
|
"motionTuner",
|
|
|
|
|
|
"mediaSync",
|
|
|
|
|
|
"regionGrid",
|
|
|
|
|
|
];
|
2026-02-27 18:55:36 +03: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",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 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> = {
|
|
|
|
|
|
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 22:59:21 +03: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 18:55:36 +03:00
|
|
|
|
label,
|
2025-10-08 22:59:21 +03:00
|
|
|
|
}: {
|
|
|
|
|
|
item: { key: string };
|
|
|
|
|
|
onSelect: (key: string) => void;
|
|
|
|
|
|
onClose?: () => void;
|
|
|
|
|
|
className?: string;
|
2026-02-27 18:55:36 +03:00
|
|
|
|
label?: ReactNode;
|
2025-10-08 22:59:21 +03:00
|
|
|
|
}) {
|
|
|
|
|
|
const { t } = useTranslation(["views/settings"]);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2025-11-05 17:49:31 +03: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 22:59:21 +03:00
|
|
|
|
onClick={() => {
|
|
|
|
|
|
onSelect(item.key);
|
|
|
|
|
|
onClose?.();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-02-27 18:55:36 +03:00
|
|
|
|
<div className="w-full">
|
|
|
|
|
|
{label ?? <div>{t("menu." + item.key)}</div>}
|
|
|
|
|
|
</div>
|
2025-10-08 22:59:21 +03:00
|
|
|
|
<LuChevronRight className="size-4" />
|
2025-11-05 17:49:31 +03:00
|
|
|
|
</div>
|
2025-10-08 22:59:21 +03:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-04-19 14:34:07 +03:00
|
|
|
|
export default function Settings() {
|
2025-03-16 18:36:20 +03:00
|
|
|
|
const { t } = useTranslation(["views/settings"]);
|
2026-02-27 18:55:36 +03:00
|
|
|
|
const [page, setPage] = useState<SettingsType>("profileSettings");
|
2024-04-19 14:34:07 +03:00
|
|
|
|
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
2025-10-08 22:59:21 +03:00
|
|
|
|
const [contentMobileOpen, setContentMobileOpen] = useState(false);
|
2026-02-27 18:55:36 +03:00
|
|
|
|
const [sectionStatusByKey, setSectionStatusByKey] = useState<
|
|
|
|
|
|
Partial<Record<SettingsType, SectionStatus>>
|
|
|
|
|
|
>({});
|
2024-04-19 14:34:07 +03:00
|
|
|
|
|
|
|
|
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
|
|
|
|
|
2025-02-15 16:56:45 +03:00
|
|
|
|
const [searchParams] = useSearchParams();
|
|
|
|
|
|
|
2025-03-08 19:01:08 +03:00
|
|
|
|
// auth and roles
|
|
|
|
|
|
|
|
|
|
|
|
const isAdmin = useIsAdmin();
|
|
|
|
|
|
|
|
|
|
|
|
const visibleSettingsViews = !isAdmin
|
2025-10-13 19:52:08 +03:00
|
|
|
|
? ALLOWED_VIEWS_FOR_VIEWER
|
2025-03-08 19:01:08 +03:00
|
|
|
|
: allSettingsViews;
|
|
|
|
|
|
|
2024-04-19 14:34:07 +03:00
|
|
|
|
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
|
|
|
|
|
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
|
|
|
|
|
|
2026-02-27 18:55:36 +03:00
|
|
|
|
// Store pending form data keyed by "sectionKey" or "cameraName::sectionKey"
|
|
|
|
|
|
const [pendingDataBySection, setPendingDataBySection] = useState<
|
|
|
|
|
|
Record<string, unknown>
|
|
|
|
|
|
>({});
|
|
|
|
|
|
|
2025-10-08 22:59:21 +03:00
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
|
|
2024-04-19 14:34:07 +03:00
|
|
|
|
const cameras = useMemo(() => {
|
|
|
|
|
|
if (!config) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Object.values(config.cameras)
|
2025-03-03 18:30:52 +03:00
|
|
|
|
.filter((conf) => conf.ui.dashboard && conf.enabled_in_config)
|
2024-04-19 14:34:07 +03:00
|
|
|
|
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
|
|
|
|
|
}, [config]);
|
|
|
|
|
|
|
|
|
|
|
|
const [selectedCamera, setSelectedCamera] = useState<string>("");
|
|
|
|
|
|
|
2026-02-27 18:55:36 +03:00
|
|
|
|
// Get all camera overrides for the selected camera
|
|
|
|
|
|
const cameraOverrides = useAllCameraOverrides(config, selectedCamera);
|
|
|
|
|
|
|
2025-03-03 18:30:52 +03: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 isn’t available yet
|
|
|
|
|
|
cameras.forEach((cam) => {
|
|
|
|
|
|
if (!(cam.name in states)) {
|
|
|
|
|
|
states[cam.name] = cam.enabled;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
return states;
|
|
|
|
|
|
}, [allCameraStates, cameras]);
|
|
|
|
|
|
|
2024-04-19 14:34:07 +03:00
|
|
|
|
const [filterZoneMask, setFilterZoneMask] = useState<PolygonType[]>();
|
|
|
|
|
|
|
2026-02-27 18:55:36 +03:00
|
|
|
|
// Save All state
|
|
|
|
|
|
const [isSavingAll, setIsSavingAll] = useState(false);
|
|
|
|
|
|
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);
|
|
|
|
|
|
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 ||
|
|
|
|
|
|
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];
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2024-04-19 14:34:07 +03:00
|
|
|
|
const handleDialog = useCallback(
|
|
|
|
|
|
(save: boolean) => {
|
|
|
|
|
|
if (unsavedChanges && save) {
|
2026-02-27 18:55:36 +03:00
|
|
|
|
handleSaveAll();
|
2024-04-19 14:34:07 +03:00
|
|
|
|
}
|
|
|
|
|
|
setConfirmationDialogOpen(false);
|
|
|
|
|
|
setUnsavedChanges(false);
|
|
|
|
|
|
},
|
2026-02-27 18:55:36 +03:00
|
|
|
|
[unsavedChanges, handleSaveAll],
|
2024-04-19 14:34:07 +03:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2025-03-03 18:30:52 +03: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 22:59:21 +03:00
|
|
|
|
} else if (
|
|
|
|
|
|
!cameraEnabledStates[selectedCamera] &&
|
2025-10-13 19:52:08 +03:00
|
|
|
|
pageToggle !== "cameraReview"
|
2025-10-08 22:59:21 +03:00
|
|
|
|
) {
|
2025-03-03 18:30:52 +03: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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-04-19 14:34:07 +03:00
|
|
|
|
}
|
2025-10-08 22:59:21 +03:00
|
|
|
|
}, [cameras, selectedCamera, cameraEnabledStates, pageToggle]);
|
2024-04-19 20:17:23 +03:00
|
|
|
|
|
2025-02-10 19:42:35 +03:00
|
|
|
|
useSearchEffect("page", (page: string) => {
|
|
|
|
|
|
if (allSettingsViews.includes(page as SettingsType)) {
|
2025-03-08 19:01:08 +03:00
|
|
|
|
// Restrict viewer to UI settings
|
2025-10-13 19:52:08 +03:00
|
|
|
|
if (
|
|
|
|
|
|
!isAdmin &&
|
|
|
|
|
|
!ALLOWED_VIEWS_FOR_VIEWER.includes(page as SettingsType)
|
|
|
|
|
|
) {
|
2026-02-27 18:55:36 +03:00
|
|
|
|
setPageToggle("profileSettings");
|
2025-03-08 19:01:08 +03:00
|
|
|
|
} else {
|
2025-10-08 22:59:21 +03:00
|
|
|
|
setPageToggle(page as SettingsType);
|
2025-03-08 19:01:08 +03:00
|
|
|
|
}
|
2025-11-05 17:49:31 +03:00
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
setContentMobileOpen(true);
|
|
|
|
|
|
}
|
2025-02-10 19:42:35 +03:00
|
|
|
|
}
|
2025-02-15 16:56:45 +03:00
|
|
|
|
// don't clear url params if we're creating a new object mask
|
2025-07-07 17:03:57 +03:00
|
|
|
|
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
|
2025-02-10 19:42:35 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
useSearchEffect("camera", (camera: string) => {
|
|
|
|
|
|
const cameraNames = cameras.map((c) => c.name);
|
|
|
|
|
|
if (cameraNames.includes(camera)) {
|
|
|
|
|
|
setSelectedCamera(camera);
|
2025-11-05 17:49:31 +03:00
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
setContentMobileOpen(true);
|
|
|
|
|
|
}
|
2025-02-10 19:42:35 +03:00
|
|
|
|
}
|
2025-07-07 17:03:57 +03: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 19:42:35 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
2024-04-27 20:02:01 +03:00
|
|
|
|
useEffect(() => {
|
2025-10-08 22:59:21 +03:00
|
|
|
|
if (!contentMobileOpen) {
|
|
|
|
|
|
document.title = t("documentTitle.default");
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [t, contentMobileOpen]);
|
2024-04-27 20:02:01 +03:00
|
|
|
|
|
2026-02-27 18:55:36 +03: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,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
[],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize override status for all camera sections
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!selectedCamera || !cameraOverrides) return;
|
|
|
|
|
|
|
|
|
|
|
|
const overrideMap: Partial<
|
|
|
|
|
|
Record<SettingsType, Pick<SectionStatus, "hasChanges" | "isOverridden">>
|
|
|
|
|
|
> = {};
|
|
|
|
|
|
|
|
|
|
|
|
// 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]) => {
|
|
|
|
|
|
const existingStatus = merged[key as SettingsType];
|
|
|
|
|
|
merged[key as SettingsType] = {
|
|
|
|
|
|
hasChanges: status.hasChanges,
|
|
|
|
|
|
isOverridden: status.isOverridden,
|
|
|
|
|
|
hasValidationErrors: existingStatus?.hasValidationErrors ?? false,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
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 (
|
|
|
|
|
|
<div className="flex w-full items-center justify-between pr-4 md:pr-0">
|
|
|
|
|
|
<div>{t("menu." + key)}</div>
|
|
|
|
|
|
{(showOverrideDot || showUnsavedDot) && (
|
|
|
|
|
|
<div className="ml-2 flex items-center gap-2">
|
|
|
|
|
|
{showOverrideDot && (
|
|
|
|
|
|
<span className="inline-block size-2 rounded-full bg-selected" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
{showUnsavedDot && (
|
|
|
|
|
|
<span className="inline-block size-2 rounded-full bg-danger" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
[sectionStatusByKey, t],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-10-08 22:59:21 +03:00
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
2026-02-27 18:55:36 +03:00
|
|
|
|
<Toaster position="top-center" />
|
2025-10-08 22:59:21 +03:00
|
|
|
|
{!contentMobileOpen && (
|
2026-02-27 18:55:36 +03:00
|
|
|
|
<div
|
|
|
|
|
|
key={`mobile-menu-${selectedCamera}`}
|
|
|
|
|
|
className="flex size-full flex-col"
|
|
|
|
|
|
>
|
2025-10-08 22:59:21 +03:00
|
|
|
|
<div className="sticky -top-2 z-50 mb-2 bg-background p-4">
|
2026-02-27 18:55:36 +03:00
|
|
|
|
<div className="relative flex w-full items-center justify-center">
|
2025-10-08 22:59:21 +03:00
|
|
|
|
<Logo className="h-8" />
|
2026-02-27 18:55:36 +03:00
|
|
|
|
<div className="absolute right-0">
|
|
|
|
|
|
<CameraSelectButton
|
|
|
|
|
|
allCameras={cameras}
|
|
|
|
|
|
selectedCamera={selectedCamera}
|
|
|
|
|
|
setSelectedCamera={setSelectedCamera}
|
|
|
|
|
|
cameraEnabledStates={cameraEnabledStates}
|
|
|
|
|
|
currentPage={page}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-10-08 22:59:21 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex flex-row text-center">
|
2025-10-09 15:23:03 +03:00
|
|
|
|
<h2 className="ml-2 text-lg">
|
2025-10-09 00:36:23 +03:00
|
|
|
|
{t("menu.settings", { ns: "common" })}
|
2025-10-08 22:59:21 +03: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 18:55:36 +03:00
|
|
|
|
<div>{t("menu." + group.label)}</div>
|
2025-10-08 22:59:21 +03:00
|
|
|
|
</h3>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{filteredItems.map((item) => (
|
|
|
|
|
|
<MobileMenuItem
|
|
|
|
|
|
key={item.key}
|
|
|
|
|
|
item={item}
|
|
|
|
|
|
className={cn(filteredItems.length == 1 && "pl-2")}
|
2026-02-27 18:55:36 +03:00
|
|
|
|
label={renderMenuItemLabel(item.key as SettingsType)}
|
2025-10-08 22:59:21 +03:00
|
|
|
|
onSelect={(key) => {
|
|
|
|
|
|
if (
|
|
|
|
|
|
!isAdmin &&
|
2025-10-13 19:52:08 +03:00
|
|
|
|
!ALLOWED_VIEWS_FOR_VIEWER.includes(
|
|
|
|
|
|
key as SettingsType,
|
|
|
|
|
|
)
|
2025-10-08 22:59:21 +03:00
|
|
|
|
) {
|
2026-02-27 18:55:36 +03:00
|
|
|
|
setPageToggle("profileSettings");
|
2025-10-08 22:59:21 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
setPageToggle(key as SettingsType);
|
|
|
|
|
|
}
|
|
|
|
|
|
setContentMobileOpen(true);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
2026-02-27 18:55:36 +03:00
|
|
|
|
{hasPendingChanges && (
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<span className="text-sm text-danger">
|
|
|
|
|
|
{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"
|
|
|
|
|
|
disabled={isSavingAll}
|
|
|
|
|
|
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"
|
|
|
|
|
|
disabled={isSavingAll || hasPendingValidationErrors}
|
|
|
|
|
|
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>
|
|
|
|
|
|
)}
|
2024-04-19 20:17:23 +03:00
|
|
|
|
</div>
|
2025-10-08 22:59:21 +03:00
|
|
|
|
)}
|
|
|
|
|
|
<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 18:55:36 +03:00
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
{CAMERA_SELECT_BUTTON_PAGES.includes(pageToggle) && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{pageToggle == "masksAndZones" && (
|
|
|
|
|
|
<ZoneMaskFilterButton
|
|
|
|
|
|
selectedZoneMask={filterZoneMask}
|
|
|
|
|
|
updateZoneMaskFilter={setFilterZoneMask}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<CameraSelectButton
|
|
|
|
|
|
allCameras={cameras}
|
|
|
|
|
|
selectedCamera={selectedCamera}
|
|
|
|
|
|
setSelectedCamera={setSelectedCamera}
|
|
|
|
|
|
cameraEnabledStates={cameraEnabledStates}
|
|
|
|
|
|
currentPage={page}
|
2025-10-08 22:59:21 +03:00
|
|
|
|
/>
|
2026-02-27 18:55:36 +03:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-10-08 22:59:21 +03: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 18:55:36 +03:00
|
|
|
|
onSectionStatusChange={handleSectionStatusChange}
|
|
|
|
|
|
pendingDataBySection={pendingDataBySection}
|
|
|
|
|
|
onPendingDataChange={handlePendingDataChange}
|
2025-10-08 22:59:21 +03: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 18:55:36 +03:00
|
|
|
|
<RestartDialog
|
|
|
|
|
|
isOpen={restartDialogOpen}
|
|
|
|
|
|
onClose={() => setRestartDialogOpen(false)}
|
|
|
|
|
|
onRestart={() => sendRestart("restart")}
|
|
|
|
|
|
/>
|
2025-10-08 22:59:21 +03:00
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex h-full flex-col">
|
2026-02-27 18:55:36 +03:00
|
|
|
|
<Toaster position="top-center" />
|
2025-12-15 21:42:11 +03:00
|
|
|
|
<div className="flex min-h-16 items-center justify-between border-b border-secondary p-3">
|
|
|
|
|
|
<Heading as="h3" className="mb-0">
|
2025-10-09 00:36:23 +03:00
|
|
|
|
{t("menu.settings", { ns: "common" })}
|
2025-10-08 22:59:21 +03:00
|
|
|
|
</Heading>
|
2026-02-27 18:55:36 +03:00
|
|
|
|
<div className="flex items-center gap-5">
|
|
|
|
|
|
{hasPendingChanges && (
|
|
|
|
|
|
<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"
|
2024-04-19 14:34:07 +03:00
|
|
|
|
/>
|
2026-02-27 18:55:36 +03:00
|
|
|
|
<Button
|
|
|
|
|
|
onClick={handleUndoAll}
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
disabled={isSavingAll}
|
|
|
|
|
|
className="flex items-center justify-center gap-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("button.undoAll", {
|
|
|
|
|
|
ns: "common",
|
|
|
|
|
|
defaultValue: "Undo All",
|
|
|
|
|
|
})}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="select"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={handleSaveAll}
|
|
|
|
|
|
disabled={isSavingAll || hasPendingValidationErrors}
|
|
|
|
|
|
className="flex items-center justify-center gap-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
{isSavingAll ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<ActivityIndicator className="mr-2" />
|
|
|
|
|
|
{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}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<CameraSelectButton
|
|
|
|
|
|
allCameras={cameras}
|
|
|
|
|
|
selectedCamera={selectedCamera}
|
|
|
|
|
|
setSelectedCamera={setSelectedCamera}
|
|
|
|
|
|
cameraEnabledStates={cameraEnabledStates}
|
|
|
|
|
|
currentPage={page}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2024-04-19 14:34:07 +03:00
|
|
|
|
</div>
|
2025-10-09 00:02:38 +03:00
|
|
|
|
<SidebarProvider>
|
|
|
|
|
|
<Sidebar variant="inset" className="relative mb-8 pl-0 pt-0">
|
2025-10-12 20:23:17 +03:00
|
|
|
|
<SidebarContent className="scrollbar-container mb-24 overflow-y-auto border-r-[1px] border-secondary bg-background py-2">
|
2025-10-08 22:59:21 +03: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
|
2025-10-09 00:02:38 +03:00
|
|
|
|
className="ml-0"
|
2025-10-08 22:59:21 +03:00
|
|
|
|
isActive={pageToggle === filteredItems[0].key}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
if (
|
|
|
|
|
|
!isAdmin &&
|
2025-10-13 19:52:08 +03:00
|
|
|
|
!ALLOWED_VIEWS_FOR_VIEWER.includes(
|
2025-10-08 22:59:21 +03:00
|
|
|
|
filteredItems[0].key as SettingsType,
|
|
|
|
|
|
)
|
|
|
|
|
|
) {
|
2026-02-27 18:55:36 +03:00
|
|
|
|
setPageToggle("profileSettings");
|
2025-10-08 22:59:21 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
setPageToggle(
|
|
|
|
|
|
filteredItems[0].key as SettingsType,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-02-27 18:55:36 +03:00
|
|
|
|
{renderMenuItemLabel(
|
|
|
|
|
|
filteredItems[0].key as SettingsType,
|
|
|
|
|
|
)}
|
2025-10-08 22:59:21 +03:00
|
|
|
|
</SidebarMenuButton>
|
|
|
|
|
|
</SidebarMenuItem>
|
|
|
|
|
|
</SidebarMenu>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<SidebarGroupLabel
|
|
|
|
|
|
className={cn(
|
2025-10-09 00:02:38 +03:00
|
|
|
|
"ml-2 cursor-default pl-0 text-sm",
|
2025-10-08 22:59:21 +03:00
|
|
|
|
filteredItems.some(
|
|
|
|
|
|
(item) => pageToggle === item.key,
|
|
|
|
|
|
)
|
|
|
|
|
|
? "text-primary"
|
|
|
|
|
|
: "text-sidebar-foreground/80",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-02-27 18:55:36 +03:00
|
|
|
|
<div>{t("menu." + group.label)}</div>
|
2025-10-08 22:59:21 +03:00
|
|
|
|
</SidebarGroupLabel>
|
2025-10-09 00:02:38 +03:00
|
|
|
|
<SidebarMenuSub className="mx-2 border-0">
|
2025-10-08 22:59:21 +03:00
|
|
|
|
{filteredItems.map((item) => (
|
|
|
|
|
|
<SidebarMenuSubItem key={item.key}>
|
|
|
|
|
|
<SidebarMenuSubButton
|
|
|
|
|
|
isActive={pageToggle === item.key}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
if (
|
|
|
|
|
|
!isAdmin &&
|
2025-10-13 19:52:08 +03:00
|
|
|
|
!ALLOWED_VIEWS_FOR_VIEWER.includes(
|
2025-10-08 22:59:21 +03:00
|
|
|
|
item.key as SettingsType,
|
|
|
|
|
|
)
|
|
|
|
|
|
) {
|
2026-02-27 18:55:36 +03:00
|
|
|
|
setPageToggle("profileSettings");
|
2025-10-08 22:59:21 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
setPageToggle(item.key as SettingsType);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-02-27 18:55:36 +03:00
|
|
|
|
<div className="w-full cursor-pointer">
|
|
|
|
|
|
{renderMenuItemLabel(
|
|
|
|
|
|
item.key as SettingsType,
|
|
|
|
|
|
)}
|
2025-10-08 22:59:21 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
</SidebarMenuSubButton>
|
|
|
|
|
|
</SidebarMenuSubItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SidebarMenuSub>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</SidebarGroup>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</SidebarMenu>
|
|
|
|
|
|
</SidebarContent>
|
|
|
|
|
|
</Sidebar>
|
|
|
|
|
|
<SidebarInset>
|
2026-02-27 18:55:36 +03:00
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"scrollbar-container mb-16 flex-1 overflow-y-auto p-2 pr-0",
|
|
|
|
|
|
LARGE_BOTTOM_MARGIN_PAGES.includes(pageToggle) && "mb-24",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
2025-10-08 22:59:21 +03:00
|
|
|
|
{(() => {
|
|
|
|
|
|
const CurrentComponent = getCurrentComponent(page);
|
|
|
|
|
|
if (!CurrentComponent) return null;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<CurrentComponent
|
|
|
|
|
|
selectedCamera={selectedCamera}
|
|
|
|
|
|
setUnsavedChanges={setUnsavedChanges}
|
|
|
|
|
|
selectedZoneMask={filterZoneMask}
|
2026-02-27 18:55:36 +03:00
|
|
|
|
onSectionStatusChange={handleSectionStatusChange}
|
|
|
|
|
|
pendingDataBySection={pendingDataBySection}
|
|
|
|
|
|
onPendingDataChange={handlePendingDataChange}
|
2025-10-08 22:59:21 +03: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>
|
2025-03-24 18:19:58 +03:00
|
|
|
|
)}
|
2025-10-08 22:59:21 +03:00
|
|
|
|
</SidebarProvider>
|
2026-02-27 18:55:36 +03:00
|
|
|
|
<RestartDialog
|
|
|
|
|
|
isOpen={restartDialogOpen}
|
|
|
|
|
|
onClose={() => setRestartDialogOpen(false)}
|
|
|
|
|
|
onRestart={() => sendRestart("restart")}
|
|
|
|
|
|
/>
|
2024-04-19 14:34:07 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type CameraSelectButtonProps = {
|
|
|
|
|
|
allCameras: CameraConfig[];
|
|
|
|
|
|
selectedCamera: string;
|
|
|
|
|
|
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
|
2025-03-03 18:30:52 +03:00
|
|
|
|
cameraEnabledStates: Record<string, boolean>;
|
|
|
|
|
|
currentPage: SettingsType;
|
2024-04-19 14:34:07 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function CameraSelectButton({
|
|
|
|
|
|
allCameras,
|
|
|
|
|
|
selectedCamera,
|
|
|
|
|
|
setSelectedCamera,
|
2025-03-03 18:30:52 +03:00
|
|
|
|
cameraEnabledStates,
|
|
|
|
|
|
currentPage,
|
2024-04-19 14:34:07 +03:00
|
|
|
|
}: CameraSelectButtonProps) {
|
2025-03-16 18:36:20 +03:00
|
|
|
|
const { t } = useTranslation(["views/settings"]);
|
|
|
|
|
|
|
2024-04-19 14:34:07 +03:00
|
|
|
|
const [open, setOpen] = useState(false);
|
2023-12-08 16:33:22 +03:00
|
|
|
|
|
2024-04-19 14:34:07 +03:00
|
|
|
|
if (!allCameras.length) {
|
2025-03-03 18:30:52 +03:00
|
|
|
|
return null;
|
2024-04-19 14:34:07 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const trigger = (
|
|
|
|
|
|
<Button
|
2025-04-23 01:21:09 +03:00
|
|
|
|
className="flex items-center gap-2 bg-selected smart-capitalize hover:bg-selected"
|
2024-10-23 01:07:42 +03:00
|
|
|
|
aria-label="Select a camera"
|
2024-04-19 14:34:07 +03:00
|
|
|
|
size="sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<FaVideo className="text-background dark:text-primary" />
|
2024-05-14 18:06:44 +03:00
|
|
|
|
<div className="hidden text-background dark:text-primary md:block">
|
2025-08-26 20:15:01 +03:00
|
|
|
|
{selectedCamera == undefined ? (
|
|
|
|
|
|
t("cameraSetting.noCamera")
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<CameraNameLabel camera={selectedCamera} />
|
|
|
|
|
|
)}
|
2024-04-19 14:34:07 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
);
|
|
|
|
|
|
const content = (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{isMobile && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<DropdownMenuLabel className="flex justify-center">
|
2025-03-16 18:36:20 +03:00
|
|
|
|
{t("cameraSetting.camera")}
|
2024-04-19 14:34:07 +03:00
|
|
|
|
</DropdownMenuLabel>
|
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2024-06-03 21:43:30 +03:00
|
|
|
|
<div className="scrollbar-container mb-5 h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden p-4 md:mb-1">
|
2024-04-19 14:34:07 +03:00
|
|
|
|
<div className="flex flex-col gap-2.5">
|
2025-03-03 18:30:52 +03:00
|
|
|
|
{allCameras.map((item) => {
|
|
|
|
|
|
const isEnabled = cameraEnabledStates[item.name];
|
2025-10-13 19:52:08 +03:00
|
|
|
|
const isCameraSettingsPage = currentPage === "cameraReview";
|
2025-03-03 18:30:52 +03:00
|
|
|
|
return (
|
|
|
|
|
|
<FilterSwitch
|
|
|
|
|
|
key={item.name}
|
|
|
|
|
|
isChecked={item.name === selectedCamera}
|
2025-08-26 20:15:01 +03:00
|
|
|
|
label={item.name}
|
2025-11-07 17:02:06 +03:00
|
|
|
|
type={"camera"}
|
2025-03-03 18:30:52 +03:00
|
|
|
|
onCheckedChange={(isChecked) => {
|
|
|
|
|
|
if (isChecked && (isEnabled || isCameraSettingsPage)) {
|
|
|
|
|
|
setSelectedCamera(item.name);
|
|
|
|
|
|
setOpen(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
disabled={!isEnabled && !isCameraSettingsPage}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2024-04-19 14:34:07 +03:00
|
|
|
|
</div>
|
2023-12-08 16:33:22 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2024-04-19 14:34:07 +03:00
|
|
|
|
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
|
2024-05-30 16:39:14 +03:00
|
|
|
|
modal={false}
|
2024-04-19 14:34:07 +03:00
|
|
|
|
open={open}
|
|
|
|
|
|
onOpenChange={(open: boolean) => {
|
|
|
|
|
|
if (!open) {
|
|
|
|
|
|
setSelectedCamera(selectedCamera);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setOpen(open);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
|
|
|
|
|
<DropdownMenuContent>{content}</DropdownMenuContent>
|
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|