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

427 lines
14 KiB
TypeScript
Raw Normal View History

import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
2024-04-19 12:17:23 -05:00
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useOptimisticState from "@/hooks/use-optimistic-state";
2025-03-09 08:47:10 -05:00
import { isIOS, 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";
2024-04-19 12:17:23 -05:00
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import scrollIntoView from "scroll-into-view-if-needed";
import CameraSettingsView from "@/views/settings/CameraSettingsView";
2024-05-29 08:01:39 -06:00
import ObjectSettingsView from "@/views/settings/ObjectSettingsView";
import MotionTunerView from "@/views/settings/MotionTunerView";
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
import AuthenticationView from "@/views/settings/AuthenticationView";
import NotificationView from "@/views/settings/NotificationsSettingsView";
2025-05-22 19:16:35 -05:00
import EnrichmentsSettingsView from "@/views/settings/EnrichmentsSettingsView";
2024-10-15 18:25:59 -06:00
import UiSettingsView from "@/views/settings/UiSettingsView";
2025-03-17 13:44:57 -05:00
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
2025-02-10 10:42:35 -06:00
import { useSearchEffect } from "@/hooks/use-overlay-state";
2025-02-15 07:56:45 -06:00
import { useSearchParams } from "react-router-dom";
2025-03-03 09:30:52 -06:00
import { useInitialCameraState } from "@/api/ws";
import { isInIframe } from "@/utils/isIFrame";
import { isPWA } from "@/utils/isPWA";
2025-03-08 10:01:08 -06:00
import { useIsAdmin } from "@/hooks/use-is-admin";
import { useTranslation } from "react-i18next";
const allSettingsViews = [
2025-03-21 11:47:32 -06:00
"ui",
2025-05-22 19:16:35 -05:00
"enrichments",
2025-03-21 11:47:32 -06:00
"cameras",
"masksAndZones",
"motionTuner",
"debug",
"users",
"notifications",
2025-03-17 13:44:57 -05:00
"frigateplus",
] as const;
type SettingsType = (typeof allSettingsViews)[number];
export default function Settings() {
const { t } = useTranslation(["views/settings"]);
2025-03-21 11:47:32 -06:00
const [page, setPage] = useState<SettingsType>("ui");
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
2024-04-19 12:17:23 -05:00
const tabsRef = useRef<HTMLDivElement | null>(null);
const { data: config } = useSWR<FrigateConfig>("config");
2025-02-15 07:56:45 -06:00
const [searchParams] = useSearchParams();
2025-03-08 10:01:08 -06:00
// auth and roles
const isAdmin = useIsAdmin();
const allowedViewsForViewer: SettingsType[] = [
"ui",
"debug",
"notifications",
];
2025-03-08 10:01:08 -06:00
const visibleSettingsViews = !isAdmin
? allowedViewsForViewer
: allSettingsViews;
// TODO: confirm leave page
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const cameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
2025-03-03 09:30:52 -06:00
.filter((conf) => conf.ui.dashboard && conf.enabled_in_config)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const [selectedCamera, setSelectedCamera] = useState<string>("");
2025-03-03 09:30:52 -06:00
const { payload: allCameraStates } = useInitialCameraState(
cameras.length > 0 ? cameras[0].name : "",
true,
);
const cameraEnabledStates = useMemo(() => {
const states: Record<string, boolean> = {};
if (allCameraStates) {
Object.entries(allCameraStates).forEach(([camName, state]) => {
states[camName] = state.config?.enabled ?? false;
});
}
// fallback to config if ws data isnt available yet
cameras.forEach((cam) => {
if (!(cam.name in states)) {
states[cam.name] = cam.enabled;
}
});
return states;
}, [allCameraStates, cameras]);
const [filterZoneMask, setFilterZoneMask] = useState<PolygonType[]>();
const handleDialog = useCallback(
(save: boolean) => {
if (unsavedChanges && save) {
// TODO
}
setConfirmationDialogOpen(false);
setUnsavedChanges(false);
},
[unsavedChanges],
);
useEffect(() => {
2025-03-03 09:30:52 -06:00
if (cameras.length > 0) {
if (!selectedCamera) {
// Set to first enabled camera initially if no selection
const firstEnabledCamera =
cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0];
setSelectedCamera(firstEnabledCamera.name);
2025-03-21 11:47:32 -06:00
} else if (!cameraEnabledStates[selectedCamera] && page !== "cameras") {
2025-03-03 09:30:52 -06:00
// Switch to first enabled camera if current one is disabled, unless on "camera settings" page
const firstEnabledCamera =
cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0];
if (firstEnabledCamera.name !== selectedCamera) {
setSelectedCamera(firstEnabledCamera.name);
}
}
}
2025-03-03 09:30:52 -06:00
}, [cameras, selectedCamera, cameraEnabledStates, page]);
2024-04-19 12:17:23 -05:00
useEffect(() => {
if (tabsRef.current) {
const element = tabsRef.current.querySelector(
`[data-nav-item="${pageToggle}"]`,
);
if (element instanceof HTMLElement) {
scrollIntoView(element, {
2025-03-09 08:47:10 -05:00
behavior:
isMobile && isIOS && !isPWA && isInIframe ? "auto" : "smooth",
2024-04-19 12:17:23 -05:00
inline: "start",
});
}
}
}, [tabsRef, pageToggle]);
2025-02-10 10:42:35 -06:00
useSearchEffect("page", (page: string) => {
if (allSettingsViews.includes(page as SettingsType)) {
2025-03-08 10:01:08 -06:00
// Restrict viewer to UI settings
if (!isAdmin && !allowedViewsForViewer.includes(page as SettingsType)) {
2025-03-21 11:47:32 -06:00
setPage("ui");
2025-03-08 10:01:08 -06:00
} else {
setPage(page as SettingsType);
}
2025-02-10 10:42:35 -06:00
}
2025-02-15 07:56:45 -06:00
// don't clear url params if we're creating a new object mask
return !searchParams.has("object_mask");
2025-02-10 10:42:35 -06:00
});
useSearchEffect("camera", (camera: string) => {
const cameraNames = cameras.map((c) => c.name);
if (cameraNames.includes(camera)) {
setSelectedCamera(camera);
}
2025-02-15 07:56:45 -06:00
// don't clear url params if we're creating a new object mask
return !searchParams.has("object_mask");
2025-02-10 10:42:35 -06:00
});
2024-04-27 12:02:01 -05:00
useEffect(() => {
document.title = t("documentTitle.default");
}, [t]);
2024-04-27 12:02:01 -05:00
return (
2024-05-14 10:06:44 -05:00
<div className="flex size-full flex-col p-2">
<div className="relative flex h-11 w-full items-center justify-between">
2024-04-19 12:17:23 -05:00
<ScrollArea className="w-full whitespace-nowrap">
<div ref={tabsRef} className="flex flex-row">
<ToggleGroup
2024-05-14 10:06:44 -05:00
className="*:rounded-md *:px-3 *:py-4"
2024-04-19 12:17:23 -05:00
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SettingsType) => {
if (value) {
2025-03-08 10:01:08 -06:00
// Restrict viewer navigation
if (!isAdmin && !allowedViewsForViewer.includes(value)) {
2025-03-21 11:47:32 -06:00
setPageToggle("ui");
2025-03-08 10:01:08 -06:00
} else {
setPageToggle(value);
}
2024-04-19 12:17:23 -05:00
}
}}
>
2025-03-08 10:01:08 -06:00
{visibleSettingsViews.map((item) => (
2024-04-19 12:17:23 -05:00
<ToggleGroupItem
key={item}
2025-03-21 11:47:32 -06:00
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "ui" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
2024-04-19 12:17:23 -05:00
value={item}
data-nav-item={item}
aria-label={t("selectItem", {
ns: "common",
2025-03-17 07:26:01 -05:00
item: t("menu." + item),
})}
2024-04-19 12:17:23 -05:00
>
<div className="smart-capitalize">{t("menu." + item)}</div>
2024-04-19 12:17:23 -05:00
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
2024-05-01 09:07:56 -05:00
{(page == "debug" ||
2025-03-21 11:47:32 -06:00
page == "cameras" ||
page == "masksAndZones" ||
page == "motionTuner") && (
2024-05-14 10:06:44 -05:00
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
{page == "masksAndZones" && (
<ZoneMaskFilterButton
selectedZoneMask={filterZoneMask}
updateZoneMaskFilter={setFilterZoneMask}
/>
)}
<CameraSelectButton
allCameras={cameras}
selectedCamera={selectedCamera}
setSelectedCamera={setSelectedCamera}
2025-03-03 09:30:52 -06:00
cameraEnabledStates={cameraEnabledStates}
currentPage={page}
/>
</div>
)}
</div>
2024-05-14 10:06:44 -05:00
<div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24">
2025-03-21 11:47:32 -06:00
{page == "ui" && <UiSettingsView />}
2025-05-22 19:16:35 -05:00
{page == "enrichments" && (
<EnrichmentsSettingsView setUnsavedChanges={setUnsavedChanges} />
2024-10-15 18:25:59 -06:00
)}
2024-05-29 08:01:39 -06:00
{page == "debug" && (
<ObjectSettingsView selectedCamera={selectedCamera} />
)}
2025-03-21 11:47:32 -06:00
{page == "cameras" && (
<CameraSettingsView
selectedCamera={selectedCamera}
setUnsavedChanges={setUnsavedChanges}
/>
)}
{page == "masksAndZones" && (
2024-05-29 08:01:39 -06:00
<MasksAndZonesView
selectedCamera={selectedCamera}
selectedZoneMask={filterZoneMask}
setUnsavedChanges={setUnsavedChanges}
/>
)}
{page == "motionTuner" && (
2024-05-29 08:01:39 -06:00
<MotionTunerView
selectedCamera={selectedCamera}
setUnsavedChanges={setUnsavedChanges}
/>
)}
2024-05-29 08:01:39 -06:00
{page == "users" && <AuthenticationView />}
{page == "notifications" && (
<NotificationView setUnsavedChanges={setUnsavedChanges} />
)}
{page == "frigateplus" && (
<FrigatePlusSettingsView setUnsavedChanges={setUnsavedChanges} />
)}
</div>
{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>
)}
</div>
);
}
type CameraSelectButtonProps = {
allCameras: CameraConfig[];
selectedCamera: string;
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
2025-03-03 09:30:52 -06:00
cameraEnabledStates: Record<string, boolean>;
currentPage: SettingsType;
};
function CameraSelectButton({
allCameras,
selectedCamera,
setSelectedCamera,
2025-03-03 09:30:52 -06:00
cameraEnabledStates,
currentPage,
}: CameraSelectButtonProps) {
const { t } = useTranslation(["views/settings"]);
const [open, setOpen] = useState(false);
if (!allCameras.length) {
2025-03-03 09:30:52 -06:00
return null;
}
const trigger = (
<Button
className="flex items-center gap-2 bg-selected smart-capitalize hover:bg-selected"
2024-10-22 17:07:42 -05:00
aria-label="Select a camera"
size="sm"
>
<FaVideo className="text-background dark:text-primary" />
2024-05-14 10:06:44 -05:00
<div className="hidden text-background dark:text-primary md:block">
{selectedCamera == undefined
? t("cameraSetting.noCamera")
: selectedCamera.replaceAll("_", " ")}
</div>
</Button>
);
const content = (
<>
{isMobile && (
<>
<DropdownMenuLabel className="flex justify-center">
{t("cameraSetting.camera")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<div className="scrollbar-container mb-5 h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden p-4 md:mb-1">
<div className="flex flex-col gap-2.5">
2025-03-03 09:30:52 -06:00
{allCameras.map((item) => {
const isEnabled = cameraEnabledStates[item.name];
2025-03-21 11:47:32 -06:00
const isCameraSettingsPage = currentPage === "cameras";
2025-03-03 09:30:52 -06:00
return (
<FilterSwitch
key={item.name}
isChecked={item.name === selectedCamera}
label={item.name.replaceAll("_", " ")}
onCheckedChange={(isChecked) => {
if (isChecked && (isEnabled || isCameraSettingsPage)) {
setSelectedCamera(item.name);
setOpen(false);
}
}}
disabled={!isEnabled && !isCameraSettingsPage}
/>
);
})}
</div>
</div>
</>
);
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(open: boolean) => {
if (!open) {
setSelectedCamera(selectedCamera);
}
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<DropdownMenu
2024-05-30 08:39:14 -05:00
modal={false}
open={open}
onOpenChange={(open: boolean) => {
if (!open) {
setSelectedCamera(selectedCamera);
}
setOpen(open);
}}
>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>{content}</DropdownMenuContent>
</DropdownMenu>
);
}