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 CameraManagementView from "@/views/settings/CameraManagementView";
import MotionTunerView from "@/views/settings/MotionTunerView";
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
import UsersView from "@/views/settings/UsersView";
import RolesView from "@/views/settings/RolesView";
import 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/FriendlyNameLabel";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
} from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
import Heading from "@/components/ui/heading";
import { LuChevronRight } from "react-icons/lu";
import Logo from "@/components/Logo";
import {
MobilePage,
MobilePageContent,
MobilePageHeader,
MobilePageTitle,
} from "@/components/mobile/MobilePage";
const allSettingsViews = [
"ui",
"enrichments",
"cameraManagement",
"cameraReview",
"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: "cameraManagement", component: CameraManagementView },
{ key: "cameraReview", 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 CAMERA_SELECT_BUTTON_PAGES = [
"debug",
"cameraReview",
"masksAndZones",
"motionTuner",
"triggers",
];
const ALLOWED_VIEWS_FOR_VIEWER = ["ui", "debug", "notifications"];
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 (
{
onSelect(item.key);
onClose?.();
}}
>
{t("menu." + item.key)}
);
}
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 visibleSettingsViews = !isAdmin
? ALLOWED_VIEWS_FOR_VIEWER
: 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 !== "cameraReview"
) {
// Switch to first enabled camera if current one is disabled, unless on "camera settings" page
const firstEnabledCamera =
cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0];
if (firstEnabledCamera.name !== selectedCamera) {
setSelectedCamera(firstEnabledCamera.name);
}
}
}
}, [cameras, selectedCamera, cameraEnabledStates, pageToggle]);
useSearchEffect("page", (page: string) => {
if (allSettingsViews.includes(page as SettingsType)) {
// Restrict viewer to UI settings
if (
!isAdmin &&
!ALLOWED_VIEWS_FOR_VIEWER.includes(page as SettingsType)
) {
setPageToggle("ui");
} else {
setPageToggle(page as SettingsType);
}
if (isMobile) {
setContentMobileOpen(true);
}
}
// don't clear url params if we're creating a new object mask
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
});
useSearchEffect("camera", (camera: string) => {
const cameraNames = cameras.map((c) => c.name);
if (cameraNames.includes(camera)) {
setSelectedCamera(camera);
if (isMobile) {
setContentMobileOpen(true);
}
}
// don't clear url params if we're creating a new object mask or trigger
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
});
useEffect(() => {
if (!contentMobileOpen) {
document.title = t("documentTitle.default");
}
}, [t, contentMobileOpen]);
if (isMobile) {
return (
<>
{!contentMobileOpen && (
{t("menu.settings", { ns: "common" })}
{settingsGroups.map((group) => {
const filteredItems = group.items.filter((item) =>
visibleSettingsViews.includes(item.key as SettingsType),
);
if (filteredItems.length === 0) return null;
return (
{filteredItems.length > 1 && (
{t("menu." + group.label)}
)}
{filteredItems.map((item) => (
{
if (
!isAdmin &&
!ALLOWED_VIEWS_FOR_VIEWER.includes(
key as SettingsType,
)
) {
setPageToggle("ui");
} else {
setPageToggle(key as SettingsType);
}
setContentMobileOpen(true);
}}
/>
))}
);
})}
)}
navigate(-1)}
actions={
CAMERA_SELECT_BUTTON_PAGES.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" })}
{CAMERA_SELECT_BUTTON_PAGES.includes(page) && (
{pageToggle == "masksAndZones" && (
)}
)}
{settingsGroups.map((group) => {
const filteredItems = group.items.filter((item) =>
visibleSettingsViews.includes(item.key as SettingsType),
);
if (filteredItems.length === 0) return null;
return (
{filteredItems.length === 1 ? (
{
if (
!isAdmin &&
!ALLOWED_VIEWS_FOR_VIEWER.includes(
filteredItems[0].key as SettingsType,
)
) {
setPageToggle("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 &&
!ALLOWED_VIEWS_FOR_VIEWER.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 === "cameraReview";
return (
{
if (isChecked && (isEnabled || isCameraSettingsPage)) {
setSelectedCamera(item.name);
setOpen(false);
}
}}
disabled={!isEnabled && !isCameraSettingsPage}
/>
);
})}
>
);
if (isMobile) {
return (
{
if (!open) {
setSelectedCamera(selectedCamera);
}
setOpen(open);
}}
>
{trigger}
{content}
);
}
return (
{
if (!open) {
setSelectedCamera(selectedCamera);
}
setOpen(open);
}}
>
{trigger}
{content}
);
}