mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-10 21:25:24 +03:00
merge dev
This commit is contained in:
parent
8d0385c991
commit
bd64e6f873
29
web/package-lock.json
generated
29
web/package-lock.json
generated
@ -21,6 +21,7 @@
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.1.2.tgz",
|
||||
@ -2568,11 +2592,6 @@
|
||||
"@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": {
|
||||
"version": "7.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
|
||||
@ -6,8 +6,8 @@ import { FrigateStats } from "@/types/stats";
|
||||
import { useFrigateStats } from "@/api/ws";
|
||||
import { useMemo } from "react";
|
||||
import useStats from "@/hooks/use-stats";
|
||||
import GeneralSettings from "../settings/GeneralSettings";
|
||||
import AccountSettings from "../settings/AccountSettings";
|
||||
import GeneralSettings from "../menu/GeneralSettings";
|
||||
import AccountSettings from "../menu/AccountSettings";
|
||||
import useNavigation from "@/hooks/use-navigation";
|
||||
|
||||
function Bottombar() {
|
||||
|
||||
@ -2,8 +2,8 @@ import Logo from "../Logo";
|
||||
import NavItem from "./NavItem";
|
||||
import { CameraGroupSelector } from "../filter/CameraGroupSelector";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import GeneralSettings from "../settings/GeneralSettings";
|
||||
import AccountSettings from "../settings/AccountSettings";
|
||||
import GeneralSettings from "../menu/GeneralSettings";
|
||||
import AccountSettings from "../menu/AccountSettings";
|
||||
import useNavigation from "@/hooks/use-navigation";
|
||||
|
||||
function Sidebar() {
|
||||
|
||||
40
web/src/components/settings/General.tsx
Normal file
40
web/src/components/settings/General.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,13 +1,4 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -23,12 +14,16 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { PolygonCanvas } from "./PolygonCanvas";
|
||||
import { Polygon } from "@/types/canvas";
|
||||
import { interpolatePoints } from "@/utils/canvasUtil";
|
||||
import { interpolatePoints, toRGBColorString } from "@/utils/canvasUtil";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import ZoneControls, { ZoneObjectSelector } from "./ZoneControls";
|
||||
import ZoneControls, {
|
||||
NewZoneButton,
|
||||
ZoneObjectSelector,
|
||||
} from "./NewZoneButton";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
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 coordinates = coordinatesString.split(",");
|
||||
@ -49,7 +44,15 @@ export type ZoneObjects = {
|
||||
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 [zonePolygons, setZonePolygons] = useState<Polygon[]>([]);
|
||||
const [zoneObjects, setZoneObjects] = useState<ZoneObjects[]>([]);
|
||||
@ -68,8 +71,6 @@ export default function SettingsZones() {
|
||||
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||
}, [config]);
|
||||
|
||||
const [selectedCamera, setSelectedCamera] = useState(cameras[0].name);
|
||||
|
||||
const cameraConfig = useMemo(() => {
|
||||
if (config && selectedCamera) {
|
||||
return config.cameras[selectedCamera];
|
||||
@ -137,7 +138,7 @@ export default function SettingsZones() {
|
||||
[setZoneObjects],
|
||||
);
|
||||
|
||||
const grow = useMemo(() => {
|
||||
const growe = useMemo(() => {
|
||||
if (!cameraConfig) {
|
||||
return;
|
||||
}
|
||||
@ -157,14 +158,51 @@ export default function SettingsZones() {
|
||||
}
|
||||
}, [cameraConfig]);
|
||||
|
||||
const handleCameraChange = useCallback(
|
||||
(camera: string) => {
|
||||
setSelectedCamera(camera);
|
||||
setActivePolygonIndex(null);
|
||||
const getCameraAspect = useCallback(
|
||||
(cam: string) => {
|
||||
if (!config) {
|
||||
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 }] =
|
||||
useResizeObserver(containerRef);
|
||||
|
||||
@ -173,13 +211,16 @@ export default function SettingsZones() {
|
||||
: { width: 1, height: 1 };
|
||||
const aspectRatio = width / height;
|
||||
|
||||
const stretch = false;
|
||||
const fitAspect = 0.75;
|
||||
const stretch = true;
|
||||
const fitAspect = 16 / 9;
|
||||
// console.log(containerRef.current?.clientHeight);
|
||||
|
||||
const scaledHeight = useMemo(() => {
|
||||
const scaledHeight =
|
||||
aspectRatio < (fitAspect ?? 0)
|
||||
? Math.floor(containerHeight)
|
||||
? Math.floor(
|
||||
Math.min(containerHeight, containerRef.current?.clientHeight),
|
||||
)
|
||||
: Math.floor(containerWidth / aspectRatio);
|
||||
const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height);
|
||||
|
||||
@ -244,57 +285,86 @@ export default function SettingsZones() {
|
||||
console.log("component zone objects", zoneObjects);
|
||||
}, [zoneObjects]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCamera) {
|
||||
setActivePolygonIndex(null);
|
||||
}
|
||||
}, [selectedCamera]);
|
||||
|
||||
if (!cameraConfig && !selectedCamera) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
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 && (
|
||||
<div className="flex flex-col justify-evenly">
|
||||
<div
|
||||
className={`flex flex-col justify-center items-center w-full md:w-[60%] ${grow}`}
|
||||
>
|
||||
<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 className="flex flex-col md:flex-row size-full">
|
||||
<div className="flex flex-col order-last w-full md:w-3/12 md:order-none md:mr-2">
|
||||
<div className="flex mb-3">
|
||||
<Separator />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full md:w-[30%]">
|
||||
<Table>
|
||||
<div className="flex flex-row justify-between items-center mb-3">
|
||||
<div className="text-md">Zones</div>
|
||||
<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>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Name</TableHead>
|
||||
@ -372,10 +442,29 @@ export default function SettingsZones() {
|
||||
0,
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -16,6 +16,7 @@ import { Button } from "../ui/button";
|
||||
import { ATTRIBUTES, FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { LuPlusSquare } from "react-icons/lu";
|
||||
|
||||
type ZoneObjectSelectorProps = {
|
||||
camera: string;
|
||||
@ -126,7 +127,7 @@ export function ZoneObjectSelector({
|
||||
);
|
||||
}
|
||||
|
||||
type ZoneControlsProps = {
|
||||
type NewZoneButtonProps = {
|
||||
camera: string;
|
||||
polygons: Polygon[];
|
||||
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
|
||||
@ -134,13 +135,13 @@ type ZoneControlsProps = {
|
||||
setActivePolygonIndex: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
};
|
||||
|
||||
export function ZoneControls({
|
||||
export function NewZoneButton({
|
||||
camera,
|
||||
polygons,
|
||||
setPolygons,
|
||||
activePolygonIndex,
|
||||
setActivePolygonIndex,
|
||||
}: ZoneControlsProps) {
|
||||
}: NewZoneButtonProps) {
|
||||
const { data: config } = useSWR("config");
|
||||
const [zoneName, setZoneName] = useState<string | null>();
|
||||
const [invalidName, setInvalidName] = useState<boolean>();
|
||||
@ -190,75 +191,76 @@ export function ZoneControls({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-between items-center my-5">
|
||||
<Button className="mr-5" variant="secondary" onClick={undo}>
|
||||
<div className="flex">
|
||||
{/* <Button className="mr-5" variant="secondary" onClick={undo}>
|
||||
Undo
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={reset}>
|
||||
Reset
|
||||
</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),
|
||||
);
|
||||
</Button> */}
|
||||
|
||||
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 && (
|
||||
<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);
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ZoneControls;
|
||||
export default NewZoneButton;
|
||||
@ -1,6 +1,6 @@
|
||||
import { useCallback, useState } from "react";
|
||||
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 Konva from "konva";
|
||||
import { Vector2d } from "konva/lib/types";
|
||||
@ -78,11 +78,7 @@ export default function PolygonDrawer({
|
||||
|
||||
const colorString = useCallback(
|
||||
(darkened: boolean) => {
|
||||
if (color.length !== 3) {
|
||||
return "rgb(220,0,0,0.5)";
|
||||
}
|
||||
|
||||
return `rgba(${color[2]},${color[1]},${color[0]},${darkened ? "0.8" : "0.5"})`;
|
||||
return toRGBColorString(color, darkened);
|
||||
},
|
||||
[color],
|
||||
);
|
||||
|
||||
29
web/src/components/ui/separator.tsx
Normal file
29
web/src/components/ui/separator.tsx
Normal 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 }
|
||||
@ -1,75 +1,191 @@
|
||||
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";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
// import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||
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() {
|
||||
return (
|
||||
<>
|
||||
<Heading as="h2">Settings</Heading>
|
||||
<div className="flex items-center space-x-2 mt-5">
|
||||
<Switch id="detect" checked={false} onCheckedChange={() => {}} />
|
||||
<Label htmlFor="detect">
|
||||
Always show PTZ controls for ONVIF cameras
|
||||
</Label>
|
||||
type CameraSelectButtonProps = {
|
||||
allCameras: CameraConfig[];
|
||||
selectedCamera: string;
|
||||
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
|
||||
};
|
||||
|
||||
function CameraSelectButton({
|
||||
allCameras,
|
||||
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 className="flex items-center space-x-2 mt-5">
|
||||
<Select>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Default Live Mode" />
|
||||
</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>
|
||||
</Button>
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
{isMobile && (
|
||||
<>
|
||||
<DropdownMenuLabel className="flex justify-center">
|
||||
Camera
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<div className="h-auto overflow-y-auto overflow-x-hidden pb-4 md:pb-0">
|
||||
{allCameras.map((item) => (
|
||||
<FilterCheckBox
|
||||
key={item.name}
|
||||
isChecked={item.name === selectedCamera}
|
||||
label={item.name}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
setSelectedCamera(item.name);
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</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() {
|
||||
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 (
|
||||
<div className="w-full h-full">
|
||||
<div className="flex h-full">
|
||||
<div className="flex-1 content-start gap-2 overflow-y-auto no-scrollbar mt-4 mr-5">
|
||||
<Tabs defaultValue="general" className="w-auto">
|
||||
<TabsList>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="objects">Objects</TabsTrigger>
|
||||
<TabsTrigger value="zones">Zones</TabsTrigger>
|
||||
<TabsTrigger value="masks">Masks</TabsTrigger>
|
||||
<TabsTrigger value="motion">Motion</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="general">
|
||||
<General />
|
||||
</TabsContent>
|
||||
<TabsContent value="objects">Objects</TabsContent>
|
||||
<TabsContent value="zones">
|
||||
<SettingsZones />
|
||||
</TabsContent>
|
||||
<TabsContent value="masks">Masks</TabsContent>
|
||||
<TabsContent value="motion">
|
||||
<MotionTuner />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="size-full p-2 flex flex-col">
|
||||
<div className="w-full h-11 relative flex justify-between items-center">
|
||||
{isMobile && (
|
||||
<Logo className="absolute inset-x-1/2 -translate-x-1/2 h-8" />
|
||||
)}
|
||||
<ToggleGroup
|
||||
className="*:px-3 *:py-4 *:rounded-md"
|
||||
type="single"
|
||||
size="sm"
|
||||
value={pageToggle}
|
||||
onValueChange={(value: SettingsType) => {
|
||||
if (value) {
|
||||
setPageToggle(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Object.values(settingsViews).map((item) => (
|
||||
<ToggleGroupItem
|
||||
key={item}
|
||||
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-gray-500"}`}
|
||||
value={item}
|
||||
aria-label={`Select ${item}`}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
|
||||
@ -63,3 +63,11 @@ export const interpolatePoints = (
|
||||
|
||||
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"})`;
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user