import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import { Button } from "@/components/ui/button"; import { useCallback, useEffect, useMemo, useState } from "react"; import useOptimisticState from "@/hooks/use-optimistic-state"; import { isMobile } from "react-device-detect"; import { FaVideo } from "react-icons/fa"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; import FilterSwitch from "@/components/filter/FilterSwitch"; import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter"; import { PolygonType } from "@/types/canvas"; import CameraSettingsView from "@/views/settings/CameraSettingsView"; import MotionTunerView from "@/views/settings/MotionTunerView"; import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; import UsersView from "@/views/settings/UsersView"; import RolesView from "@/views/settings/RolesView"; import NotificationView from "@/views/settings/NotificationsSettingsView"; import EnrichmentsSettingsView from "@/views/settings/EnrichmentsSettingsView"; import UiSettingsView from "@/views/settings/UiSettingsView"; import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView"; import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useInitialCameraState } from "@/api/ws"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { useTranslation } from "react-i18next"; import TriggerView from "@/views/settings/TriggerView"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; 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"; const allSettingsViews = [ "ui", "enrichments", "cameras", "masksAndZones", "motionTuner", "triggers", "debug", "users", "roles", "notifications", "frigateplus", ] as const; type SettingsType = (typeof allSettingsViews)[number]; const settingsGroups = [ { label: "general", items: [{ key: "ui", component: UiSettingsView }], }, { label: "cameras", items: [ { key: "cameras", component: CameraSettingsView }, { key: "masksAndZones", component: MasksAndZonesView }, { key: "motionTuner", component: MotionTunerView }, ], }, { label: "enrichments", items: [{ key: "enrichments", component: EnrichmentsSettingsView }], }, { label: "users", items: [ { key: "users", component: UsersView }, { key: "roles", component: RolesView }, ], }, { label: "notifications", items: [ { key: "notifications", component: NotificationView }, { key: "triggers", component: TriggerView }, ], }, { label: "frigateplus", 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 ( ); } export default function Settings() { const { t } = useTranslation(["views/settings"]); const [page, setPage] = useState("ui"); const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); const [contentMobileOpen, setContentMobileOpen] = useState(false); const { data: config } = useSWR("config"); const [searchParams] = useSearchParams(); // auth and roles const isAdmin = useIsAdmin(); const allowedViewsForViewer: SettingsType[] = [ "ui", "debug", "notifications", ]; const visibleSettingsViews = !isAdmin ? allowedViewsForViewer : allSettingsViews; // TODO: confirm leave page const [unsavedChanges, setUnsavedChanges] = useState(false); const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); const navigate = useNavigate(); const cameras = useMemo(() => { if (!config) { return []; } return Object.values(config.cameras) .filter((conf) => conf.ui.dashboard && conf.enabled_in_config) .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); }, [config]); const [selectedCamera, setSelectedCamera] = useState(""); const { payload: allCameraStates } = useInitialCameraState( cameras.length > 0 ? cameras[0].name : "", true, ); const cameraEnabledStates = useMemo(() => { const states: Record = {}; if (allCameraStates) { Object.entries(allCameraStates).forEach(([camName, state]) => { states[camName] = state.config?.enabled ?? false; }); } // fallback to config if ws data isn’t available yet cameras.forEach((cam) => { if (!(cam.name in states)) { states[cam.name] = cam.enabled; } }); return states; }, [allCameraStates, cameras]); const [filterZoneMask, setFilterZoneMask] = useState(); const handleDialog = useCallback( (save: boolean) => { if (unsavedChanges && save) { // TODO } setConfirmationDialogOpen(false); setUnsavedChanges(false); }, [unsavedChanges], ); useEffect(() => { if (cameras.length > 0) { if (!selectedCamera) { // Set to first enabled camera initially if no selection const firstEnabledCamera = cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0]; setSelectedCamera(firstEnabledCamera.name); } else if ( !cameraEnabledStates[selectedCamera] && pageToggle !== "cameras" ) { // Switch to first enabled camera if current one is disabled, unless on "camera settings" page const firstEnabledCamera = cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0]; if (firstEnabledCamera.name !== selectedCamera) { setSelectedCamera(firstEnabledCamera.name); } } } }, [cameras, selectedCamera, cameraEnabledStates, pageToggle]); useSearchEffect("page", (page: string) => { if (allSettingsViews.includes(page as SettingsType)) { // Restrict viewer to UI settings if (!isAdmin && !allowedViewsForViewer.includes(page as SettingsType)) { setPageToggle("ui"); } else { setPageToggle(page as SettingsType); } } // don't clear url params if we're creating a new object mask return !(searchParams.has("object_mask") || searchParams.has("event_id")); }); useSearchEffect("camera", (camera: string) => { const cameraNames = cameras.map((c) => c.name); if (cameraNames.includes(camera)) { setSelectedCamera(camera); } // don't clear url params if we're creating a new object mask or trigger return !(searchParams.has("object_mask") || searchParams.has("event_id")); }); useEffect(() => { if (!contentMobileOpen) { document.title = t("documentTitle.default"); } }, [t, contentMobileOpen]); if (isMobile) { return ( <> {!contentMobileOpen && (

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

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

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

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