diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 53b20eab8..401da1fad 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -15,20 +15,17 @@ import { 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"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import useOptimisticState from "@/hooks/use-optimistic-state"; -import { isIOS, isMobile } from "react-device-detect"; +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 { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; -import scrollIntoView from "scroll-into-view-if-needed"; import CameraSettingsView from "@/views/settings/CameraSettingsView"; import ObjectSettingsView from "@/views/settings/ObjectSettingsView"; import MotionTunerView from "@/views/settings/MotionTunerView"; @@ -42,12 +39,33 @@ import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView"; import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useSearchParams } from "react-router-dom"; import { useInitialCameraState } from "@/api/ws"; -import { isInIframe } from "@/utils/isIFrame"; -import { isPWA } from "@/utils/isPWA"; 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 { + MobilePage, + MobilePageContent, + MobilePageHeader, + MobilePagePortal, + MobilePageTitle, +} from "@/components/mobile/MobilePage"; +import { ChevronRight } from "lucide-react"; +import { IoMdArrowRoundBack } from "react-icons/io"; const allSettingsViews = [ "ui", @@ -64,11 +82,86 @@ const allSettingsViews = [ ] as const; type SettingsType = (typeof allSettingsViews)[number]; +const settingsGroups = [ + { + label: "General", + items: [ + { key: "ui", component: UiSettingsView }, + { key: "debug", component: ObjectSettingsView }, + ], + }, + { + label: "Cameras", + items: [ + { key: "cameras", component: CameraSettingsView }, + { key: "masksAndZones", component: MasksAndZonesView }, + { key: "motionTuner", component: MotionTunerView }, + { key: "triggers", component: TriggerView }, + ], + }, + { + 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 }], + }, + { + label: "Frigate+", + 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, +}: { + item: { key: string }; + onSelect: (key: string) => void; + onClose: () => void; +}) { + 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 tabsRef = useRef(null); + const [_, setPageToggle] = useOptimisticState(page, setPage, 100); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const { data: config } = useSWR("config"); @@ -155,21 +248,6 @@ export default function Settings() { } }, [cameras, selectedCamera, cameraEnabledStates, page]); - useEffect(() => { - if (tabsRef.current) { - const element = tabsRef.current.querySelector( - `[data-nav-item="${pageToggle}"]`, - ); - if (element instanceof HTMLElement) { - scrollIntoView(element, { - behavior: - isMobile && isIOS && !isPWA && isInIframe ? "auto" : "smooth", - inline: "start", - }); - } - } - }, [tabsRef, pageToggle]); - useSearchEffect("page", (page: string) => { if (allSettingsViews.includes(page as SettingsType)) { // Restrict viewer to UI settings @@ -196,50 +274,74 @@ export default function Settings() { document.title = t("documentTitle.default"); }, [t]); - return ( -
-
- -
- { - if (value) { - // Restrict viewer navigation - if (!isAdmin && !allowedViewsForViewer.includes(value)) { - setPageToggle("ui"); - } else { - setPageToggle(value); - } - } - }} - > - {visibleSettingsViews.map((item) => ( - -
{t("menu." + item)}
-
- ))} -
- -
-
- {(page == "debug" || - page == "cameras" || - page == "masksAndZones" || - page == "motionTuner" || - page == "triggers") && ( + if (isMobile) { + return ( +
+
+ +

+ {t("menu." + page)} +

+
+ + + + + + {t("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 ( +
+

+ {group.label} +

+ {filteredItems.map((item) => ( + { + if ( + !isAdmin && + !allowedViewsForViewer.includes( + key as SettingsType, + ) + ) { + setPageToggle("ui"); + } else { + setPageToggle(key as SettingsType); + } + }} + onClose={() => setMobileMenuOpen(false)} + /> + ))} +
+ ); + })} +
+
+
+
+ {[ + "debug", + "cameras", + "masksAndZones", + "motionTuner", + "triggers", + ].includes(page) && (
{page == "masksAndZones" && (
)} -
-
- {page == "ui" && } - {page == "enrichments" && ( - - )} - {page == "debug" && ( - - )} - {page == "cameras" && ( - - )} - {page == "masksAndZones" && ( - - )} - {page == "motionTuner" && ( - - )} - {page === "triggers" && ( - - )} - {page == "users" && } - {page == "roles" && } - {page == "notifications" && ( - - )} - {page == "frigateplus" && ( - +
+
+ {(() => { + 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 ( + + + + + {settingsGroups.map((group) => { + const filteredItems = group.items.filter((item) => + visibleSettingsViews.includes(item.key as SettingsType), + ); + if (filteredItems.length === 0) return null; + return ( + + {group.label} + {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)} +
+
+
+
+ ) : ( + + {filteredItems.map((item) => ( + + { + if ( + !isAdmin && + !allowedViewsForViewer.includes( + item.key as SettingsType, + ) + ) { + setPageToggle("ui"); + } else { + setPageToggle(item.key as SettingsType); + } + }} + > +
+ {t("menu." + item.key)} +
+
+
+ ))} +
+ )} +
+ ); + })} +
+
+
+ +
+
+

Settings

+
+ {[ + "debug", + "cameras", + "masksAndZones", + "motionTuner", + "triggers", + ].includes(page) && ( +
+ {page == "masksAndZones" && ( + + )} + +
+ )} +
+ {(() => { + const CurrentComponent = getCurrentComponent(page); + if (!CurrentComponent) return null; + return ( + + ); + })()} +
+
+
{confirmationDialogOpen && ( )} -
+ ); }