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";
|
2025-10-08 22:59:21 +03:00
|
|
|
|
import { useCallback, useEffect, useMemo, useState } 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";
|
|
|
|
|
|
import useSWR from "swr";
|
|
|
|
|
|
import FilterSwitch from "@/components/filter/FilterSwitch";
|
|
|
|
|
|
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
|
|
|
|
|
import { PolygonType } from "@/types/canvas";
|
2024-07-12 16:42:53 +03:00
|
|
|
|
import CameraSettingsView from "@/views/settings/CameraSettingsView";
|
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-07-22 23:39:15 +03:00
|
|
|
|
import NotificationView from "@/views/settings/NotificationsSettingsView";
|
2025-05-23 03:16:35 +03:00
|
|
|
|
import EnrichmentsSettingsView from "@/views/settings/EnrichmentsSettingsView";
|
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";
|
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";
|
2025-07-07 17:03:57 +03:00
|
|
|
|
import TriggerView from "@/views/settings/TriggerView";
|
2025-08-26 20:15:01 +03:00
|
|
|
|
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
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";
|
2024-07-22 23:39:15 +03:00
|
|
|
|
|
|
|
|
|
|
const allSettingsViews = [
|
2025-03-21 20:47:32 +03:00
|
|
|
|
"ui",
|
2025-05-23 03:16:35 +03:00
|
|
|
|
"enrichments",
|
2025-03-21 20:47:32 +03:00
|
|
|
|
"cameras",
|
2025-03-16 18:36:20 +03:00
|
|
|
|
"masksAndZones",
|
|
|
|
|
|
"motionTuner",
|
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",
|
2024-07-22 23:39:15 +03:00
|
|
|
|
] as const;
|
|
|
|
|
|
type SettingsType = (typeof allSettingsViews)[number];
|
2024-04-19 14:34:07 +03:00
|
|
|
|
|
2025-10-08 22:59:21 +03:00
|
|
|
|
const settingsGroups = [
|
|
|
|
|
|
{
|
2025-10-09 00:36:23 +03:00
|
|
|
|
label: "general",
|
2025-10-08 22:59:21 +03:00
|
|
|
|
items: [{ key: "ui", component: UiSettingsView }],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2025-10-09 00:36:23 +03:00
|
|
|
|
label: "cameras",
|
2025-10-08 22:59:21 +03:00
|
|
|
|
items: [
|
|
|
|
|
|
{ key: "cameras", component: CameraSettingsView },
|
|
|
|
|
|
{ key: "masksAndZones", component: MasksAndZonesView },
|
|
|
|
|
|
{ key: "motionTuner", component: MotionTunerView },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2025-10-09 00:36:23 +03:00
|
|
|
|
label: "enrichments",
|
2025-10-08 22:59:21 +03:00
|
|
|
|
items: [{ key: "enrichments", component: EnrichmentsSettingsView }],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
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",
|
2025-10-08 22:59:21 +03:00
|
|
|
|
items: [
|
|
|
|
|
|
{ key: "notifications", component: NotificationView },
|
|
|
|
|
|
{ key: "triggers", component: TriggerView },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2025-10-09 00:36:23 +03:00
|
|
|
|
label: "frigateplus",
|
2025-10-08 22:59:21 +03:00
|
|
|
|
items: [{ key: "frigateplus", component: FrigatePlusSettingsView }],
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
item: { key: string };
|
|
|
|
|
|
onSelect: (key: string) => void;
|
|
|
|
|
|
onClose?: () => void;
|
|
|
|
|
|
className?: string;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const { t } = useTranslation(["views/settings"]);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
className={cn("w-full justify-between pr-2", className)}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
onSelect(item.key);
|
|
|
|
|
|
onClose?.();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="smart-capitalize">{t("menu." + item.key)}</div>
|
|
|
|
|
|
<LuChevronRight className="size-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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"]);
|
2025-03-21 20:47:32 +03:00
|
|
|
|
const [page, setPage] = useState<SettingsType>("ui");
|
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);
|
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();
|
|
|
|
|
|
|
2025-08-20 03:29:11 +03:00
|
|
|
|
const allowedViewsForViewer: SettingsType[] = [
|
|
|
|
|
|
"ui",
|
|
|
|
|
|
"debug",
|
|
|
|
|
|
"notifications",
|
|
|
|
|
|
];
|
2025-03-08 19:01:08 +03:00
|
|
|
|
const visibleSettingsViews = !isAdmin
|
|
|
|
|
|
? allowedViewsForViewer
|
|
|
|
|
|
: allSettingsViews;
|
|
|
|
|
|
|
2024-04-19 14:34:07 +03:00
|
|
|
|
// TODO: confirm leave page
|
|
|
|
|
|
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
|
|
|
|
|
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
|
|
|
|
|
|
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>("");
|
|
|
|
|
|
|
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[]>();
|
|
|
|
|
|
|
|
|
|
|
|
const handleDialog = useCallback(
|
|
|
|
|
|
(save: boolean) => {
|
|
|
|
|
|
if (unsavedChanges && save) {
|
|
|
|
|
|
// TODO
|
|
|
|
|
|
}
|
|
|
|
|
|
setConfirmationDialogOpen(false);
|
|
|
|
|
|
setUnsavedChanges(false);
|
|
|
|
|
|
},
|
|
|
|
|
|
[unsavedChanges],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
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] &&
|
|
|
|
|
|
pageToggle !== "cameras"
|
|
|
|
|
|
) {
|
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-08-20 03:29:11 +03:00
|
|
|
|
if (!isAdmin && !allowedViewsForViewer.includes(page as SettingsType)) {
|
2025-10-08 22:59:21 +03:00
|
|
|
|
setPageToggle("ui");
|
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-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-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
|
|
|
|
|
2025-10-08 22:59:21 +03:00
|
|
|
|
if (isMobile) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{!contentMobileOpen && (
|
|
|
|
|
|
<div className="flex size-full flex-col">
|
|
|
|
|
|
<div className="sticky -top-2 z-50 mb-2 bg-background p-4">
|
|
|
|
|
|
<div className="flex items-center justify-center">
|
|
|
|
|
|
<Logo className="h-8" />
|
|
|
|
|
|
</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">
|
2025-10-09 00:36:23 +03:00
|
|
|
|
<div className="smart-capitalize">
|
|
|
|
|
|
{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")}
|
|
|
|
|
|
onSelect={(key) => {
|
|
|
|
|
|
if (
|
|
|
|
|
|
!isAdmin &&
|
|
|
|
|
|
!allowedViewsForViewer.includes(key as SettingsType)
|
|
|
|
|
|
) {
|
|
|
|
|
|
setPageToggle("ui");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setPageToggle(key as SettingsType);
|
|
|
|
|
|
}
|
|
|
|
|
|
setContentMobileOpen(true);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</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={
|
|
|
|
|
|
[
|
|
|
|
|
|
"debug",
|
|
|
|
|
|
"cameras",
|
|
|
|
|
|
"masksAndZones",
|
|
|
|
|
|
"motionTuner",
|
|
|
|
|
|
"triggers",
|
|
|
|
|
|
].includes(pageToggle) ? (
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
{pageToggle == "masksAndZones" && (
|
|
|
|
|
|
<ZoneMaskFilterButton
|
|
|
|
|
|
selectedZoneMask={filterZoneMask}
|
|
|
|
|
|
updateZoneMaskFilter={setFilterZoneMask}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<CameraSelectButton
|
|
|
|
|
|
allCameras={cameras}
|
|
|
|
|
|
selectedCamera={selectedCamera}
|
|
|
|
|
|
setSelectedCamera={setSelectedCamera}
|
|
|
|
|
|
cameraEnabledStates={cameraEnabledStates}
|
|
|
|
|
|
currentPage={page}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<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}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
})()}
|
|
|
|
|
|
</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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex h-full flex-col">
|
|
|
|
|
|
<div className="flex 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>
|
|
|
|
|
|
{[
|
|
|
|
|
|
"debug",
|
|
|
|
|
|
"cameras",
|
|
|
|
|
|
"masksAndZones",
|
|
|
|
|
|
"motionTuner",
|
|
|
|
|
|
"triggers",
|
|
|
|
|
|
].includes(page) && (
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
{pageToggle == "masksAndZones" && (
|
2024-04-19 14:34:07 +03:00
|
|
|
|
<ZoneMaskFilterButton
|
|
|
|
|
|
selectedZoneMask={filterZoneMask}
|
|
|
|
|
|
updateZoneMaskFilter={setFilterZoneMask}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<CameraSelectButton
|
|
|
|
|
|
allCameras={cameras}
|
|
|
|
|
|
selectedCamera={selectedCamera}
|
|
|
|
|
|
setSelectedCamera={setSelectedCamera}
|
2025-03-03 18:30:52 +03:00
|
|
|
|
cameraEnabledStates={cameraEnabledStates}
|
|
|
|
|
|
currentPage={page}
|
2024-04-19 14:34:07 +03:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-10-09 00:02:38 +03:00
|
|
|
|
<SidebarProvider>
|
|
|
|
|
|
<Sidebar variant="inset" className="relative mb-8 pl-0 pt-0">
|
|
|
|
|
|
<SidebarContent className="scrollbar-container mb-20 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 &&
|
|
|
|
|
|
!allowedViewsForViewer.includes(
|
|
|
|
|
|
filteredItems[0].key as SettingsType,
|
|
|
|
|
|
)
|
|
|
|
|
|
) {
|
|
|
|
|
|
setPageToggle("ui");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setPageToggle(
|
|
|
|
|
|
filteredItems[0].key as SettingsType,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="smart-capitalize">
|
|
|
|
|
|
{t("menu." + filteredItems[0].key)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</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",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
2025-10-09 00:36:23 +03:00
|
|
|
|
<div className="smart-capitalize">
|
|
|
|
|
|
{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 &&
|
|
|
|
|
|
!allowedViewsForViewer.includes(
|
|
|
|
|
|
item.key as SettingsType,
|
|
|
|
|
|
)
|
|
|
|
|
|
) {
|
|
|
|
|
|
setPageToggle("ui");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setPageToggle(item.key as SettingsType);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="w-full cursor-pointer smart-capitalize">
|
|
|
|
|
|
{t("menu." + item.key)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SidebarMenuSubButton>
|
|
|
|
|
|
</SidebarMenuSubItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SidebarMenuSub>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</SidebarGroup>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</SidebarMenu>
|
|
|
|
|
|
</SidebarContent>
|
|
|
|
|
|
</Sidebar>
|
|
|
|
|
|
<SidebarInset>
|
|
|
|
|
|
<div className="flex-1 overflow-auto p-2 pr-0">
|
|
|
|
|
|
{(() => {
|
|
|
|
|
|
const CurrentComponent = getCurrentComponent(page);
|
|
|
|
|
|
if (!CurrentComponent) return null;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<CurrentComponent
|
|
|
|
|
|
selectedCamera={selectedCamera}
|
|
|
|
|
|
setUnsavedChanges={setUnsavedChanges}
|
|
|
|
|
|
selectedZoneMask={filterZoneMask}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
})()}
|
|
|
|
|
|
</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>
|
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-03-21 20:47:32 +03:00
|
|
|
|
const isCameraSettingsPage = currentPage === "cameras";
|
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}
|
|
|
|
|
|
isCameraName={true}
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|