Compare commits

...

3 Commits

Author SHA1 Message Date
dependabot[bot]
c8d6930f35
Merge 5d0d89a3f2 into b751025339 2026-06-04 14:41:29 -04:00
Josh Hawkins
b751025339
Mobile UI/UX improvements (#23402)
Some checks are pending
CI / Synaptics Build (push) Blocked by required conditions
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / Assemble and push default build (push) Blocked by required conditions
* 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)
2026-06-04 09:56:11 -06:00
dependabot[bot]
5d0d89a3f2
Bump mermaid from 11.12.2 to 11.15.0 in /docs
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.12.2 to 11.15.0.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.12.2...mermaid@11.15.0)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-version: 11.15.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-11 22:56:50 +00:00
12 changed files with 419 additions and 219 deletions

215
docs/package-lock.json generated
View File

@ -2072,43 +2072,10 @@
"integrity": "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==",
"license": "MIT"
},
"node_modules/@chevrotain/cst-dts-gen": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz",
"integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/gast": "11.0.3",
"@chevrotain/types": "11.0.3",
"lodash-es": "4.17.21"
}
},
"node_modules/@chevrotain/gast": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz",
"integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/types": "11.0.3",
"lodash-es": "4.17.21"
}
},
"node_modules/@chevrotain/regexp-to-ast": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz",
"integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/types": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz",
"integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/utils": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz",
"integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==",
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
"license": "Apache-2.0"
},
"node_modules/@colors/colors": {
@ -4504,12 +4471,12 @@
}
},
"node_modules/@mermaid-js/parser": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz",
"integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz",
"integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==",
"license": "MIT",
"dependencies": {
"langium": "3.3.1"
"@chevrotain/types": "~11.1.1"
}
},
"node_modules/@nodelib/fs.scandir": {
@ -5995,6 +5962,16 @@
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC"
},
"node_modules/@upsetjs/venn.js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
"integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==",
"license": "MIT",
"optionalDependencies": {
"d3-selection": "^3.0.0",
"d3-transition": "^3.0.1"
}
},
"node_modules/@vercel/oidc": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.5.tgz",
@ -7199,32 +7176,6 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/chevrotain": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/cst-dts-gen": "11.0.3",
"@chevrotain/gast": "11.0.3",
"@chevrotain/regexp-to-ast": "11.0.3",
"@chevrotain/types": "11.0.3",
"@chevrotain/utils": "11.0.3",
"lodash-es": "4.17.21"
}
},
"node_modules/chevrotain-allstar": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz",
"integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==",
"license": "MIT",
"dependencies": {
"lodash-es": "^4.17.21"
},
"peerDependencies": {
"chevrotain": "^11.0.0"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@ -8560,9 +8511,9 @@
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
@ -8796,9 +8747,9 @@
}
},
"node_modules/dagre-d3-es": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz",
"integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==",
"version": "7.0.14",
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz",
"integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==",
"license": "MIT",
"dependencies": {
"d3": "^7.9.0",
@ -8973,9 +8924,9 @@
}
},
"node_modules/delaunator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
@ -10499,6 +10450,16 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.46.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
"integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/es6-promise": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz",
@ -13058,22 +13019,6 @@
"node": ">=6"
}
},
"node_modules/langium": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz",
"integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==",
"license": "MIT",
"dependencies": {
"chevrotain": "~11.0.3",
"chevrotain-allstar": "~0.3.0",
"vscode-languageserver": "~9.0.1",
"vscode-languageserver-textdocument": "~1.0.11",
"vscode-uri": "~3.0.8"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/latest-version": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/latest-version/-/latest-version-7.0.0.tgz",
@ -13190,9 +13135,9 @@
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
@ -13840,31 +13785,32 @@
}
},
"node_modules/mermaid": {
"version": "11.12.2",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz",
"integrity": "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==",
"version": "11.15.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz",
"integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.1.1",
"@iconify/utils": "^3.0.1",
"@mermaid-js/parser": "^0.6.3",
"@iconify/utils": "^3.0.2",
"@mermaid-js/parser": "^1.1.1",
"@types/d3": "^7.4.3",
"cytoscape": "^3.29.3",
"@upsetjs/venn.js": "^2.0.0",
"cytoscape": "^3.33.1",
"cytoscape-cose-bilkent": "^4.1.0",
"cytoscape-fcose": "^2.2.0",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
"dagre-d3-es": "7.0.13",
"dayjs": "^1.11.18",
"dompurify": "^3.2.5",
"katex": "^0.16.22",
"dagre-d3-es": "7.0.14",
"dayjs": "^1.11.19",
"dompurify": "^3.3.1",
"es-toolkit": "^1.45.1",
"katex": "^0.16.25",
"khroma": "^2.1.0",
"lodash-es": "^4.17.21",
"marked": "^16.2.1",
"marked": "^16.3.0",
"roughjs": "^4.6.6",
"stylis": "^4.3.6",
"ts-dedent": "^2.2.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
}
},
"node_modules/methods": {
@ -20210,9 +20156,9 @@
}
},
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
"license": "Unlicense"
},
"node_modules/roughjs": {
@ -22561,55 +22507,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vscode-languageserver": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
"license": "MIT",
"dependencies": {
"vscode-languageserver-protocol": "3.17.5"
},
"bin": {
"installServerIntoExtension": "bin/installServerIntoExtension"
}
},
"node_modules/vscode-languageserver-protocol": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "8.2.0",
"vscode-languageserver-types": "3.17.5"
}
},
"node_modules/vscode-languageserver-textdocument": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
"license": "MIT"
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz",
"integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==",
"license": "MIT"
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/images/branding/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Frigate</title>
<link
rel="apple-touch-icon"

