diff --git a/web/public/locales/en/components/camera.json b/web/public/locales/en/components/camera.json index ed37d1771f..d07413dd80 100644 --- a/web/public/locales/en/components/camera.json +++ b/web/public/locales/en/components/camera.json @@ -2,6 +2,8 @@ "group": { "label": "Camera Groups", "add": "Add Camera Group", + "showAll": "Show all camera groups", + "showLess": "Show less", "edit": "Edit Camera Group", "delete": { "label": "Delete Camera Group", diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index d4f01481f3..35998d1118 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) && ( )} - {isMobile && }
-
+ ) : ( +
+
+ {visibleCount == null + ? groupButtons() + : groupButtons().slice(0, visibleCount)} +
+ {visibleCount != null && ( + + )} + + {/* 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/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({
)}