diff --git a/web/src/components/mobile/MobilePage.tsx b/web/src/components/mobile/MobilePage.tsx index 524e0839c..776831541 100644 --- a/web/src/components/mobile/MobilePage.tsx +++ b/web/src/components/mobile/MobilePage.tsx @@ -170,12 +170,14 @@ export function MobilePageContent({ interface MobilePageHeaderProps extends React.HTMLAttributes { onClose?: () => void; + actions?: React.ReactNode; } export function MobilePageHeader({ children, className, onClose, + actions, ...props }: MobilePageHeaderProps) { const { t } = useTranslation(["common"]); @@ -208,6 +210,11 @@ export function MobilePageHeader({
{children}
+ {actions && ( +
+ {actions} +
+ )} ); } diff --git a/web/src/components/ui/sidebar.tsx b/web/src/components/ui/sidebar.tsx index 3cc345f7a..d5990a5e9 100644 --- a/web/src/components/ui/sidebar.tsx +++ b/web/src/components/ui/sidebar.tsx @@ -338,7 +338,7 @@ const SidebarInset = React.forwardRef< ref={ref} className={cn( "relative flex w-full flex-1 flex-col bg-background", - "md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow", + "md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:mb-2 md:peer-data-[variant=inset]:ml-0", className, )} {...props} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 53b20eab8..37627c995 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -15,22 +15,18 @@ 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"; import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; import UsersView from "@/views/settings/UsersView"; @@ -40,14 +36,36 @@ 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 { useSearchParams } from "react-router-dom"; +import { useNavigate, 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 { 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", @@ -64,11 +82,87 @@ const allSettingsViews = [ ] 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: "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, + 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 tabsRef = useRef(null); + const [contentMobileOpen, setContentMobileOpen] = useState(false); const { data: config } = useSWR("config"); @@ -91,6 +185,8 @@ export default function Settings() { const [unsavedChanges, setUnsavedChanges] = useState(false); const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); + const navigate = useNavigate(); + const cameras = useMemo(() => { if (!config) { return []; @@ -144,7 +240,10 @@ export default function Settings() { const firstEnabledCamera = cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0]; setSelectedCamera(firstEnabledCamera.name); - } else if (!cameraEnabledStates[selectedCamera] && page !== "cameras") { + } 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]; @@ -153,30 +252,15 @@ 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]); + }, [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)) { - setPage("ui"); + setPageToggle("ui"); } else { - setPage(page as SettingsType); + setPageToggle(page as SettingsType); } } // don't clear url params if we're creating a new object mask @@ -193,55 +277,162 @@ export default function Settings() { }); useEffect(() => { - document.title = t("documentTitle.default"); - }, [t]); + if (!contentMobileOpen) { + document.title = t("documentTitle.default"); + } + }, [t, contentMobileOpen]); + + if (isMobile) { + return ( + <> + {!contentMobileOpen && ( +
+
+
+ +
+
+

+ {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 ( +
+ {filteredItems.length > 1 && ( +

+ {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 ( -
-
- -
- { - 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") && ( -
- {page == "masksAndZones" && ( +
+
+ + {t("settings", { ns: "common" })} + + {[ + "debug", + "cameras", + "masksAndZones", + "motionTuner", + "triggers", + ].includes(page) && ( +
+ {pageToggle == "masksAndZones" && ( )}
-
- {page == "ui" && } - {page == "enrichments" && ( - + + + + + {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", + )} + > + {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" })} + + + + )} - {page == "debug" && ( - - )} - {page == "cameras" && ( - - )} - {page == "masksAndZones" && ( - - )} - {page == "motionTuner" && ( - - )} - {page === "triggers" && ( - - )} - {page == "users" && } - {page == "roles" && } - {page == "notifications" && ( - - )} - {page == "frigateplus" && ( - - )} -
- {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" })} - - - - - )} +
); } diff --git a/web/src/views/settings/AuthenticationView.tsx b/web/src/views/settings/AuthenticationView.tsx index 161da0f81..94f1562d7 100644 --- a/web/src/views/settings/AuthenticationView.tsx +++ b/web/src/views/settings/AuthenticationView.tsx @@ -405,9 +405,9 @@ export default function AuthenticationView({ // Users section const UsersSection = ( <> -
+
- + {t("users.management.title")}

@@ -425,7 +425,7 @@ export default function AuthenticationView({

-
+
@@ -594,9 +594,9 @@ export default function AuthenticationView({ // Roles section const RolesSection = ( <> -
+
- + {t("roles.management.title")}

@@ -614,7 +614,7 @@ export default function AuthenticationView({

-
+
@@ -784,7 +784,7 @@ export default function AuthenticationView({ return (
-
+
{section === "users" && UsersSection} {section === "roles" && RolesSection} {!section && ( diff --git a/web/src/views/settings/CameraSettingsView.tsx b/web/src/views/settings/CameraSettingsView.tsx index cc30806e5..7d0c7e4e1 100644 --- a/web/src/views/settings/CameraSettingsView.tsx +++ b/web/src/views/settings/CameraSettingsView.tsx @@ -313,10 +313,10 @@ export default function CameraSettingsView({ <>
-
+
{viewMode === "settings" ? ( <> - + {t("camera.title")}
diff --git a/web/src/views/settings/EnrichmentsSettingsView.tsx b/web/src/views/settings/EnrichmentsSettingsView.tsx index a2a835969..e3b0626b9 100644 --- a/web/src/views/settings/EnrichmentsSettingsView.tsx +++ b/web/src/views/settings/EnrichmentsSettingsView.tsx @@ -244,8 +244,8 @@ export default function EnrichmentsSettingsView({ return (
-
- +
+ {t("enrichments.title")} diff --git a/web/src/views/settings/FrigatePlusSettingsView.tsx b/web/src/views/settings/FrigatePlusSettingsView.tsx index 658370fbb..20e248070 100644 --- a/web/src/views/settings/FrigatePlusSettingsView.tsx +++ b/web/src/views/settings/FrigatePlusSettingsView.tsx @@ -211,8 +211,8 @@ export default function FrigatePlusSettingsView({ <>
-
- +
+ {t("frigatePlus.title")} diff --git a/web/src/views/settings/MasksAndZonesView.tsx b/web/src/views/settings/MasksAndZonesView.tsx index 320fe0994..5bba0c3a0 100644 --- a/web/src/views/settings/MasksAndZonesView.tsx +++ b/web/src/views/settings/MasksAndZonesView.tsx @@ -433,7 +433,7 @@ export default function MasksAndZonesView({ {cameraConfig && editingPolygons && (
-
+
{editPane == "zone" && ( - + {t("menu.masksAndZones")}
diff --git a/web/src/views/settings/MotionTunerView.tsx b/web/src/views/settings/MotionTunerView.tsx index 152de774f..dc356b6ab 100644 --- a/web/src/views/settings/MotionTunerView.tsx +++ b/web/src/views/settings/MotionTunerView.tsx @@ -191,8 +191,8 @@ export default function MotionTunerView({ return (
-
- +
+ {t("motionDetectionTuner.title")}
diff --git a/web/src/views/settings/NotificationsSettingsView.tsx b/web/src/views/settings/NotificationsSettingsView.tsx index 37e555dfa..685b624a8 100644 --- a/web/src/views/settings/NotificationsSettingsView.tsx +++ b/web/src/views/settings/NotificationsSettingsView.tsx @@ -331,10 +331,10 @@ export default function NotificationView({ if (!("Notification" in window) || !window.isSecureContext) { return ( -
+
- + {t("notification.notificationSettings.title")}
@@ -385,14 +385,14 @@ export default function NotificationView({ <>
-
+
- + {t("notification.notificationSettings.title")} diff --git a/web/src/views/settings/ObjectSettingsView.tsx b/web/src/views/settings/ObjectSettingsView.tsx index b5b08adcc..169e50e20 100644 --- a/web/src/views/settings/ObjectSettingsView.tsx +++ b/web/src/views/settings/ObjectSettingsView.tsx @@ -164,8 +164,8 @@ export default function ObjectSettingsView({ return (
-
- +
+ {t("debug.title")}
diff --git a/web/src/views/settings/TriggerView.tsx b/web/src/views/settings/TriggerView.tsx index 109fd0ff8..7f262e4b2 100644 --- a/web/src/views/settings/TriggerView.tsx +++ b/web/src/views/settings/TriggerView.tsx @@ -414,11 +414,11 @@ export default function TriggerView({ return (
-
+
{!isSemanticSearchEnabled ? (
- + {t("triggers.management.title")}

@@ -452,7 +452,7 @@ export default function TriggerView({ <>

- + {t("triggers.management.title")}

diff --git a/web/src/views/settings/UiSettingsView.tsx b/web/src/views/settings/UiSettingsView.tsx index c5b273027..432a989a7 100644 --- a/web/src/views/settings/UiSettingsView.tsx +++ b/web/src/views/settings/UiSettingsView.tsx @@ -100,8 +100,8 @@ export default function UiSettingsView() { <>

-
- +
+ {t("general.title")} diff --git a/web/tailwind.config.cjs b/web/tailwind.config.cjs index 0ba6592f3..33d403e4f 100644 --- a/web/tailwind.config.cjs +++ b/web/tailwind.config.cjs @@ -119,12 +119,12 @@ module.exports = { DEFAULT: "hsl(var(--neutral_variant))", }, sidebar: { - DEFAULT: "hsl(var(--secondary))", + DEFAULT: "hsl(var(--background))", foreground: "hsl(var(--secondary-foreground))", primary: "hsl(var(--primary))", "primary-foreground": "hsl(var(--primary-foreground))", - accent: "hsl(var(--primary-variant))", - "accent-foreground": "hsl(var(--primary-foreground))", + accent: "hsl(var(--background-alt))", + "accent-foreground": "hsl(var(--primary))", border: "hsl(var(--border))", ring: "hsl(var(--ring))", },