merge dev

This commit is contained in:
Josh Hawkins 2024-04-11 15:48:35 -05:00
parent 8d0385c991
commit bd64e6f873
13 changed files with 511 additions and 211 deletions

29
web/package-lock.json generated
View File

@ -21,6 +21,7 @@
"@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-switch": "^1.0.3",
@ -1650,6 +1651,29 @@
} }
} }
}, },
"node_modules/@radix-ui/react-separator": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.3.tgz",
"integrity": "sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-primitive": "1.0.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider": { "node_modules/@radix-ui/react-slider": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.1.2.tgz",
@ -2568,11 +2592,6 @@
"@types/react": "*" "@types/react": "*"
} }
}, },
"node_modules/@types/scheduler": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
"integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A=="
},
"node_modules/@types/semver": { "node_modules/@types/semver": {
"version": "7.5.6", "version": "7.5.6",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",

View File

@ -26,6 +26,7 @@
"@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-switch": "^1.0.3",

View File

@ -6,8 +6,8 @@ import { FrigateStats } from "@/types/stats";
import { useFrigateStats } from "@/api/ws"; import { useFrigateStats } from "@/api/ws";
import { useMemo } from "react"; import { useMemo } from "react";
import useStats from "@/hooks/use-stats"; import useStats from "@/hooks/use-stats";
import GeneralSettings from "../settings/GeneralSettings"; import GeneralSettings from "../menu/GeneralSettings";
import AccountSettings from "../settings/AccountSettings"; import AccountSettings from "../menu/AccountSettings";
import useNavigation from "@/hooks/use-navigation"; import useNavigation from "@/hooks/use-navigation";
function Bottombar() { function Bottombar() {

View File

@ -2,8 +2,8 @@ import Logo from "../Logo";
import NavItem from "./NavItem"; import NavItem from "./NavItem";
import { CameraGroupSelector } from "../filter/CameraGroupSelector"; import { CameraGroupSelector } from "../filter/CameraGroupSelector";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import GeneralSettings from "../settings/GeneralSettings"; import GeneralSettings from "../menu/GeneralSettings";
import AccountSettings from "../settings/AccountSettings"; import AccountSettings from "../menu/AccountSettings";
import useNavigation from "@/hooks/use-navigation"; import useNavigation from "@/hooks/use-navigation";
function Sidebar() { function Sidebar() {

View File

@ -0,0 +1,40 @@
import Heading from "@/components/ui/heading";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
export default function General() {
return (
<>
<Heading as="h2">Settings</Heading>
<div className="flex items-center space-x-2 mt-5">
<Switch id="lowdata" checked={false} onCheckedChange={() => {}} />
<Label htmlFor="lowdata">Low Data Mode (this device only)</Label>
</div>
<div className="flex items-center space-x-2 mt-5">
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Another General Option" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Live Mode</SelectLabel>
<SelectItem value="jsmpeg">JSMpeg</SelectItem>
<SelectItem value="mse">MSE</SelectItem>
<SelectItem value="webrtc">WebRTC</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</>
);
}

View File

@ -1,13 +1,4 @@
import Heading from "@/components/ui/heading"; import { Separator } from "@/components/ui/separator";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Table, Table,
TableBody, TableBody,
@ -23,12 +14,16 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { PolygonCanvas } from "./PolygonCanvas"; import { PolygonCanvas } from "./PolygonCanvas";
import { Polygon } from "@/types/canvas"; import { Polygon } from "@/types/canvas";
import { interpolatePoints } from "@/utils/canvasUtil"; import { interpolatePoints, toRGBColorString } from "@/utils/canvasUtil";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import ZoneControls, { ZoneObjectSelector } from "./ZoneControls"; import ZoneControls, {
NewZoneButton,
ZoneObjectSelector,
} from "./NewZoneButton";
import { Skeleton } from "../ui/skeleton"; import { Skeleton } from "../ui/skeleton";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import { LuPencil } from "react-icons/lu"; import { LuCopy, LuPencil, LuPlusSquare, LuTrash } from "react-icons/lu";
import { FaDrawPolygon } from "react-icons/fa";
const parseCoordinates = (coordinatesString: string) => { const parseCoordinates = (coordinatesString: string) => {
const coordinates = coordinatesString.split(","); const coordinates = coordinatesString.split(",");
@ -49,7 +44,15 @@ export type ZoneObjects = {
objects: string[]; objects: string[];
}; };
export default function SettingsZones() { type MasksAndZoneProps = {
selectedCamera: string;
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
};
export default function MasksAndZones({
selectedCamera,
setSelectedCamera,
}: MasksAndZoneProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [zonePolygons, setZonePolygons] = useState<Polygon[]>([]); const [zonePolygons, setZonePolygons] = useState<Polygon[]>([]);
const [zoneObjects, setZoneObjects] = useState<ZoneObjects[]>([]); const [zoneObjects, setZoneObjects] = useState<ZoneObjects[]>([]);
@ -68,8 +71,6 @@ export default function SettingsZones() {
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]); }, [config]);
const [selectedCamera, setSelectedCamera] = useState(cameras[0].name);
const cameraConfig = useMemo(() => { const cameraConfig = useMemo(() => {
if (config && selectedCamera) { if (config && selectedCamera) {
return config.cameras[selectedCamera]; return config.cameras[selectedCamera];
@ -137,7 +138,7 @@ export default function SettingsZones() {
[setZoneObjects], [setZoneObjects],
); );
const grow = useMemo(() => { const growe = useMemo(() => {
if (!cameraConfig) { if (!cameraConfig) {
return; return;
} }
@ -157,14 +158,51 @@ export default function SettingsZones() {
} }
}, [cameraConfig]); }, [cameraConfig]);
const handleCameraChange = useCallback( const getCameraAspect = useCallback(
(camera: string) => { (cam: string) => {
setSelectedCamera(camera); if (!config) {
setActivePolygonIndex(null); return undefined;
}
const camera = config.cameras[cam];
if (!camera) {
return undefined;
}
return camera.detect.width / camera.detect.height;
}, },
[setSelectedCamera, setActivePolygonIndex], [config],
); );
const mainCameraAspect = useMemo(() => {
const aspectRatio = getCameraAspect(selectedCamera);
if (!aspectRatio) {
return "normal";
} else if (aspectRatio > 2) {
return "wide";
} else if (aspectRatio < 16 / 9) {
return "tall";
} else {
return "normal";
}
}, [getCameraAspect, selectedCamera]);
const grow = useMemo(() => {
if (mainCameraAspect == "wide") {
return "w-full aspect-wide";
} else if (mainCameraAspect == "tall") {
if (isDesktop) {
return "size-full aspect-tall flex flex-col justify-center";
} else {
return "size-full";
}
} else {
return "w-full aspect-video";
}
}, [mainCameraAspect]);
const [{ width: containerWidth, height: containerHeight }] = const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef); useResizeObserver(containerRef);
@ -173,13 +211,16 @@ export default function SettingsZones() {
: { width: 1, height: 1 }; : { width: 1, height: 1 };
const aspectRatio = width / height; const aspectRatio = width / height;
const stretch = false; const stretch = true;
const fitAspect = 0.75; const fitAspect = 16 / 9;
// console.log(containerRef.current?.clientHeight);
const scaledHeight = useMemo(() => { const scaledHeight = useMemo(() => {
const scaledHeight = const scaledHeight =
aspectRatio < (fitAspect ?? 0) aspectRatio < (fitAspect ?? 0)
? Math.floor(containerHeight) ? Math.floor(
Math.min(containerHeight, containerRef.current?.clientHeight),
)
: Math.floor(containerWidth / aspectRatio); : Math.floor(containerWidth / aspectRatio);
const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height); const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height);
@ -244,57 +285,86 @@ export default function SettingsZones() {
console.log("component zone objects", zoneObjects); console.log("component zone objects", zoneObjects);
}, [zoneObjects]); }, [zoneObjects]);
useEffect(() => {
if (selectedCamera) {
setActivePolygonIndex(null);
}
}, [selectedCamera]);
if (!cameraConfig && !selectedCamera) { if (!cameraConfig && !selectedCamera) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
return ( return (
<div className="overflow-auto"> <>
<Heading as="h2">Zones</Heading>
<div className="flex items-center space-x-2 mt-5">
<Select value={selectedCamera} onValueChange={handleCameraChange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Camera" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Choose a camera</SelectLabel>
{cameras.map((camera) => (
<SelectItem
key={camera.name}
value={`${camera.name}`}
className="capitalize"
>
{camera.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
{cameraConfig && ( {cameraConfig && (
<div className="flex flex-col justify-evenly"> <div className="flex flex-col md:flex-row size-full">
<div <div className="flex flex-col order-last w-full md:w-3/12 md:order-none md:mr-2">
className={`flex flex-col justify-center items-center w-full md:w-[60%] ${grow}`} <div className="flex mb-3">
> <Separator />
<div ref={containerRef} className="size-full">
{cameraConfig ? (
<PolygonCanvas
camera={cameraConfig.name}
width={scaledWidth}
height={scaledHeight}
polygons={zonePolygons}
setPolygons={setZonePolygons}
activePolygonIndex={activePolygonIndex}
/>
) : (
<Skeleton className="w-full h-full" />
)}
</div> </div>
</div> <div className="flex flex-row justify-between items-center mb-3">
<div className="w-full md:w-[30%]"> <div className="text-md">Zones</div>
<Table> <NewZoneButton
camera={cameraConfig.name}
polygons={zonePolygons}
setPolygons={setZonePolygons}
activePolygonIndex={activePolygonIndex}
setActivePolygonIndex={setActivePolygonIndex}
/>
</div>
{zonePolygons.map((polygon, index) => (
<div
key={index}
className="flex p-1 rounded-lg flex-row items-center justify-between mx-2 mb-1"
style={{
backgroundColor:
activePolygonIndex === index
? toRGBColorString(polygon.color, false)
: "",
}}
>
<div
className={`flex items-center ${activePolygonIndex === index ? "text-primary" : "text-secondary-foreground"}`}
>
<FaDrawPolygon
className="size-4 mr-2"
style={{
fill: toRGBColorString(polygon.color, true),
color: toRGBColorString(polygon.color, true),
}}
/>
{polygon.name}
</div>
<div className="flex flex-row gap-2">
<div
className="cursor-pointer"
onClick={() => setActivePolygonIndex(index)}
>
<LuPencil
className={`size-4 ${activePolygonIndex === index ? "text-primary" : "text-secondary-foreground"}`}
/>
</div>
<LuCopy
className={`size-4 ${activePolygonIndex === index ? "text-primary" : "text-secondary-foreground"}`}
/>
<div
className="cursor-pointer"
onClick={() => {
setZonePolygons((oldPolygons) => {
return oldPolygons.filter((_, i) => i !== index);
});
setActivePolygonIndex(null);
}}
>
<LuTrash
className={`size-4 ${activePolygonIndex === index ? "text-primary fill-primary" : "text-secondary-foreground fill-secondary-foreground"}`}
/>
</div>
</div>
</div>
))}
{/* <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[100px]">Name</TableHead> <TableHead className="w-[100px]">Name</TableHead>
@ -372,10 +442,29 @@ export default function SettingsZones() {
0, 0,
)} )}
</pre> </pre>
</div> */}
</div>
<div
ref={containerRef}
className="flex md:w-7/12 md:grow md:h-dvh md:max-h-[90%]"
>
<div className="size-full">
{cameraConfig ? (
<PolygonCanvas
camera={cameraConfig.name}
width={scaledWidth}
height={scaledHeight}
polygons={zonePolygons}
setPolygons={setZonePolygons}
activePolygonIndex={activePolygonIndex}
/>
) : (
<Skeleton className="w-full h-full" />
)}
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </>
); );
} }

View File

@ -16,6 +16,7 @@ import { Button } from "../ui/button";
import { ATTRIBUTES, FrigateConfig } from "@/types/frigateConfig"; import { ATTRIBUTES, FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr"; import useSWR from "swr";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { LuPlusSquare } from "react-icons/lu";
type ZoneObjectSelectorProps = { type ZoneObjectSelectorProps = {
camera: string; camera: string;
@ -126,7 +127,7 @@ export function ZoneObjectSelector({
); );
} }
type ZoneControlsProps = { type NewZoneButtonProps = {
camera: string; camera: string;
polygons: Polygon[]; polygons: Polygon[];
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>; setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
@ -134,13 +135,13 @@ type ZoneControlsProps = {
setActivePolygonIndex: React.Dispatch<React.SetStateAction<number | null>>; setActivePolygonIndex: React.Dispatch<React.SetStateAction<number | null>>;
}; };
export function ZoneControls({ export function NewZoneButton({
camera, camera,
polygons, polygons,
setPolygons, setPolygons,
activePolygonIndex, activePolygonIndex,
setActivePolygonIndex, setActivePolygonIndex,
}: ZoneControlsProps) { }: NewZoneButtonProps) {
const { data: config } = useSWR("config"); const { data: config } = useSWR("config");
const [zoneName, setZoneName] = useState<string | null>(); const [zoneName, setZoneName] = useState<string | null>();
const [invalidName, setInvalidName] = useState<boolean>(); const [invalidName, setInvalidName] = useState<boolean>();
@ -190,75 +191,76 @@ export function ZoneControls({
}; };
return ( return (
<div className="flex flex-col"> <div className="flex">
<div className="flex justify-between items-center my-5"> {/* <Button className="mr-5" variant="secondary" onClick={undo}>
<Button className="mr-5" variant="secondary" onClick={undo}>
Undo Undo
</Button> </Button>
<Button variant="secondary" onClick={reset}> <Button variant="secondary" onClick={reset}>
Reset Reset
</Button> </Button> */}
<Button variant="secondary" onClick={() => setDialogOpen(true)}>
New Zone
</Button>
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
setZoneName("");
}
}}
>
<DialogContent>
{isMobile && <span tabIndex={0} className="sr-only" />}
<DialogTitle>New Zone</DialogTitle>
<DialogDescription>
Enter a unique label for your zone. Do not include spaces, and
don't use the name of a camera.
</DialogDescription>
<>
<Input
className={`mt-3 ${isMobile && "text-md"}`}
type="search"
value={zoneName ?? ""}
onChange={(e) => {
setInvalidName(
Object.keys(config.cameras).includes(e.target.value) ||
e.target.value.includes(" ") ||
polygons
.map((item) => item.name)
.includes(e.target.value),
);
setZoneName(e.target.value); <Button
variant="ghost"
className="h-8 px-0"
onClick={() => setDialogOpen(true)}
>
<LuPlusSquare />
</Button>
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
setZoneName("");
}
}}
>
<DialogContent>
{isMobile && <span tabIndex={0} className="sr-only" />}
<DialogTitle>New Zone</DialogTitle>
<DialogDescription>
Enter a unique label for your zone. Do not include spaces, and don't
use the name of a camera.
</DialogDescription>
<>
<Input
className={`mt-3 ${isMobile && "text-md"}`}
type="search"
value={zoneName ?? ""}
onChange={(e) => {
setInvalidName(
Object.keys(config.cameras).includes(e.target.value) ||
e.target.value.includes(" ") ||
polygons.map((item) => item.name).includes(e.target.value),
);
setZoneName(e.target.value);
}}
/>
{invalidName && (
<div className="text-danger text-sm">Invalid zone name.</div>
)}
<DialogFooter>
<Button
size="sm"
variant="select"
disabled={invalidName || (zoneName?.length ?? 0) == 0}
onClick={() => {
if (zoneName) {
setDialogOpen(false);
handleNewPolygon(zoneName);
}
setZoneName(null);
}} }}
/> >
{invalidName && ( Continue
<div className="text-danger text-sm">Invalid zone name.</div> </Button>
)} </DialogFooter>
<DialogFooter> </>
<Button </DialogContent>
size="sm" </Dialog>
variant="select"
disabled={invalidName || (zoneName?.length ?? 0) == 0}
onClick={() => {
if (zoneName) {
setDialogOpen(false);
handleNewPolygon(zoneName);
}
setZoneName(null);
}}
>
Continue
</Button>
</DialogFooter>
</>
</DialogContent>
</Dialog>
</div>
</div> </div>
); );
} }
export default ZoneControls; export default NewZoneButton;

View File

@ -1,6 +1,6 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Line, Circle, Group } from "react-konva"; import { Line, Circle, Group } from "react-konva";
import { minMax, dragBoundFunc } from "@/utils/canvasUtil"; import { minMax, toRGBColorString, dragBoundFunc } from "@/utils/canvasUtil";
import type { KonvaEventObject } from "konva/lib/Node"; import type { KonvaEventObject } from "konva/lib/Node";
import Konva from "konva"; import Konva from "konva";
import { Vector2d } from "konva/lib/types"; import { Vector2d } from "konva/lib/types";
@ -78,11 +78,7 @@ export default function PolygonDrawer({
const colorString = useCallback( const colorString = useCallback(
(darkened: boolean) => { (darkened: boolean) => {
if (color.length !== 3) { return toRGBColorString(color, darkened);
return "rgb(220,0,0,0.5)";
}
return `rgba(${color[2]},${color[1]},${color[0]},${darkened ? "0.8" : "0.5"})`;
}, },
[color], [color],
); );

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@ -1,75 +1,191 @@
import Heading from "@/components/ui/heading";
import { Label } from "@/components/ui/label";
import { import {
Select, DropdownMenu,
SelectContent, DropdownMenuContent,
SelectGroup, DropdownMenuLabel,
SelectItem, DropdownMenuSeparator,
SelectLabel, DropdownMenuTrigger,
SelectTrigger, } from "@/components/ui/dropdown-menu";
SelectValue, // import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
} from "@/components/ui/select"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Switch } from "@/components/ui/switch"; import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import MotionTuner from "@/components/settings/MotionTuner"; import MotionTuner from "@/components/settings/MotionTuner";
import SettingsZones from "@/components/settings/Zones"; import MasksAndZones from "@/components/settings/MasksAndZones";
import { Button } from "@/components/ui/button";
import { useMemo, useState } from "react";
import useOptimisticState from "@/hooks/use-optimistic-state";
import Logo from "@/components/Logo";
import { isMobile } from "react-device-detect";
import { FaVideo } from "react-icons/fa";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import General from "@/components/settings/General";
import FilterCheckBox from "@/components/filter/FilterCheckBox";
function General() { type CameraSelectButtonProps = {
return ( allCameras: CameraConfig[];
<> selectedCamera: string;
<Heading as="h2">Settings</Heading> setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
<div className="flex items-center space-x-2 mt-5"> };
<Switch id="detect" checked={false} onCheckedChange={() => {}} />
<Label htmlFor="detect"> function CameraSelectButton({
Always show PTZ controls for ONVIF cameras allCameras,
</Label> selectedCamera,
setSelectedCamera,
}: CameraSelectButtonProps) {
const [open, setOpen] = useState(false);
const trigger = (
<Button
className="flex items-center gap-2 capitalize bg-selected hover:bg-selected"
size="sm"
>
<FaVideo className="text-background dark:text-primary" />
<div className="hidden md:block text-background dark:text-primary">
{selectedCamera == undefined ? "No Camera" : selectedCamera}
</div> </div>
</Button>
<div className="flex items-center space-x-2 mt-5"> );
<Select> const content = (
<SelectTrigger className="w-[180px]"> <>
<SelectValue placeholder="Default Live Mode" /> {isMobile && (
</SelectTrigger> <>
<SelectContent> <DropdownMenuLabel className="flex justify-center">
<SelectGroup> Camera
<SelectLabel>Live Mode</SelectLabel> </DropdownMenuLabel>
<SelectItem value="jsmpeg">JSMpeg</SelectItem> <DropdownMenuSeparator />
<SelectItem value="mse">MSE</SelectItem> </>
<SelectItem value="webrtc">WebRTC</SelectItem> )}
</SelectGroup> <div className="h-auto overflow-y-auto overflow-x-hidden pb-4 md:pb-0">
</SelectContent> {allCameras.map((item) => (
</Select> <FilterCheckBox
key={item.name}
isChecked={item.name === selectedCamera}
label={item.name}
onCheckedChange={(isChecked) => {
if (isChecked) {
setSelectedCamera(item.name);
setOpen(false);
}
}}
/>
))}
</div> </div>
</> </>
); );
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(open: boolean) => {
if (!open) {
setSelectedCamera(selectedCamera);
}
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<DropdownMenu
open={open}
onOpenChange={(open: boolean) => {
if (!open) {
setSelectedCamera(selectedCamera);
}
setOpen(open);
}}
>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>{content}</DropdownMenuContent>
</DropdownMenu>
);
} }
export default function Settings() { export default function Settings() {
const settingsViews = [
"general",
"objects",
"masks / zones",
"motion tuner",
] as const;
type SettingsType = (typeof settingsViews)[number];
const [page, setPage] = useState<SettingsType>("general");
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
const { data: config } = useSWR<FrigateConfig>("config");
const cameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard && conf.enabled)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const [selectedCamera, setSelectedCamera] = useState(cameras[0].name);
return ( return (
<div className="w-full h-full"> <div className="size-full p-2 flex flex-col">
<div className="flex h-full"> <div className="w-full h-11 relative flex justify-between items-center">
<div className="flex-1 content-start gap-2 overflow-y-auto no-scrollbar mt-4 mr-5"> {isMobile && (
<Tabs defaultValue="general" className="w-auto"> <Logo className="absolute inset-x-1/2 -translate-x-1/2 h-8" />
<TabsList> )}
<TabsTrigger value="general">General</TabsTrigger> <ToggleGroup
<TabsTrigger value="objects">Objects</TabsTrigger> className="*:px-3 *:py-4 *:rounded-md"
<TabsTrigger value="zones">Zones</TabsTrigger> type="single"
<TabsTrigger value="masks">Masks</TabsTrigger> size="sm"
<TabsTrigger value="motion">Motion</TabsTrigger> value={pageToggle}
</TabsList> onValueChange={(value: SettingsType) => {
<TabsContent value="general"> if (value) {
<General /> setPageToggle(value);
</TabsContent> }
<TabsContent value="objects">Objects</TabsContent> }}
<TabsContent value="zones"> >
<SettingsZones /> {Object.values(settingsViews).map((item) => (
</TabsContent> <ToggleGroupItem
<TabsContent value="masks">Masks</TabsContent> key={item}
<TabsContent value="motion"> className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-gray-500"}`}
<MotionTuner /> value={item}
</TabsContent> aria-label={`Select ${item}`}
</Tabs> >
</div> <div className="capitalize">{item}</div>
</ToggleGroupItem>
))}
</ToggleGroup>
{(page == "objects" ||
page == "masks / zones" ||
page == "motion tuner") && (
<div className="flex items-center gap-2">
<CameraSelectButton
allCameras={cameras}
selectedCamera={selectedCamera}
setSelectedCamera={setSelectedCamera}
/>
</div>
)}
</div>
<div className="mt-2 flex flex-col items-start">
{page == "general" && <General />}
{page == "objects" && <></>}
{page == "masks / zones" && (
<MasksAndZones
selectedCamera={selectedCamera}
setSelectedCamera={setSelectedCamera}
/>
)}
{page == "motion tuner" && <MotionTuner />}
</div> </div>
</div> </div>
); );

View File

@ -63,3 +63,11 @@ export const interpolatePoints = (
return newPoints; return newPoints;
}; };
export const toRGBColorString = (color: number[], darkened: boolean) => {
if (color.length !== 3) {
return "rgb(220,0,0,0.5)";
}
return `rgba(${color[2]},${color[1]},${color[0]},${darkened ? "0.9" : "0.5"})`;
};