mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +03:00
increase camera group icon size on mobile
add an animated slider when there is not enough space for all defined camera groups
This commit is contained in:
parent
7e83d5de90
commit
b3f7aa124b
@ -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",
|
||||
|
||||
@ -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<number | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const measureRef = useRef<HTMLDivElement | null>(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 = [
|
||||
<Button
|
||||
key="default-group"
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
group == "default"
|
||||
? "bg-blue-900 bg-opacity-60 text-selected focus:bg-blue-900 focus:bg-opacity-60"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
aria-label={t("menu.live.allCameras", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (group) {
|
||||
setGroup("default", true);
|
||||
}
|
||||
afterSelect?.();
|
||||
}}
|
||||
>
|
||||
<MdHome className="size-5" />
|
||||
</Button>,
|
||||
...groups.map(([name, config]) => (
|
||||
<Button
|
||||
key={name}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
group == name
|
||||
? "bg-blue-900 bg-opacity-60 text-selected focus:bg-blue-900 focus:bg-opacity-60"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
aria-label={t("group.label")}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setGroup(name, group != "default");
|
||||
afterSelect?.();
|
||||
}}
|
||||
>
|
||||
{config && config.icon && isValidIconName(config.icon) && (
|
||||
<IconRenderer icon={LuIcons[config.icon]} className="size-5" />
|
||||
)}
|
||||
</Button>
|
||||
)),
|
||||
];
|
||||
|
||||
if (isAdmin) {
|
||||
buttons.push(
|
||||
<Button
|
||||
key="add-group"
|
||||
className="shrink-0 bg-secondary text-muted-foreground"
|
||||
aria-label={t("group.add")}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAddGroup(true);
|
||||
afterSelect?.();
|
||||
}}
|
||||
>
|
||||
<LuPlus className="size-5 text-primary" />
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
|
||||
return buttons;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -158,12 +302,11 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
deleteGroup={deleteGroup}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
<Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}>
|
||||
{isDesktop ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-start gap-2",
|
||||
"flex flex-col items-center justify-start gap-2",
|
||||
className,
|
||||
isDesktop ? "flex-col" : "whitespace-nowrap",
|
||||
)}
|
||||
>
|
||||
<Tooltip open={tooltip == "default"}>
|
||||
@ -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)}
|
||||
>
|
||||
<MdHome className="size-4" />
|
||||
</Button>
|
||||
@ -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) && (
|
||||
<IconRenderer
|
||||
@ -234,9 +375,77 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
<LuPlus className="size-4 text-primary" />
|
||||
</Button>
|
||||
)}
|
||||
{isMobile && <ScrollBar orientation="horizontal" className="h-0" />}
|
||||
</div>
|
||||
</Scroller>
|
||||
) : (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={cn("flex min-w-0 items-center gap-1", className)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||
{visibleCount == null
|
||||
? groupButtons()
|
||||
: groupButtons().slice(0, visibleCount)}
|
||||
</div>
|
||||
{visibleCount != null && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 px-2 text-secondary-foreground"
|
||||
aria-label={t("group.showAll")}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<HiDotsHorizontal className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* invisible row used only to measure natural button widths so we
|
||||
can render exactly the buttons that fully fit */}
|
||||
<div
|
||||
className="pointer-events-none absolute left-0 top-0 h-0 w-0 overflow-hidden"
|
||||
aria-hidden
|
||||
inert
|
||||
>
|
||||
<div ref={measureRef} className="flex w-max items-center gap-2">
|
||||
{groupButtons()}
|
||||
<Button variant="ghost" size="sm" className="px-2">
|
||||
<HiDotsHorizontal className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
className="fixed inset-0 z-20"
|
||||
onClick={() => setExpanded(false)}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
key="group-overlay"
|
||||
className="absolute inset-x-0 top-0 z-30 bg-background py-1 shadow-lg"
|
||||
initial={{ clipPath: "inset(0 100% 0 0)" }}
|
||||
animate={{ clipPath: "inset(0 0% 0 0)" }}
|
||||
exit={{ clipPath: "inset(0 100% 0 0)" }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{groupButtons(() => setExpanded(false))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto shrink-0 px-2 text-secondary-foreground"
|
||||
aria-label={t("group.showLess")}
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
<IoClose className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,9 +6,9 @@ export function LiveGridIcon({ layout }: LiveIconProps) {
|
||||
return (
|
||||
<div className="flex size-full flex-col gap-0.5 overflow-hidden rounded-md">
|
||||
<div
|
||||
className={`h-1 w-full ${layout == "grid" ? "bg-selected" : "bg-muted-foreground"}`}
|
||||
className={`w-full flex-1 ${layout == "grid" ? "bg-selected" : "bg-muted-foreground"}`}
|
||||
/>
|
||||
<div className="flex h-1 w-full gap-0.5">
|
||||
<div className="flex w-full flex-1 gap-0.5">
|
||||
<div
|
||||
className={`w-full ${layout == "grid" ? "bg-selected" : "bg-muted-foreground"}`}
|
||||
/>
|
||||
@ -16,7 +16,7 @@ export function LiveGridIcon({ layout }: LiveIconProps) {
|
||||
className={`w-full ${layout == "grid" ? "bg-selected" : "bg-muted-foreground"}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-1 w-full gap-0.5">
|
||||
<div className="flex w-full flex-1 gap-0.5">
|
||||
<div
|
||||
className={`w-full ${layout == "grid" ? "bg-selected" : "bg-muted-foreground"}`}
|
||||
/>
|
||||
|
||||
@ -404,34 +404,38 @@ export default function LiveDashboardView({
|
||||
{isMobile && (
|
||||
<div className="relative flex h-11 items-center justify-between">
|
||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||
<div className="max-w-[45%]">
|
||||
<div className="w-[45%]">
|
||||
<CameraGroupSelector />
|
||||
</div>
|
||||
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
className={`p-1 ${
|
||||
className={
|
||||
mobileLayout == "grid"
|
||||
? "bg-blue-900 bg-opacity-60 focus:bg-blue-900 focus:bg-opacity-60"
|
||||
: "bg-secondary"
|
||||
}`}
|
||||
}
|
||||
aria-label="Use mobile grid layout"
|
||||
size="xs"
|
||||
size="sm"
|
||||
onClick={() => setMobileLayout("grid")}
|
||||
>
|
||||
<LiveGridIcon layout={mobileLayout} />
|
||||
<div className="size-5">
|
||||
<LiveGridIcon layout={mobileLayout} />
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
className={`p-1 ${
|
||||
className={
|
||||
mobileLayout == "list"
|
||||
? "bg-blue-900 bg-opacity-60 focus:bg-blue-900 focus:bg-opacity-60"
|
||||
: "bg-secondary"
|
||||
}`}
|
||||
}
|
||||
aria-label="Use mobile list layout"
|
||||
size="xs"
|
||||
size="sm"
|
||||
onClick={() => setMobileLayout("list")}
|
||||
>
|
||||
<LiveListIcon layout={mobileLayout} />
|
||||
<div className="size-5">
|
||||
<LiveListIcon layout={mobileLayout} />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@ -439,18 +443,21 @@ export default function LiveDashboardView({
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
className={cn(
|
||||
"p-1",
|
||||
isEditMode
|
||||
? "bg-selected text-primary"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
aria-label="Enter layout editing mode"
|
||||
size="xs"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setIsEditMode((prevIsEditMode) => !prevIsEditMode)
|
||||
}
|
||||
>
|
||||
{isEditMode ? <IoClose /> : <LuLayoutDashboard />}
|
||||
{isEditMode ? (
|
||||
<IoClose className="size-5" />
|
||||
) : (
|
||||
<LuLayoutDashboard className="size-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user