mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-03 13:26:48 +03:00
Compare commits
2
Commits
dev
...
overflow-menu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06ed433e9a | ||
|
|
5ab8666d4a |
@@ -0,0 +1,11 @@
|
|||||||
|
import type { FrigateApp } from "../fixtures/frigate-test";
|
||||||
|
|
||||||
|
// On mobile the System tabs sit in an OverflowStrip, which keeps an inert copy
|
||||||
|
// of every tab for measurement and hides the ones that do not fit behind a
|
||||||
|
// kebab. The selected tab always stays in the strip.
|
||||||
|
|
||||||
|
export function systemTab(frigateApp: FrigateApp, name: string) {
|
||||||
|
return frigateApp.page
|
||||||
|
.locator(`[aria-label="Select ${name}" i]:not([inert] *)`)
|
||||||
|
.first();
|
||||||
|
}
|
||||||
@@ -7,12 +7,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import { systemTab } from "../helpers/system-tabs";
|
||||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||||
|
|
||||||
test.describe("Auth — admin access @high", () => {
|
test.describe("Auth — admin access @high", () => {
|
||||||
test("admin /system renders general tab", async ({ frigateApp }) => {
|
test("admin /system renders general tab", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/system");
|
await frigateApp.goto("/system");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
await expect(systemTab(frigateApp, "general")).toBeVisible({
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -28,7 +29,7 @@ test.describe("Auth — admin access @high", () => {
|
|||||||
|
|
||||||
test("admin /logs renders frigate tab", async ({ frigateApp }) => {
|
test("admin /logs renders frigate tab", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/logs");
|
await frigateApp.goto("/logs");
|
||||||
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
await expect(systemTab(frigateApp, "frigate")).toBeVisible({
|
||||||
timeout: 5_000,
|
timeout: 5_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -287,3 +287,22 @@ test.describe("Live mobile layout @critical @mobile", () => {
|
|||||||
await expect(frigateApp.page.locator("body")).toBeVisible();
|
await expect(frigateApp.page.locator("body")).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe("Live camera groups @medium", () => {
|
||||||
|
test("a group with an invalid icon renders a fallback icon", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults({
|
||||||
|
config: {
|
||||||
|
camera_groups: {
|
||||||
|
outdoor: { cameras: ["front_door"], icon: "generic" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await frigateApp.goto("/");
|
||||||
|
const group = frigateApp.page
|
||||||
|
.locator('[aria-label="Camera Groups"]:not([inert] *)')
|
||||||
|
.first();
|
||||||
|
await expect(group.locator("svg")).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import { systemTab } from "../helpers/system-tabs";
|
||||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||||
|
|
||||||
const NOW = Math.floor(Date.now() / 1000);
|
const NOW = Math.floor(Date.now() / 1000);
|
||||||
@@ -55,7 +56,7 @@ test.describe("System — Health tab @medium", () => {
|
|||||||
});
|
});
|
||||||
await frigateApp.goto("/system");
|
await frigateApp.goto("/system");
|
||||||
|
|
||||||
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "health")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
@@ -801,7 +802,7 @@ test.describe("System — Health notices sources @medium", () => {
|
|||||||
stats: QUIET_STATS,
|
stats: QUIET_STATS,
|
||||||
});
|
});
|
||||||
await frigateApp.goto("/system#health");
|
await frigateApp.goto("/system#health");
|
||||||
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "health")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
|
|||||||
@@ -6,42 +6,66 @@
|
|||||||
* RestartDialog cancel flow.
|
* RestartDialog cancel flow.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect, FrigateApp } from "../fixtures/frigate-test";
|
||||||
import {
|
import {
|
||||||
expectBodyInteractive,
|
expectBodyInteractive,
|
||||||
waitForBodyInteractive,
|
waitForBodyInteractive,
|
||||||
} from "../helpers/overlay-interaction";
|
} from "../helpers/overlay-interaction";
|
||||||
|
import { systemTab } from "../helpers/system-tabs";
|
||||||
|
|
||||||
|
async function selectTab(frigateApp: FrigateApp, name: string) {
|
||||||
|
const kebab = frigateApp.page.getByLabel("Show all tabs");
|
||||||
|
|
||||||
|
if (frigateApp.isMobile && (await kebab.isVisible())) {
|
||||||
|
await kebab.click();
|
||||||
|
await frigateApp.page
|
||||||
|
.locator(`[aria-label="Select ${name}" i]:not([inert] *)`)
|
||||||
|
.last()
|
||||||
|
.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await systemTab(frigateApp, name).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectTabActive(frigateApp: FrigateApp, name: string) {
|
||||||
|
await expect(systemTab(frigateApp, name)).toHaveAttribute(
|
||||||
|
"data-state",
|
||||||
|
"on",
|
||||||
|
{
|
||||||
|
timeout: 5_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test.describe("System — tabs @medium", () => {
|
test.describe("System — tabs @medium", () => {
|
||||||
test("general tab is active by default via #general hash", async ({
|
test("general tab is active by default via #general hash", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await expect(frigateApp.page.getByLabel("Select storage")).toBeVisible();
|
if (!frigateApp.isMobile) {
|
||||||
await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible();
|
await expect(systemTab(frigateApp, "storage")).toBeVisible();
|
||||||
|
await expect(systemTab(frigateApp, "cameras")).toBeVisible();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Storage tab activates and deactivates General", async ({
|
test("Storage tab activates and deactivates General", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await frigateApp.page.getByLabel("Select storage").click();
|
await selectTab(frigateApp, "storage");
|
||||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
await expectTabActive(frigateApp, "storage");
|
||||||
"data-state",
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"on",
|
|
||||||
{ timeout: 5_000 },
|
|
||||||
);
|
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
|
||||||
"data-state",
|
"data-state",
|
||||||
"off",
|
"off",
|
||||||
);
|
);
|
||||||
@@ -49,24 +73,20 @@ test.describe("System — tabs @medium", () => {
|
|||||||
|
|
||||||
test("Cameras tab activates", async ({ frigateApp }) => {
|
test("Cameras tab activates", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await frigateApp.page.getByLabel("Select cameras").click();
|
await selectTab(frigateApp, "cameras");
|
||||||
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
await expectTabActive(frigateApp, "cameras");
|
||||||
"data-state",
|
|
||||||
"on",
|
|
||||||
{ timeout: 5_000 },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("general tab shows version and last-refreshed", async ({
|
test("general tab shows version and last-refreshed", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
@@ -87,17 +107,13 @@ test.describe("System — tabs @medium", () => {
|
|||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await frigateApp.page.getByLabel("Select storage").click();
|
await selectTab(frigateApp, "storage");
|
||||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
await expectTabActive(frigateApp, "storage");
|
||||||
"data-state",
|
|
||||||
"on",
|
|
||||||
{ timeout: 5_000 },
|
|
||||||
);
|
|
||||||
// On desktop, tab buttons render text labels so the word "storage"
|
// On desktop, tab buttons render text labels so the word "storage"
|
||||||
// always appears in #pageRoot after switching. On mobile, tabs are
|
// always appears in #pageRoot after switching. On mobile, tabs are
|
||||||
// icon-only, so we verify the general-tab content disappears instead
|
// icon-only, so we verify the general-tab content disappears instead
|
||||||
@@ -112,25 +128,23 @@ test.describe("System — tabs @medium", () => {
|
|||||||
} else {
|
} else {
|
||||||
// Mobile: tab activation (data-state "on") already asserted above.
|
// Mobile: tab activation (data-state "on") already asserted above.
|
||||||
// Additionally confirm general tab is no longer the active tab.
|
// Additionally confirm general tab is no longer the active tab.
|
||||||
await expect(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
frigateApp.page.getByLabel("Select general"),
|
"data-state",
|
||||||
).toHaveAttribute("data-state", "off", { timeout: 5_000 });
|
"off",
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("cameras tab renders each configured camera", async ({ frigateApp }) => {
|
test("cameras tab renders each configured camera", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await frigateApp.page.getByLabel("Select cameras").click();
|
await selectTab(frigateApp, "cameras");
|
||||||
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
await expectTabActive(frigateApp, "cameras");
|
||||||
"data-state",
|
|
||||||
"on",
|
|
||||||
{ timeout: 5_000 },
|
|
||||||
);
|
|
||||||
// Cameras tab lists every camera from config/stats. The default
|
// Cameras tab lists every camera from config/stats. The default
|
||||||
// mock has front_door, backyard, garage.
|
// mock has front_door, backyard, garage.
|
||||||
for (const cam of ["front_door", "backyard", "garage"]) {
|
for (const cam of ["front_door", "backyard", "garage"]) {
|
||||||
@@ -151,17 +165,13 @@ test.describe("System — tabs @medium", () => {
|
|||||||
config: { semantic_search: { enabled: true } },
|
config: { semantic_search: { enabled: true } },
|
||||||
});
|
});
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
const enrichTab = frigateApp.page.getByLabel(/select enrichments/i).first();
|
await selectTab(frigateApp, "enrichments");
|
||||||
await expect(enrichTab).toBeVisible({ timeout: 5_000 });
|
await expectTabActive(frigateApp, "enrichments");
|
||||||
await enrichTab.click();
|
|
||||||
await expect(enrichTab).toHaveAttribute("data-state", "on", {
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -223,31 +233,27 @@ test.describe("System — mobile @medium @mobile", () => {
|
|||||||
|
|
||||||
test("tabs render at mobile viewport", async ({ frigateApp }) => {
|
test("tabs render at mobile viewport", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
await expect(systemTab(frigateApp, "general")).toBeVisible({
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("switching tabs works at mobile viewport", async ({ frigateApp }) => {
|
test("switching tabs works at mobile viewport", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
await frigateApp.page.getByLabel("Select storage").click();
|
await selectTab(frigateApp, "storage");
|
||||||
await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute(
|
await expectTabActive(frigateApp, "storage");
|
||||||
"data-state",
|
|
||||||
"on",
|
|
||||||
{ timeout: 5_000 },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("header controls leave the logo uncovered on a narrow phone", async ({
|
test("header controls leave the logo uncovered on a narrow phone", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/system#general");
|
await frigateApp.goto("/system#general");
|
||||||
await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute(
|
await expect(systemTab(frigateApp, "general")).toHaveAttribute(
|
||||||
"data-state",
|
"data-state",
|
||||||
"on",
|
"on",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
@@ -255,9 +261,9 @@ test.describe("System — mobile @medium @mobile", () => {
|
|||||||
await frigateApp.page.setViewportSize({ width: 320, height: 740 });
|
await frigateApp.page.setViewportSize({ width: 320, height: 740 });
|
||||||
|
|
||||||
const logo = frigateApp.page.locator("svg.fill-current").first();
|
const logo = frigateApp.page.locator("svg.fill-current").first();
|
||||||
const tabs = frigateApp.page
|
const kebab = frigateApp.page.getByLabel("Show all tabs");
|
||||||
.locator("[data-radix-scroll-area-viewport]")
|
await expect(kebab).toBeVisible();
|
||||||
.filter({ has: frigateApp.page.getByLabel("Select general") });
|
const tabs = kebab.locator("..");
|
||||||
const refreshed = frigateApp.page.getByText(/Just now|ago/);
|
const refreshed = frigateApp.page.getByText(/Just now|ago/);
|
||||||
|
|
||||||
const logoBox = await logo.boundingBox();
|
const logoBox = await logo.boundingBox();
|
||||||
@@ -267,20 +273,9 @@ test.describe("System — mobile @medium @mobile", () => {
|
|||||||
expect(tabsBox!.x + tabsBox!.width).toBeLessThanOrEqual(logoBox!.x + 1);
|
expect(tabsBox!.x + tabsBox!.width).toBeLessThanOrEqual(logoBox!.x + 1);
|
||||||
expect(refreshedBox!.x).toBeGreaterThanOrEqual(logoBox!.x + logoBox!.width);
|
expect(refreshedBox!.x).toBeGreaterThanOrEqual(logoBox!.x + logoBox!.width);
|
||||||
|
|
||||||
// the clipped tabs stay reachable by scrolling
|
// the tabs that do not fit stay reachable through the kebab
|
||||||
const overflow = await tabs.evaluate((el) => ({
|
await selectTab(frigateApp, "cameras");
|
||||||
scroll: el.scrollWidth,
|
await expectTabActive(frigateApp, "cameras");
|
||||||
client: el.clientWidth,
|
await expect(frigateApp.page.getByLabel("Show less")).toHaveCount(0);
|
||||||
}));
|
|
||||||
expect(overflow.scroll).toBeGreaterThan(overflow.client);
|
|
||||||
await tabs.evaluate((el) => {
|
|
||||||
el.scrollLeft = el.scrollWidth;
|
|
||||||
});
|
|
||||||
await frigateApp.page.getByLabel("Select cameras").click();
|
|
||||||
await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute(
|
|
||||||
"data-state",
|
|
||||||
"on",
|
|
||||||
{ timeout: 5_000 },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
},
|
},
|
||||||
"title": "System",
|
"title": "System",
|
||||||
"metrics": "System metrics",
|
"metrics": "System metrics",
|
||||||
|
"showAllTabs": "Show all tabs",
|
||||||
|
"showLessTabs": "Show less",
|
||||||
"health": {
|
"health": {
|
||||||
"title": "Health",
|
"title": "Health",
|
||||||
"notices": {
|
"notices": {
|
||||||
|
|||||||
@@ -8,18 +8,8 @@ import { isDesktop, isMobile } from "react-device-detect";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { MdHome } from "react-icons/md";
|
import { MdHome } from "react-icons/md";
|
||||||
import { Button, buttonVariants } from "../ui/button";
|
import { Button, buttonVariants } from "../ui/button";
|
||||||
import {
|
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
useCallback,
|
import OverflowStrip from "../mobile/OverflowStrip";
|
||||||
useContext,
|
|
||||||
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 { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
import { LuPencil, LuPlus } from "react-icons/lu";
|
import { LuPencil, LuPlus } from "react-icons/lu";
|
||||||
import {
|
import {
|
||||||
@@ -156,80 +146,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
|
|
||||||
const [addGroup, setAddGroup] = useState(false);
|
const [addGroup, setAddGroup] = useState(false);
|
||||||
|
|
||||||
// mobile overflow reveal - the group strip sits left of the logo and is
|
const groupButtons = () => {
|
||||||
// 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 = [
|
const buttons = [
|
||||||
<Button
|
<Button
|
||||||
key="default-group"
|
key="default-group"
|
||||||
@@ -245,7 +162,6 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
if (group) {
|
if (group) {
|
||||||
setGroup("default", true);
|
setGroup("default", true);
|
||||||
}
|
}
|
||||||
afterSelect?.();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MdHome className="size-5" />
|
<MdHome className="size-5" />
|
||||||
@@ -263,12 +179,16 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setGroup(name, group != "default");
|
setGroup(name, group != "default");
|
||||||
afterSelect?.();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{config && config.icon && isValidIconName(config.icon) && (
|
<IconRenderer
|
||||||
<IconRenderer icon={LuIcons[config.icon]} className="size-5" />
|
icon={
|
||||||
)}
|
isValidIconName(config.icon)
|
||||||
|
? LuIcons[config.icon]
|
||||||
|
: LuIcons.LuFolder
|
||||||
|
}
|
||||||
|
className="size-5"
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
)),
|
)),
|
||||||
];
|
];
|
||||||
@@ -282,7 +202,6 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAddGroup(true);
|
setAddGroup(true);
|
||||||
afterSelect?.();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LuPencil className="size-5 text-primary-variant" />
|
<LuPencil className="size-5 text-primary-variant" />
|
||||||
@@ -350,12 +269,14 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
onMouseEnter={() => showTooltip(name)}
|
onMouseEnter={() => showTooltip(name)}
|
||||||
onMouseLeave={() => showTooltip(undefined)}
|
onMouseLeave={() => showTooltip(undefined)}
|
||||||
>
|
>
|
||||||
{config && config.icon && isValidIconName(config.icon) && (
|
<IconRenderer
|
||||||
<IconRenderer
|
icon={
|
||||||
icon={LuIcons[config.icon]}
|
isValidIconName(config.icon)
|
||||||
className="size-4"
|
? LuIcons[config.icon]
|
||||||
/>
|
: LuIcons.LuFolder
|
||||||
)}
|
}
|
||||||
|
className="size-4"
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
@@ -390,74 +311,13 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<OverflowStrip
|
||||||
ref={wrapperRef}
|
className={className}
|
||||||
className={cn("flex min-w-0 items-center gap-1", className)}
|
items={groupButtons()}
|
||||||
>
|
activeIndex={groups.findIndex(([name]) => name == group) + 1}
|
||||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
|
showAllLabel={t("group.showAll")}
|
||||||
{visibleCount == null
|
showLessLabel={t("group.showLess")}
|
||||||
? 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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { ReactNode, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { HiDotsHorizontal } from "react-icons/hi";
|
||||||
|
import { IoClose } from "react-icons/io5";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type OverflowStripProps = {
|
||||||
|
className?: string;
|
||||||
|
items: ReactNode[];
|
||||||
|
activeIndex?: number;
|
||||||
|
gapClassName?: string;
|
||||||
|
showAllLabel: string;
|
||||||
|
showLessLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Renders only the items that fully fit and surfaces a kebab next to the last
|
||||||
|
// visible one. The kebab expands a panel over the nearest positioned ancestor
|
||||||
|
// that reveals every item.
|
||||||
|
export default function OverflowStrip({
|
||||||
|
className,
|
||||||
|
items,
|
||||||
|
activeIndex = 0,
|
||||||
|
gapClassName = "gap-2",
|
||||||
|
showAllLabel,
|
||||||
|
showLessLabel,
|
||||||
|
}: OverflowStripProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
// null => all items 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(() => {
|
||||||
|
const wrapper = wrapperRef.current;
|
||||||
|
const measure = measureRef.current;
|
||||||
|
|
||||||
|
if (!wrapper || !measure) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapperGap = 4; // gap-1 between the strip and the kebab
|
||||||
|
|
||||||
|
const compute = () => {
|
||||||
|
const children = Array.from(measure.children) as HTMLElement[];
|
||||||
|
|
||||||
|
if (children.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the trailing child of the measurement row is a kebab clone
|
||||||
|
const kebab = children[children.length - 1];
|
||||||
|
const start = children[0].offsetLeft;
|
||||||
|
const ends = children
|
||||||
|
.slice(0, -1)
|
||||||
|
.map((el) => el.offsetLeft + el.offsetWidth - start);
|
||||||
|
const available = wrapper.clientWidth;
|
||||||
|
|
||||||
|
if (ends[ends.length - 1] <= available) {
|
||||||
|
setVisibleCount(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const budget = available - kebab.offsetWidth - wrapperGap;
|
||||||
|
const count = ends.filter((end) => end <= budget).length;
|
||||||
|
|
||||||
|
setVisibleCount(Math.max(count, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
compute();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(compute);
|
||||||
|
observer.observe(wrapper);
|
||||||
|
observer.observe(measure);
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [items.length, gapClassName]);
|
||||||
|
|
||||||
|
// a selected item past the cut takes the last visible slot
|
||||||
|
const visibleItems =
|
||||||
|
visibleCount == null
|
||||||
|
? items
|
||||||
|
: activeIndex >= visibleCount
|
||||||
|
? [...items.slice(0, visibleCount - 1), items[activeIndex]]
|
||||||
|
: items.slice(0, visibleCount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className={cn("flex min-w-0 items-center gap-1", className)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 items-center overflow-hidden whitespace-nowrap",
|
||||||
|
gapClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{visibleItems}
|
||||||
|
</div>
|
||||||
|
{visibleCount != null && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0 px-2 text-secondary-foreground"
|
||||||
|
aria-label={showAllLabel}
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
>
|
||||||
|
<HiDotsHorizontal className="size-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* invisible row used only to measure natural item widths so we can
|
||||||
|
render exactly the items 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={cn("flex w-max items-center", gapClassName)}
|
||||||
|
>
|
||||||
|
{items}
|
||||||
|
<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="overflow-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" }}
|
||||||
|
>
|
||||||
|
{/* a tap on any item bubbles up and collapses the panel */}
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-wrap items-center", gapClassName)}
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
|
>
|
||||||
|
{items}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="ml-auto shrink-0 px-2 text-secondary-foreground"
|
||||||
|
aria-label={showLessLabel}
|
||||||
|
>
|
||||||
|
<IoClose className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+63
-30
@@ -1,8 +1,10 @@
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateStats } from "@/types/stats";
|
import { FrigateStats } from "@/types/stats";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import TimeAgo from "@/components/dynamic/TimeAgo";
|
import TimeAgo from "@/components/dynamic/TimeAgo";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
|
import { Toggle } from "@/components/ui/toggle";
|
||||||
|
import OverflowStrip from "@/components/mobile/OverflowStrip";
|
||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import GeneralMetrics from "@/views/system/GeneralMetrics";
|
import GeneralMetrics from "@/views/system/GeneralMetrics";
|
||||||
import StorageMetrics from "@/views/system/StorageMetrics";
|
import StorageMetrics from "@/views/system/StorageMetrics";
|
||||||
@@ -36,6 +38,14 @@ const allMetrics = [
|
|||||||
] as const;
|
] as const;
|
||||||
type SystemMetric = (typeof allMetrics)[number];
|
type SystemMetric = (typeof allMetrics)[number];
|
||||||
|
|
||||||
|
const metricIcons: Record<SystemMetric, ReactNode> = {
|
||||||
|
health: <LuHeartPulse className="size-4" />,
|
||||||
|
general: <LuActivity className="size-4" />,
|
||||||
|
enrichments: <LuSearchCode className="size-4" />,
|
||||||
|
storage: <LuHardDrive className="size-4" />,
|
||||||
|
cameras: <FaVideo className="size-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
function System() {
|
function System() {
|
||||||
const { t } = useTranslation(["views/system"]);
|
const { t } = useTranslation(["views/system"]);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
@@ -98,43 +108,66 @@ function System() {
|
|||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||||
)}
|
)}
|
||||||
<ScrollArea className={cn("whitespace-nowrap", isMobile && "w-[45%]")}>
|
{isMobile ? (
|
||||||
<div className="flex flex-row">
|
<div className="w-[calc(50%-1rem)]">
|
||||||
<ToggleGroup
|
<OverflowStrip
|
||||||
className="*:rounded-md *:px-3 *:py-4"
|
items={metrics.map((item) => (
|
||||||
type="single"
|
<Toggle
|
||||||
size="sm"
|
|
||||||
value={pageToggle}
|
|
||||||
onValueChange={(value: SystemMetric) => {
|
|
||||||
if (value) {
|
|
||||||
setPageToggle(value);
|
|
||||||
}
|
|
||||||
}} // don't allow the severity to be unselected
|
|
||||||
>
|
|
||||||
{Object.values(metrics).map((item) => (
|
|
||||||
<ToggleGroupItem
|
|
||||||
key={item}
|
key={item}
|
||||||
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
className={cn(
|
||||||
value={item}
|
"shrink-0 rounded-md px-3 py-4",
|
||||||
|
pageToggle != item && "*:text-muted-foreground",
|
||||||
|
)}
|
||||||
|
size="sm"
|
||||||
|
pressed={pageToggle == item}
|
||||||
|
onPressedChange={() => setPageToggle(item)}
|
||||||
aria-label={t("selectItem", {
|
aria-label={t("selectItem", {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
item: t(item + ".title"),
|
item: t(item + ".title"),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{item == "health" && <LuHeartPulse className="size-4" />}
|
{metricIcons[item]}
|
||||||
{item == "general" && <LuActivity className="size-4" />}
|
</Toggle>
|
||||||
{item == "enrichments" && <LuSearchCode className="size-4" />}
|
|
||||||
{item == "storage" && <LuHardDrive className="size-4" />}
|
|
||||||
{item == "cameras" && <FaVideo className="size-4" />}
|
|
||||||
{isDesktop && (
|
|
||||||
<div className="smart-capitalize">{t(item + ".title")}</div>
|
|
||||||
)}
|
|
||||||
</ToggleGroupItem>
|
|
||||||
))}
|
))}
|
||||||
</ToggleGroup>
|
activeIndex={metrics.indexOf(pageToggle)}
|
||||||
<ScrollBar orientation="horizontal" className="h-0" />
|
gapClassName="gap-0.5"
|
||||||
|
showAllLabel={t("showAllTabs")}
|
||||||
|
showLessLabel={t("showLessTabs")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
) : (
|
||||||
|
<ScrollArea className="whitespace-nowrap">
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<ToggleGroup
|
||||||
|
className="*:rounded-md *:px-3 *:py-4"
|
||||||
|
type="single"
|
||||||
|
size="sm"
|
||||||
|
value={pageToggle}
|
||||||
|
onValueChange={(value: SystemMetric) => {
|
||||||
|
if (value) {
|
||||||
|
setPageToggle(value);
|
||||||
|
}
|
||||||
|
}} // don't allow the severity to be unselected
|
||||||
|
>
|
||||||
|
{Object.values(metrics).map((item) => (
|
||||||
|
<ToggleGroupItem
|
||||||
|
key={item}
|
||||||
|
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||||
|
value={item}
|
||||||
|
aria-label={t("selectItem", {
|
||||||
|
ns: "common",
|
||||||
|
item: t(item + ".title"),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{metricIcons[item]}
|
||||||
|
<div className="smart-capitalize">{t(item + ".title")}</div>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
<ScrollBar orientation="horizontal" className="h-0" />
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex h-full items-center">
|
<div className="flex h-full items-center">
|
||||||
{pageToggle == "health" && (
|
{pageToggle == "health" && (
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ export default function LiveDashboardView({
|
|||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div className="relative flex h-11 items-center justify-between">
|
<div className="relative flex h-11 items-center justify-between">
|
||||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||||
<div className="w-[45%]">
|
<div className="w-[calc(50%-1rem)]">
|
||||||
<CameraGroupSelector />
|
<CameraGroupSelector />
|
||||||
</div>
|
</div>
|
||||||
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (
|
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (
|
||||||
|
|||||||
Reference in New Issue
Block a user