From b751025339d9f3b44d024808295d2939020ce0dc Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:56:11 -0500 Subject: [PATCH] Mobile UI/UX improvements (#23402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * increase camera group icon size on mobile add an animated slider when there is not enough space for all defined camera groups * change desktop and mobile edit camera groups icon to pencil and add desktop tooltip * apply safe area insets to mobile layout in PWA mode using viewport-fit=cover * adaptively size bottom bar nav targets to 48px when they fit, else compact icon size now targets the standardized 48×48px mobile touch target (Material Design 3 / Android 48dp bottom-nav minimum) --- web/index.html | 2 +- web/public/locales/en/components/camera.json | 3 + web/src/App.tsx | 4 +- .../components/filter/CameraGroupSelector.tsx | 258 ++++++++++++++++-- web/src/components/icons/LiveIcons.tsx | 6 +- web/src/components/menu/GeneralSettings.tsx | 11 +- web/src/components/mobile/MobilePage.tsx | 7 +- web/src/components/navigation/Bottombar.tsx | 89 +++++- web/src/components/navigation/NavItem.tsx | 5 +- web/src/components/ui/drawer.tsx | 7 +- web/src/views/live/LiveDashboardView.tsx | 31 ++- 11 files changed, 363 insertions(+), 60 deletions(-) diff --git a/web/index.html b/web/index.html index be6b302f5c..bb019d4263 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,7 @@ - + Frigate diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index d4f01481f3..8f32846fc1 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -8,7 +8,17 @@ import { isDesktop, isMobile } from "react-device-detect"; import useSWR from "swr"; import { MdHome } from "react-icons/md"; import { Button, buttonVariants } from "../ui/button"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { HiDotsHorizontal } from "react-icons/hi"; +import { IoClose } from "react-icons/io5"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { LuPencil, LuPlus } from "react-icons/lu"; import { @@ -56,7 +66,6 @@ import { z } from "zod"; import { Toaster } from "@/components/ui/sonner"; import { toast } from "sonner"; import ActivityIndicator from "../indicators/activity-indicator"; -import { ScrollArea, ScrollBar } from "../ui/scroll-area"; import { useUserPersistence } from "@/hooks/use-user-persistence"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { cn } from "@/lib/utils"; @@ -145,7 +154,142 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { const [addGroup, setAddGroup] = useState(false); - const Scroller = isMobile ? ScrollArea : "div"; + // mobile overflow reveal - the group strip sits left of the logo and is + // clipped (not scrollable) when there are too many groups, so render only + // the buttons that fully fit and surface a kebab next to the last visible + // one that expands a panel revealing all of them + + const [expanded, setExpanded] = useState(false); + // null => all buttons fit, render them all with no kebab; a number => only + // that many fit alongside the kebab + const [visibleCount, setVisibleCount] = useState(null); + const wrapperRef = useRef(null); + const measureRef = useRef(null); + + useLayoutEffect(() => { + if (isDesktop) { + return; + } + + const wrapper = wrapperRef.current; + const measure = measureRef.current; + + if (!wrapper || !measure) { + return; + } + + const gap = 8; // gap-2 between buttons in the strip + const wrapperGap = 4; // gap-1 between the strip and the kebab + + const compute = () => { + const buttons = Array.from(measure.children) as HTMLElement[]; + + if (buttons.length === 0) { + return; + } + + // the trailing child of the measurement row is a kebab clone + const kebab = buttons[buttons.length - 1]; + const groupButtons = buttons.slice(0, -1); + const available = wrapper.clientWidth; + const fullWidth = + groupButtons.reduce((sum, el) => sum + el.offsetWidth, 0) + + Math.max(groupButtons.length - 1, 0) * gap; + + if (fullWidth <= available) { + setVisibleCount(null); + return; + } + + const budget = available - kebab.offsetWidth - wrapperGap; + let used = 0; + let count = 0; + + for (const el of groupButtons) { + const next = (count === 0 ? 0 : gap) + el.offsetWidth; + + if (used + next <= budget) { + used += next; + count += 1; + } else { + break; + } + } + + setVisibleCount(Math.max(count, 1)); + }; + + compute(); + + const observer = new ResizeObserver(compute); + observer.observe(wrapper); + + return () => observer.disconnect(); + }, [groups, isAdmin]); + + const groupButtons = (afterSelect?: () => void) => { + const buttons = [ + , + ...groups.map(([name, config]) => ( + + )), + ]; + + if (isAdmin) { + buttons.push( + , + ); + } + + return buttons; + }; return ( <> @@ -158,12 +302,11 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { deleteGroup={deleteGroup} isAdmin={isAdmin} /> - + {isDesktop ? (
@@ -177,8 +320,8 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { aria-label={t("menu.live.allCameras", { ns: "common" })} size="xs" onClick={() => (group ? setGroup("default", true) : null)} - onMouseEnter={() => (isDesktop ? showTooltip("default") : null)} - onMouseLeave={() => (isDesktop ? showTooltip(undefined) : null)} + onMouseEnter={() => showTooltip("default")} + onMouseLeave={() => showTooltip(undefined)} > @@ -202,10 +345,8 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { aria-label={t("group.label")} size="xs" onClick={() => setGroup(name, group != "default")} - onMouseEnter={() => (isDesktop ? showTooltip(name) : null)} - onMouseLeave={() => - isDesktop ? showTooltip(undefined) : null - } + onMouseEnter={() => showTooltip(name)} + onMouseLeave={() => showTooltip(undefined)} > {config && config.icon && isValidIconName(config.icon) && ( + + + + + + {t("group.editGroups")} + + + + )} +
+ ) : ( +
+
+ {visibleCount == null + ? groupButtons() + : groupButtons().slice(0, visibleCount)} +
+ {visibleCount != null && ( )} - {isMobile && } + + {/* invisible row used only to measure natural button widths so we + can render exactly the buttons that fully fit */} +
+
+ {groupButtons()} + +
+
+ + {expanded && ( +
setExpanded(false)} + /> + )} + + {expanded && ( + +
+ {groupButtons(() => setExpanded(false))} + +
+
+ )} +
- + )} ); } diff --git a/web/src/components/icons/LiveIcons.tsx b/web/src/components/icons/LiveIcons.tsx index 9a8d58fcf9..b8ff089bba 100644 --- a/web/src/components/icons/LiveIcons.tsx +++ b/web/src/components/icons/LiveIcons.tsx @@ -6,9 +6,9 @@ export function LiveGridIcon({ layout }: LiveIconProps) { return (
-
+
@@ -16,7 +16,7 @@ export function LiveGridIcon({ layout }: LiveIconProps) { className={`w-full ${layout == "grid" ? "bg-selected" : "bg-muted-foreground"}`} />
-
+
diff --git a/web/src/components/menu/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx index 88d834d4dc..19a0f51022 100644 --- a/web/src/components/menu/GeneralSettings.tsx +++ b/web/src/components/menu/GeneralSettings.tsx @@ -82,9 +82,13 @@ import { MdCategory } from "react-icons/md"; type GeneralSettingsProps = { className?: string; + large?: boolean; }; -export default function GeneralSettings({ className }: GeneralSettingsProps) { +export default function GeneralSettings({ + className, + large, +}: GeneralSettingsProps) { const { t } = useTranslation(["common", "views/settings"]); const { getLocaleDocUrl } = useDocDomain(); const { data: profile } = useSWR("profile"); @@ -225,10 +229,13 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { isDesktop ? "cursor-pointer rounded-lg bg-secondary text-secondary-foreground hover:bg-muted" : "text-secondary-foreground", + large && "size-12", className, )} > - +
diff --git a/web/src/components/mobile/MobilePage.tsx b/web/src/components/mobile/MobilePage.tsx index ee56468cb2..b445a9f036 100644 --- a/web/src/components/mobile/MobilePage.tsx +++ b/web/src/components/mobile/MobilePage.tsx @@ -146,9 +146,10 @@ export function MobilePageContent({ (null); + const [large, setLarge] = useState(false); + + useLayoutEffect(() => { + const el = containerRef.current; + if (!el) { + return; + } + + const TARGET = 48; // standard bottom-nav touch target (px) + const MIN_GAP = 8; // minimum spacing between targets (px) + + const compute = () => { + const count = el.children.length; + if (count === 0) { + return; + } + const needed = count * TARGET + Math.max(count - 1, 0) * MIN_GAP; + setLarge(needed <= el.clientWidth); + }; + + compute(); + + const resize = new ResizeObserver(compute); + resize.observe(el); + // recompute when items are added/removed (e.g. the status alert appears) + const mutation = new MutationObserver(compute); + mutation.observe(el, { childList: true }); + + return () => { + resize.disconnect(); + mutation.disconnect(); + }; + }, [navItems]); + return (
{navItems.map((item) => ( - + ))} - - + +
); } type StatusAlertNavProps = { className?: string; + large?: boolean; }; -function StatusAlertNav({ className }: StatusAlertNavProps) { +function StatusAlertNav({ className, large }: StatusAlertNavProps) { const { t } = useTranslation(["views/system"]); const { data: initialStats } = useSWR("stats", { revalidateOnFocus: false, @@ -105,8 +158,18 @@ function StatusAlertNav({ className }: StatusAlertNavProps) { return ( -
- +
+
void; + large?: boolean; }; export default function NavItem({ @@ -34,6 +35,7 @@ export default function NavItem({ item, Icon, onClick, + large, }: NavItemProps) { const { t } = useTranslation(["common"]); if (item.enabled == false) { @@ -48,11 +50,12 @@ export default function NavItem({ cn( "flex flex-col items-center justify-center rounded-lg p-[6px]", className, + large && "size-12", variants[item.variant ?? "primary"][isActive ? "active" : "inactive"], ) } > - + ); diff --git a/web/src/components/ui/drawer.tsx b/web/src/components/ui/drawer.tsx index 220708e7e2..af73e93e9d 100644 --- a/web/src/components/ui/drawer.tsx +++ b/web/src/components/ui/drawer.tsx @@ -2,8 +2,6 @@ import * as React from "react"; import { Drawer as DrawerPrimitive } from "vaul"; import { cn } from "@/lib/utils"; -import { isPWA } from "@/utils/isPWA"; -import { isIOS } from "react-device-detect"; const Drawer = ({ shouldScaleBackground = true, @@ -43,10 +41,9 @@ const DrawerContent = React.forwardRef< diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index 629bf30b87..e935148151 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -404,34 +404,38 @@ export default function LiveDashboardView({ {isMobile && (
-
+
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (
)} @@ -439,18 +443,21 @@ export default function LiveDashboardView({
)}