View File

@ -2,7 +2,10 @@
"group": {
"label": "Camera Groups",
"add": "Add Camera Group",
"showAll": "Show all camera groups",
"showLess": "Show less",
"edit": "Edit Camera Group",
"editGroups": "Edit Camera Groups",
"delete": {
"label": "Delete Camera Group",
"confirm": {

View File

@ -78,7 +78,9 @@ function DefaultAppView() {
className={cn(
"absolute right-0 top-0 overflow-hidden",
isMobile
? `bottom-${isPWA ? 16 : 12} left-0 md:bottom-16 landscape:bottom-14 landscape:md:bottom-16`
? isPWA
? "bottom-[calc(3rem+env(safe-area-inset-bottom))] left-0 pt-[env(safe-area-inset-top)] md:bottom-[calc(4rem+env(safe-area-inset-bottom))] landscape:pl-[env(safe-area-inset-left)] landscape:pr-[env(safe-area-inset-right)]"
: "bottom-12 left-0 md:bottom-16"
: "bottom-8 left-[52px]",
)}
>

View File

@ -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?.();
}}
>
<LuPencil 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
@ -225,18 +366,97 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
})}
{isAdmin && (
<Tooltip open={tooltip == "edit"}>
<TooltipTrigger asChild>
<Button
className="bg-secondary text-muted-foreground"
aria-label={t("group.editGroups")}
size="xs"
onClick={() => setAddGroup(true)}
onMouseEnter={() => showTooltip("edit")}
onMouseLeave={() => showTooltip(undefined)}
>
<LuPencil className="size-4 text-primary" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="right">
{t("group.editGroups")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
</div>
) : (
<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
className="bg-secondary text-muted-foreground"
aria-label={t("group.add")}
size="xs"
onClick={() => setAddGroup(true)}
variant="ghost"
size="sm"
className="shrink-0 px-2 text-secondary-foreground"
aria-label={t("group.showAll")}
onClick={() => setExpanded(true)}
>
<LuPlus className="size-4 text-primary" />
<HiDotsHorizontal className="size-5" />
</Button>
)}
{isMobile && <ScrollBar orientation="horizontal" className="h-0" />}
{/* 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>
</Scroller>
)}
</>
);
}

View File

@ -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"}`}
/>

View File

@ -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,
)}
>
<LuSettings className="size-5 md:m-[6px]" />
<LuSettings
className={cn("md:m-[6px]", large ? "size-6" : "size-5")}
/>
</div>
</TooltipTrigger>
<TooltipPortal>

View File

@ -146,9 +146,10 @@ export function MobilePageContent({
<motion.div
ref={containerRef}
className={cn(
"fixed inset-0 z-50 mb-12 bg-background",
isPWA && "mb-16",
"landscape:mb-14 landscape:md:mb-16",
"fixed inset-0 z-50 bg-background",
isPWA
? "mb-[calc(3rem+env(safe-area-inset-bottom))] md:mb-[calc(4rem+env(safe-area-inset-bottom))]"
: "mb-12 md:mb-16",
className,
)}
initial={{ x: "100%" }}

View File

@ -4,7 +4,14 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import useSWR from "swr";
import { FrigateStats } from "@/types/stats";
import { useEmbeddingsReindexProgress, useFrigateStats } from "@/api/ws";
import { useContext, useEffect, useMemo } from "react";
import {
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import useStats from "@/hooks/use-stats";
import GeneralSettings from "../menu/GeneralSettings";
import useNavigation from "@/hooks/use-navigation";
@ -14,36 +21,82 @@ import {
} from "@/context/statusbar-provider";
import { Link } from "react-router-dom";
import { cn } from "@/lib/utils";
import { isIOS, isMobile } from "react-device-detect";
import { isMobile } from "react-device-detect";
import { isPWA } from "@/utils/isPWA";
import { useTranslation } from "react-i18next";
function Bottombar() {
const navItems = useNavigation("secondary");
// Render 48px touch targets when they fit with even spacing, otherwise fall
// back to the compact size. Measured against the live bar width and icon
// count (which varies with enabled nav items and the status alert).
const containerRef = useRef<HTMLDivElement | null>(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 (
<div
ref={containerRef}
className={cn(
"absolute inset-x-4 bottom-0 flex h-16 flex-row justify-between",
isPWA && isIOS
? "portrait:items-start portrait:pt-1 landscape:items-center"
: "items-center",
isMobile && !isPWA && "h-12 md:h-16",
"absolute inset-x-4 bottom-0 flex h-16 flex-row items-center justify-between",
isMobile &&
(isPWA
? "h-[calc(3rem+env(safe-area-inset-bottom))] pb-[env(safe-area-inset-bottom)] md:h-[calc(4rem+env(safe-area-inset-bottom))]"
: "h-12 md:h-16 md:pb-2"),
)}
>
{navItems.map((item) => (
<NavItem key={item.id} className="p-2" item={item} Icon={item.icon} />
<NavItem
key={item.id}
large={large}
className="p-2"
item={item}
Icon={item.icon}
/>
))}
<GeneralSettings className="p-2" />
<StatusAlertNav className="p-2" />
<GeneralSettings large={large} className="p-2" />
<StatusAlertNav large={large} className="p-2" />
</div>
);
}
type StatusAlertNavProps = {
className?: string;
large?: boolean;
};
function StatusAlertNav({ className }: StatusAlertNavProps) {
function StatusAlertNav({ className, large }: StatusAlertNavProps) {
const { t } = useTranslation(["views/system"]);
const { data: initialStats } = useSWR<FrigateStats>("stats", {
revalidateOnFocus: false,
@ -105,8 +158,18 @@ function StatusAlertNav({ className }: StatusAlertNavProps) {
return (
<Drawer>
<DrawerTrigger asChild>
<div className="p-2">
<IoIosWarning className="size-5 text-danger md:m-[6px]" />
<div
className={cn(
"flex flex-col items-center justify-center p-2",
large && "size-12",
)}
>
<IoIosWarning
className={cn(
"text-danger md:m-[6px]",
large ? "size-6" : "size-5",
)}
/>
</div>
</DrawerTrigger>
<DrawerContent

View File

@ -27,6 +27,7 @@ type NavItemProps = {
item: NavData;
Icon: IconType;
onClick?: () => 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"],
)
}
>
<Icon className="size-5" />
<Icon className={large ? "size-6" : "size-5"} />
</NavLink>
);

View File

@ -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<
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border border-b-0 bg-background",
className,
isIOS && isPWA && "pb-5",
isIOS && !isPWA && "md:pb-5",
"pb-[calc(0.25rem+env(safe-area-inset-bottom))]",
)}
{...props}
>

View File

@ -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>
)}