add objects and unsaved to type

This commit is contained in:
Josh Hawkins 2024-04-13 23:23:54 -05:00
parent 72e7e67b29
commit 8deec1c9b6
10 changed files with 625 additions and 664 deletions

View File

@ -14,7 +14,7 @@ const variants = {
overlay: { overlay: {
active: "font-bold text-white bg-selected rounded-full", active: "font-bold text-white bg-selected rounded-full",
inactive: inactive:
"text-primary-white rounded-full bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500", "text-primary rounded-full bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500",
}, },
}; };

View File

@ -120,7 +120,6 @@ export function GeneralFilterContent({
))} ))}
</div> </div>
</div> </div>
<DropdownMenuSeparator />
</> </>
); );
} }

View File

@ -17,9 +17,19 @@ export function ZoneMaskFilterButton({
updateZoneMaskFilter, updateZoneMaskFilter,
}: ZoneMaskFilterButtonProps) { }: ZoneMaskFilterButtonProps) {
const trigger = ( const trigger = (
<Button size="sm" className="flex items-center gap-2"> <Button
<FaFilter className="text-secondary-foreground" /> size="sm"
<div className="hidden md:block text-primary">Filter</div> variant={selectedZoneMask?.length ? "select" : "default"}
className="flex items-center gap-2 capitalize"
>
<FaFilter
className={`${selectedZoneMask?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
<div
className={`hidden md:block ${selectedZoneMask?.length ? "text-selected-foreground" : "text-primary"}`}
>
Filter
</div>
</Button> </Button>
); );
const content = ( const content = (
@ -80,7 +90,7 @@ export function GeneralFilterContent({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="my-2.5 flex flex-col gap-2.5"> <div className="my-2.5 flex flex-col gap-2.5">
{["zone", "motion_mask", "object_mask"].map((item) => ( {["zone", "motion_mask", "object_mask"].map((item) => (
<div className="flex justify-between items-center"> <div key={item} className="flex justify-between items-center">
<Label <Label
className="w-full mx-2 text-primary capitalize cursor-pointer" className="w-full mx-2 text-primary capitalize cursor-pointer"
htmlFor={item} htmlFor={item}
@ -124,7 +134,6 @@ export function GeneralFilterContent({
))} ))}
</div> </div>
</div> </div>
<DropdownMenuSeparator />
</> </>
); );
} }

View File

@ -9,7 +9,7 @@ import { isDesktop, isMobile } from "react-device-detect";
import { Skeleton } from "../ui/skeleton"; import { Skeleton } from "../ui/skeleton";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import { LuCopy, LuPencil, LuPlusSquare, LuTrash } from "react-icons/lu"; import { LuCopy, LuPencil, LuPlusSquare, LuTrash } from "react-icons/lu";
import { FaDrawPolygon } from "react-icons/fa"; import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { toast } from "sonner"; import { toast } from "sonner";
import { Toaster } from "../ui/sonner"; import { Toaster } from "../ui/sonner";
@ -25,6 +25,8 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "../ui/alert-dialog"; } from "../ui/alert-dialog";
import { Separator } from "../ui/separator";
import { BsPersonBoundingBox } from "react-icons/bs";
const parseCoordinates = (coordinatesString: string) => { const parseCoordinates = (coordinatesString: string) => {
const coordinates = coordinatesString.split(","); const coordinates = coordinatesString.split(",");
@ -53,125 +55,6 @@ type PolygonItemProps = {
handleCopyCoordinates: (index: number) => void; handleCopyCoordinates: (index: number) => void;
}; };
function PolygonItem({
polygon,
setAllPolygons,
index,
activePolygonIndex,
hoveredPolygonIndex,
setHoveredPolygonIndex,
deleteDialogOpen,
setDeleteDialogOpen,
setActivePolygonIndex,
setEditPane,
handleCopyCoordinates,
}: PolygonItemProps) {
return (
<div
key={index}
className="flex p-1 rounded-lg flex-row items-center justify-between mx-2 mb-1 transition-background duration-100"
onMouseEnter={() => setHoveredPolygonIndex(index)}
onMouseLeave={() => setHoveredPolygonIndex(null)}
style={{
backgroundColor:
hoveredPolygonIndex === index
? toRGBColorString(polygon.color, false)
: "",
}}
>
{isMobile && <></>}
<div
className={`flex items-center ${
activePolygonIndex === index
? "text-primary"
: "text-muted-foreground"
}`}
>
<FaDrawPolygon
className="size-4 mr-2"
style={{
fill: toRGBColorString(polygon.color, true),
color: toRGBColorString(polygon.color, true),
}}
/>
<p className="cursor-default">{polygon.name}</p>
</div>
{deleteDialogOpen && (
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete this{" "}
{polygon.type.replace("_", " ")}?
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setAllPolygons((oldPolygons) => {
return oldPolygons.filter((_, i) => i !== index);
});
setActivePolygonIndex(undefined);
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{hoveredPolygonIndex === index && (
<div className="flex flex-row gap-2">
<div
className="cursor-pointer"
onClick={() => {
setActivePolygonIndex(index);
setEditPane(polygon.type);
}}
>
<LuPencil
className={`size-4 ${
activePolygonIndex === index
? "text-primary"
: "text-muted-foreground"
}`}
/>
</div>
<div
className="cursor-pointer"
onClick={() => handleCopyCoordinates(index)}
>
<LuCopy
className={`size-4 ${
activePolygonIndex === index
? "text-primary"
: "text-muted-foreground"
}`}
/>
</div>
<div
className="cursor-pointer"
onClick={() => setDeleteDialogOpen(true)}
>
<LuTrash
className={`size-4 ${
activePolygonIndex === index
? "text-primary fill-primary"
: "text-muted-foreground fill-muted-foreground"
}`}
/>
</div>
</div>
)}
</div>
);
}
export type ZoneObjects = { export type ZoneObjects = {
camera: string; camera: string;
zoneName: string; zoneName: string;
@ -180,16 +63,24 @@ export type ZoneObjects = {
type MasksAndZoneProps = { type MasksAndZoneProps = {
selectedCamera: string; selectedCamera: string;
selectedZoneMask: PolygonType; selectedZoneMask?: PolygonType[];
isEditing: boolean;
setIsEditing: React.Dispatch<React.SetStateAction<boolean>>;
unsavedChanges: boolean;
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
}; };
export default function MasksAndZones({ export default function MasksAndZones({
selectedCamera, selectedCamera,
selectedZoneMask, selectedZoneMask,
isEditing,
setIsEditing,
unsavedChanges,
setUnsavedChanges,
}: MasksAndZoneProps) { }: MasksAndZoneProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]); const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>(); const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
const [zoneObjects, setZoneObjects] = useState<ZoneObjects[]>([]); const [zoneObjects, setZoneObjects] = useState<ZoneObjects[]>([]);
const [activePolygonIndex, setActivePolygonIndex] = useState< const [activePolygonIndex, setActivePolygonIndex] = useState<
number | undefined number | undefined
@ -323,8 +214,6 @@ export default function MasksAndZones({
return finalHeight; return finalHeight;
} }
} }
return 100;
}, [ }, [
aspectRatio, aspectRatio,
containerWidth, containerWidth,
@ -338,32 +227,31 @@ export default function MasksAndZones({
if (aspectRatio && scaledHeight) { if (aspectRatio && scaledHeight) {
return Math.ceil(scaledHeight * aspectRatio); return Math.ceil(scaledHeight * aspectRatio);
} }
return 100;
}, [scaledHeight, aspectRatio]); }, [scaledHeight, aspectRatio]);
const handleNewPolygon = (type: PolygonType) => { const handleNewPolygon = (type: PolygonType) => {
setAllPolygons([ setActivePolygonIndex(allPolygons.length);
setEditingPolygons([
...(allPolygons || []), ...(allPolygons || []),
{ {
points: [], points: [],
isFinished: false, isFinished: false,
// isUnsaved: true, isUnsaved: true,
type, type,
name: "", name: "",
objects: [],
camera: selectedCamera, camera: selectedCamera,
color: [0, 0, 220], color: [0, 0, 220],
}, },
]); ]);
setActivePolygonIndex(allPolygons.length);
}; };
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
setEditPane(undefined); setEditPane(undefined);
// setAllPolygons(allPolygons.filter((poly) => !poly.isUnsaved)); setAllPolygons(allPolygons.filter((poly) => !poly.isUnsaved));
setActivePolygonIndex(undefined); setActivePolygonIndex(undefined);
setHoveredPolygonIndex(null); setHoveredPolygonIndex(null);
}, []); }, [allPolygons]);
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
setAllPolygons([...(editingPolygons ?? [])]); setAllPolygons([...(editingPolygons ?? [])]);
@ -374,7 +262,7 @@ export default function MasksAndZones({
const handleCopyCoordinates = useCallback( const handleCopyCoordinates = useCallback(
(index: number) => { (index: number) => {
if (allPolygons && scaledWidth) { if (allPolygons && scaledWidth && scaledHeight) {
const poly = allPolygons[index]; const poly = allPolygons[index];
copy( copy(
interpolatePoints(poly.points, scaledWidth, scaledHeight, 1, 1) interpolatePoints(poly.points, scaledWidth, scaledHeight, 1, 1)
@ -389,13 +277,16 @@ export default function MasksAndZones({
[allPolygons, scaledHeight, scaledWidth], [allPolygons, scaledHeight, scaledWidth],
); );
useEffect(() => {}, [editPane]);
useEffect(() => { useEffect(() => {
if (cameraConfig && containerRef.current && scaledWidth) { if (cameraConfig && containerRef.current && scaledWidth && scaledHeight) {
const zones = Object.entries(cameraConfig.zones).map( const zones = Object.entries(cameraConfig.zones).map(
([name, zoneData]) => ({ ([name, zoneData]) => ({
type: "zone" as PolygonType, type: "zone" as PolygonType,
camera: cameraConfig.name, camera: cameraConfig.name,
name, name,
objects: zoneData.objects,
points: interpolatePoints( points: interpolatePoints(
parseCoordinates(zoneData.coordinates), parseCoordinates(zoneData.coordinates),
1, 1,
@ -404,6 +295,7 @@ export default function MasksAndZones({
scaledHeight, scaledHeight,
), ),
isFinished: true, isFinished: true,
isUnsaved: false,
color: zoneData.color, color: zoneData.color,
}), }),
); );
@ -413,6 +305,7 @@ export default function MasksAndZones({
type: "motion_mask" as PolygonType, type: "motion_mask" as PolygonType,
camera: cameraConfig.name, camera: cameraConfig.name,
name: `Motion Mask ${index + 1}`, name: `Motion Mask ${index + 1}`,
objects: [],
points: interpolatePoints( points: interpolatePoints(
parseCoordinates(maskData), parseCoordinates(maskData),
1, 1,
@ -421,6 +314,7 @@ export default function MasksAndZones({
scaledHeight, scaledHeight,
), ),
isFinished: true, isFinished: true,
isUnsaved: false,
color: [0, 0, 255], color: [0, 0, 255],
}), }),
); );
@ -430,6 +324,7 @@ export default function MasksAndZones({
type: "object_mask" as PolygonType, type: "object_mask" as PolygonType,
camera: cameraConfig.name, camera: cameraConfig.name,
name: `All Objects Object Mask ${index + 1}`, name: `All Objects Object Mask ${index + 1}`,
objects: [],
points: interpolatePoints( points: interpolatePoints(
parseCoordinates(maskData), parseCoordinates(maskData),
1, 1,
@ -438,6 +333,7 @@ export default function MasksAndZones({
scaledHeight, scaledHeight,
), ),
isFinished: true, isFinished: true,
isUnsaved: false,
color: [0, 0, 255], color: [0, 0, 255],
}), }),
); );
@ -454,6 +350,7 @@ export default function MasksAndZones({
type: "object_mask" as PolygonType, type: "object_mask" as PolygonType,
camera: cameraConfig.name, camera: cameraConfig.name,
name: `${objectName.charAt(0).toUpperCase() + objectName.slice(1)} Object Mask ${globalObjectMasksCount + subIndex + 1}`, name: `${objectName.charAt(0).toUpperCase() + objectName.slice(1)} Object Mask ${globalObjectMasksCount + subIndex + 1}`,
objects: [objectName],
points: interpolatePoints( points: interpolatePoints(
parseCoordinates(maskItem), parseCoordinates(maskItem),
1, 1,
@ -462,6 +359,7 @@ export default function MasksAndZones({
scaledHeight, scaledHeight,
), ),
isFinished: true, isFinished: true,
isUnsaved: false,
color: [128, 128, 128], color: [128, 128, 128],
}, },
] ]
@ -487,26 +385,29 @@ export default function MasksAndZones({
} }
// we know that these deps are correct // we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [cameraConfig, containerRef]); }, [cameraConfig, containerRef, scaledHeight, scaledWidth]);
useEffect(() => { useEffect(() => {
if (editPane === undefined) { if (editPane === undefined) {
setEditingPolygons([...allPolygons]); setEditingPolygons([...allPolygons]);
setIsEditing(false);
console.log(allPolygons); console.log(allPolygons);
} else {
setIsEditing(true);
} }
}, [setEditingPolygons, allPolygons, editPane]); }, [setEditingPolygons, setIsEditing, allPolygons, editPane]);
// useEffect(() => { useEffect(() => {
// console.log( console.log(
// "config zone objects", "config zone objects",
// Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({ Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
// camera: cameraConfig.name, camera: cameraConfig.name,
// zoneName: name, zoneName: name,
// objects: Object.keys(zoneData.filters), objects: Object.keys(zoneData.filters),
// })), })),
// ); );
// console.log("component zone objects", zoneObjects); console.log("component zone objects", zoneObjects);
// }, [zoneObjects]); }, [zoneObjects]);
useEffect(() => { useEffect(() => {
if (selectedCamera) { if (selectedCamera) {
@ -518,8 +419,6 @@ export default function MasksAndZones({
return <ActivityIndicator />; return <ActivityIndicator />;
} }
// console.log(selectedZoneMask);
return ( return (
<> <>
{cameraConfig && allPolygons && ( {cameraConfig && allPolygons && (
@ -528,7 +427,7 @@ export default function MasksAndZones({
<div className="flex flex-col w-full overflow-y-auto md:w-3/12 order-last md:order-none md:mr-2 rounded-lg border-secondary-foreground border-[1px] p-2 bg-background_alt"> <div className="flex flex-col w-full overflow-y-auto md:w-3/12 order-last md:order-none md:mr-2 rounded-lg border-secondary-foreground border-[1px] p-2 bg-background_alt">
{editPane == "zone" && ( {editPane == "zone" && (
<ZoneEditPane <ZoneEditPane
polygons={allPolygons} polygons={editingPolygons}
activePolygonIndex={activePolygonIndex} activePolygonIndex={activePolygonIndex}
onCancel={handleCancel} onCancel={handleCancel}
onSave={handleSave} onSave={handleSave}
@ -536,16 +435,18 @@ export default function MasksAndZones({
)} )}
{editPane == "motion_mask" && ( {editPane == "motion_mask" && (
<ZoneEditPane <ZoneEditPane
polygons={allPolygons} polygons={editingPolygons}
activePolygonIndex={activePolygonIndex} activePolygonIndex={activePolygonIndex}
onCancel={handleCancel} onCancel={handleCancel}
onSave={handleSave}
/> />
)} )}
{editPane == "object_mask" && ( {editPane == "object_mask" && (
<ZoneEditPane <ZoneEditPane
polygons={allPolygons} polygons={editingPolygons}
activePolygonIndex={activePolygonIndex} activePolygonIndex={activePolygonIndex}
onCancel={handleCancel} onCancel={handleCancel}
onSave={handleSave}
/> />
)} )}
{editPane === undefined && ( {editPane === undefined && (
@ -553,7 +454,7 @@ export default function MasksAndZones({
{(selectedZoneMask === undefined || {(selectedZoneMask === undefined ||
selectedZoneMask.includes("zone" as PolygonType)) && ( selectedZoneMask.includes("zone" as PolygonType)) && (
<> <>
<div className="flex flex-row justify-between items-center mb-3"> <div className="flex flex-row justify-between items-center my-2">
<div className="text-md">Zones</div> <div className="text-md">Zones</div>
<Button <Button
variant="ghost" variant="ghost"
@ -588,10 +489,13 @@ export default function MasksAndZones({
))} ))}
</> </>
)} )}
<div className="flex my-2">
<Separator className="bg-secondary" />
</div>
{(selectedZoneMask === undefined || {(selectedZoneMask === undefined ||
selectedZoneMask.includes("motion_mask" as PolygonType)) && ( selectedZoneMask.includes("motion_mask" as PolygonType)) && (
<> <>
<div className="flex flex-row justify-between items-center my-3"> <div className="flex flex-row justify-between items-center my-2">
<div className="text-md">Motion Masks</div> <div className="text-md">Motion Masks</div>
<Button <Button
variant="ghost" variant="ghost"
@ -628,17 +532,20 @@ export default function MasksAndZones({
))} ))}
</> </>
)} )}
<div className="flex my-2">
<Separator className="bg-secondary" />
</div>
{(selectedZoneMask === undefined || {(selectedZoneMask === undefined ||
selectedZoneMask.includes("object_mask" as PolygonType)) && ( selectedZoneMask.includes("object_mask" as PolygonType)) && (
<> <>
<div className="flex flex-row justify-between items-center my-3"> <div className="flex flex-row justify-between items-center my-2">
<div className="text-md">Object Masks</div> <div className="text-md">Object Masks</div>
<Button <Button
variant="ghost" variant="ghost"
className="h-8 px-0" className="h-8 px-0"
onClick={() => { onClick={() => {
setEditPane("motion_mask"); setEditPane("object_mask");
handleNewPolygon("motion_mask"); handleNewPolygon("object_mask");
}} }}
> >
<LuPlusSquare /> <LuPlusSquare />
@ -755,12 +662,14 @@ export default function MasksAndZones({
className="flex md:w-7/12 md:grow md:h-dvh md:max-h-full" className="flex md:w-7/12 md:grow md:h-dvh md:max-h-full"
> >
<div className="size-full"> <div className="size-full">
{cameraConfig ? ( {cameraConfig &&
scaledWidth &&
scaledHeight &&
editingPolygons ? (
<PolygonCanvas <PolygonCanvas
camera={cameraConfig.name} camera={cameraConfig.name}
width={scaledWidth} width={scaledWidth}
height={scaledHeight} height={scaledHeight}
scale={1}
polygons={editingPolygons} polygons={editingPolygons}
setPolygons={setEditingPolygons} setPolygons={setEditingPolygons}
activePolygonIndex={activePolygonIndex} activePolygonIndex={activePolygonIndex}
@ -777,3 +686,133 @@ export default function MasksAndZones({
</> </>
); );
} }
function PolygonItem({
polygon,
setAllPolygons,
index,
activePolygonIndex,
hoveredPolygonIndex,
setHoveredPolygonIndex,
deleteDialogOpen,
setDeleteDialogOpen,
setActivePolygonIndex,
setEditPane,
handleCopyCoordinates,
}: PolygonItemProps) {
const polygonTypeIcons = {
zone: FaDrawPolygon,
motion_mask: FaObjectGroup,
object_mask: BsPersonBoundingBox,
};
const PolygonItemIcon = polygon ? polygonTypeIcons[polygon.type] : undefined;
return (
<div
key={index}
className="flex p-1 rounded-lg flex-row items-center justify-between mx-2 mb-1 transition-background duration-100"
data-index={index}
onMouseEnter={() => setHoveredPolygonIndex(index)}
onMouseLeave={() => setHoveredPolygonIndex(null)}
style={{
backgroundColor:
hoveredPolygonIndex === index
? toRGBColorString(polygon.color, false)
: "",
}}
>
{isMobile && <></>}
<div
className={`flex items-center ${
activePolygonIndex === index
? "text-primary"
: "text-muted-foreground"
}`}
>
{PolygonItemIcon && (
<PolygonItemIcon
className="size-4 mr-2"
style={{
fill: toRGBColorString(polygon.color, true),
color: toRGBColorString(polygon.color, true),
}}
/>
)}
<p className="cursor-default">{polygon.name}</p>
</div>
{deleteDialogOpen && hoveredPolygonIndex === index && (
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete the{" "}
{polygon.type.replace("_", " ")} <em>{polygon.name}</em>?
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setAllPolygons((oldPolygons) => {
return oldPolygons.filter((_, i) => i !== index);
});
setActivePolygonIndex(undefined);
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{hoveredPolygonIndex === index && (
<div className="flex flex-row gap-2">
<div
className="cursor-pointer"
onClick={() => {
setActivePolygonIndex(index);
setEditPane(polygon.type);
}}
>
<LuPencil
className={`size-4 ${
activePolygonIndex === index
? "text-primary"
: "text-muted-foreground"
}`}
/>
</div>
<div
className="cursor-pointer"
onClick={() => handleCopyCoordinates(index)}
>
<LuCopy
className={`size-4 ${
activePolygonIndex === index
? "text-primary"
: "text-muted-foreground"
}`}
/>
</div>
<div
className="cursor-pointer"
onClick={() => setDeleteDialogOpen(true)}
>
<LuTrash
className={`size-4 ${
activePolygonIndex === index
? "text-primary fill-primary"
: "text-muted-foreground fill-muted-foreground"
}`}
/>
</div>
</div>
)}
</div>
);
}

View File

@ -1,158 +0,0 @@
import { Polygon } from "@/types/canvas";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { useMemo, useState } from "react";
import { Input } from "../ui/input";
import { GeneralFilterContent } from "../filter/ReviewFilterGroup";
import { FaObjectGroup } from "react-icons/fa";
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 NewZoneButtonProps = {
camera: string;
polygons: Polygon[];
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
activePolygonIndex: number | null;
setActivePolygonIndex: React.Dispatch<React.SetStateAction<number | null>>;
};
export function NewZoneButton({
camera,
polygons,
setPolygons,
activePolygonIndex,
setActivePolygonIndex,
}: NewZoneButtonProps) {
const { data: config } = useSWR("config");
const [zoneName, setZoneName] = useState<string | null>();
const [invalidName, setInvalidName] = useState<boolean>();
const [dialogOpen, setDialogOpen] = useState(false);
const undo = () => {
if (activePolygonIndex !== null && polygons) {
const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex];
if (activePolygon.points.length > 0) {
updatedPolygons[activePolygonIndex] = {
...activePolygon,
points: activePolygon.points.slice(0, -1),
isFinished: false,
};
setPolygons(updatedPolygons);
}
}
};
const reset = () => {
if (activePolygonIndex !== null) {
const updatedPolygons = [...polygons];
updatedPolygons[activePolygonIndex] = {
points: [],
isFinished: false,
name: updatedPolygons[activePolygonIndex].name,
camera: camera,
color: updatedPolygons[activePolygonIndex].color ?? [220, 0, 0],
};
setPolygons(updatedPolygons);
}
};
const handleNewPolygon = (zoneName: string) => {
setPolygons([
...(polygons || []),
{
points: [],
isFinished: false,
// isUnsaved: true,
name: zoneName,
camera: camera,
color: [220, 0, 0],
},
]);
setActivePolygonIndex(polygons.length);
};
return (
<div className="flex">
{/* <Button className="mr-5" variant="secondary" onClick={undo}>
Undo
</Button>
<Button variant="secondary" onClick={reset}>
Reset
</Button> */}
<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);
}}
>
Continue
</Button>
</DialogFooter>
</>
</DialogContent>
</Dialog>
</div>
);
}
export default NewZoneButton;

View File

@ -11,19 +11,17 @@ type PolygonCanvasProps = {
camera: string; camera: string;
width: number; width: number;
height: number; height: number;
scale: number;
polygons: Polygon[]; polygons: Polygon[];
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>; setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
activePolygonIndex: number | undefined; activePolygonIndex: number | undefined;
hoveredPolygonIndex: number | null; hoveredPolygonIndex: number | null;
selectedZoneMask: PolygonType; selectedZoneMask: PolygonType[] | undefined;
}; };
export function PolygonCanvas({ export function PolygonCanvas({
camera, camera,
width, width,
height, height,
scale,
polygons, polygons,
setPolygons, setPolygons,
activePolygonIndex, activePolygonIndex,
@ -193,8 +191,6 @@ export function PolygonCanvas({
ref={stageRef} ref={stageRef}
width={width} width={width}
height={height} height={height}
scaleX={scale}
scaleY={scale}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onTouchStart={handleMouseDown} onTouchStart={handleMouseDown}
> >

View File

@ -0,0 +1,51 @@
import { Polygon } from "@/types/canvas";
import { Button } from "../ui/button";
type PolygonEditControlsProps = {
polygons: Polygon[];
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
activePolygonIndex: number | null;
};
export default function PolygonEditControls({
polygons,
setPolygons,
activePolygonIndex,
}: PolygonEditControlsProps) {
const undo = () => {
if (activePolygonIndex !== null && polygons) {
const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex];
if (activePolygon.points.length > 0) {
updatedPolygons[activePolygonIndex] = {
...activePolygon,
points: activePolygon.points.slice(0, -1),
isFinished: false,
};
setPolygons(updatedPolygons);
}
}
};
const reset = () => {
if (activePolygonIndex !== null) {
const updatedPolygons = [...polygons];
updatedPolygons[activePolygonIndex] = {
...updatedPolygons[activePolygonIndex],
points: [],
};
setPolygons(updatedPolygons);
}
};
return (
<div className="flex">
<Button className="mr-5" variant="secondary" onClick={undo}>
Undo
</Button>
<Button variant="secondary" onClick={reset}>
Reset
</Button>
</div>
);
}

View File

@ -11,12 +11,8 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { GeneralFilterContent } from "../filter/ReviewFilterGroup"; import { ATTRIBUTES, FrigateConfig } from "@/types/frigateConfig";
import { FaObjectGroup } from "react-icons/fa";
import { ATTRIBUTES, CameraConfig, 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 { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@ -26,6 +22,265 @@ import { Polygon } from "@/types/canvas";
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
type ZoneEditPaneProps = {
polygons?: Polygon[];
activePolygonIndex?: number;
onSave?: () => void;
onCancel?: () => void;
};
export function ZoneEditPane({
polygons,
activePolygonIndex,
onSave,
onCancel,
}: ZoneEditPaneProps) {
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 polygon = useMemo(() => {
if (polygons && activePolygonIndex !== undefined) {
return polygons[activePolygonIndex];
} else {
return null;
}
}, [polygons, activePolygonIndex]);
const formSchema = z
.object({
name: z
.string()
.min(2, {
message: "Zone name must be at least 2 characters.",
})
.transform((val: string) => val.trim().replace(/\s+/g, "_"))
.refine(
(value: string) => {
return !cameras.map((cam) => cam.name).includes(value);
},
{
message: "Zone name must not be the name of a camera.",
},
)
.refine(
(value: string) => {
const otherPolygonNames =
polygons
?.filter((_, index) => index !== activePolygonIndex)
.map((polygon) => polygon.name) || [];
return !otherPolygonNames.includes(value);
},
{
message: "Zone name already exists on this camera.",
},
),
inertia: z.coerce.number().min(1, {
message: "Inertia must be above 0.",
}),
loitering_time: z.coerce.number().min(0, {
message: "Loitering time must be greater than or equal to 0.",
}),
polygon: z.object({ isFinished: z.boolean() }),
})
.refine(() => polygon?.isFinished === true, {
message: "The polygon drawing must be finished before saving.",
path: ["polygon.isFinished"],
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
name: polygon?.name ?? "",
inertia:
((polygon?.camera &&
polygon?.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]
?.inertia) as number) || 3,
loitering_time:
((polygon?.camera &&
polygon?.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]
?.loitering_time) as number) || 0,
polygon: { isFinished: polygon?.isFinished ?? false },
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
polygons[activePolygonIndex].name = values.name;
console.log("form values", values);
console.log("active polygon", polygons[activePolygonIndex]);
// make sure polygon isFinished
onSave();
}
if (!polygon) {
return;
}
return (
<>
<Heading as="h3" className="my-2">
Zone
</Heading>
<div className="flex my-3">
<Separator className="bg-secondary" />
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Enter a name..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex my-2">
<Separator className="bg-secondary" />
</div>
<FormField
control={form.control}
name="inertia"
render={({ field }) => (
<FormItem>
<FormLabel>Inertia</FormLabel>
<FormControl>
<Input placeholder="3" {...field} />
</FormControl>
<FormDescription>
Specifies how many frames that an object must be in a zone
before they are considered in the zone.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex my-2">
<Separator className="bg-secondary" />
</div>
<FormField
control={form.control}
name="loitering_time"
render={({ field }) => (
<FormItem>
<FormLabel>Loitering Time</FormLabel>
<FormControl>
<Input placeholder="0" {...field} />
</FormControl>
<FormDescription>
Sets a minimum amount of time in seconds that the object must
be in the zone for it to activate.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex my-2">
<Separator className="bg-secondary" />
</div>
<FormItem>
<FormLabel>Objects</FormLabel>
<FormDescription>
List of objects that apply to this zone.
</FormDescription>
<ZoneObjectSelector
camera={polygon.camera}
zoneName={polygon.name}
updateLabelFilter={(objects) => {
// console.log(objects);
}}
/>
</FormItem>
<div className="flex my-2">
<Separator className="bg-secondary" />
</div>
<FormItem>
<FormLabel>Alerts and Detections</FormLabel>
<FormDescription>
When an object enters this zone, ensure it is marked as an alert
or detection.
</FormDescription>
<FormControl>
<div className="flex flex-col gap-2.5">
<div className="flex flex-row justify-between items-center">
<Label
className="text-primary cursor-pointer"
htmlFor="mark_alert"
>
Required for Alert
</Label>
<Switch
className="ml-1"
id="mark_alert"
checked={false}
onCheckedChange={(isChecked) => {
if (isChecked) {
return;
}
}}
/>
</div>
<div className="flex flex-row justify-between items-center">
<Label
className="text-primary cursor-pointer"
htmlFor="mark_detection"
>
Required for Detection
</Label>
<Switch
className="ml-1"
id="mark_detection"
checked={false}
onCheckedChange={(isChecked) => {
if (isChecked) {
return;
}
}}
/>
</div>
</div>
</FormControl>
</FormItem>
<FormField
control={form.control}
name="polygon.isFinished"
render={() => (
<FormItem>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row gap-2 pt-5">
<Button className="flex flex-1" onClick={onCancel}>
Cancel
</Button>
<Button variant="select" className="flex flex-1" type="submit">
Save
</Button>
</div>
</form>
</Form>
</>
);
}
type ZoneObjectSelectorProps = { type ZoneObjectSelectorProps = {
camera: string; camera: string;
zoneName: string; zoneName: string;
@ -101,10 +356,7 @@ export function ZoneObjectSelector({
<> <>
<div className="h-auto overflow-y-auto overflow-x-hidden"> <div className="h-auto overflow-y-auto overflow-x-hidden">
<div className="flex justify-between items-center my-2.5"> <div className="flex justify-between items-center my-2.5">
<Label <Label className="text-primary cursor-pointer" htmlFor="allLabels">
className="mx-2 text-primary cursor-pointer"
htmlFor="allLabels"
>
All Objects All Objects
</Label> </Label>
<Switch <Switch
@ -123,7 +375,7 @@ export function ZoneObjectSelector({
{allLabels.map((item) => ( {allLabels.map((item) => (
<div key={item} className="flex justify-between items-center"> <div key={item} className="flex justify-between items-center">
<Label <Label
className="w-full mx-2 text-primary capitalize cursor-pointer" className="w-full text-primary capitalize cursor-pointer"
htmlFor={item} htmlFor={item}
> >
{item.replaceAll("_", " ")} {item.replaceAll("_", " ")}
@ -161,241 +413,3 @@ export function ZoneObjectSelector({
</> </>
); );
} }
type ZoneEditPaneProps = {
polygons: Polygon[];
activePolygonIndex?: number;
onSave?: () => void;
onCancel?: () => void;
};
export function ZoneEditPane({
polygons,
activePolygonIndex,
onSave,
onCancel,
}: ZoneEditPaneProps) {
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 formSchema = z.object({
name: z
.string()
.min(2, {
message: "Zone name must be at least 2 characters.",
})
.transform((val: string) => val.trim().replace(/\s+/g, "_"))
.refine(
(value: string) => {
return !cameras.map((cam) => cam.name).includes(value);
},
{
message: "Zone name must not be the name of a camera.",
},
)
.refine(
(value: string) => {
return !polygons
.filter((polygon, index) => index !== activePolygonIndex)
.map((polygon) => polygon.name)
.includes(value);
},
{
message: "Zone name already exists on this camera.",
},
),
inertia: z.coerce.number().min(1, {
message: "Inertia must be above 0.",
}),
loitering_time: z.coerce.number().min(0, {
message: "Loitering time must be greater than or equal to 0.",
}),
});
const polygon = useMemo(() => {
if (polygons && activePolygonIndex !== undefined) {
return polygons[activePolygonIndex];
} else {
return null;
}
}, [polygons, activePolygonIndex]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
name: polygon?.name ?? "",
inertia:
((polygon &&
polygon.camera &&
polygon.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]
?.inertia) as number) ?? 3,
loitering_time:
((polygon &&
polygon.camera &&
polygon.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]
?.loitering_time) as number) ?? 0,
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values, polygons[activePolygonIndex]);
onSave();
}
if (!polygon) {
return;
}
return (
<>
<Heading as="h3">Zone</Heading>
<div className="flex my-3">
<Separator className="bg-secondary" />
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder={polygon.name} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex my-3">
<Separator className="bg-secondary" />
</div>
<FormField
control={form.control}
name="inertia"
render={({ field }) => (
<FormItem>
<FormLabel>Inertia</FormLabel>
<FormControl>
<Input placeholder="" {...field} />
</FormControl>
<FormDescription>
Specifies how many frames that an object must be in a zone
before they are considered in the zone.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex my-3">
<Separator className="bg-secondary" />
</div>
<FormField
control={form.control}
name="loitering_time"
render={({ field }) => (
<FormItem>
<FormLabel>Loitering Time</FormLabel>
<FormControl>
<Input placeholder="" {...field} />
</FormControl>
<FormDescription>
Sets a minimum amount of time in seconds that the object must
be in the zone for it to activate.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex my-3">
<Separator className="bg-secondary" />
</div>
<FormItem>
<FormLabel>Objects</FormLabel>
<FormDescription>
List of objects that apply to this zone.
</FormDescription>
<ZoneObjectSelector
camera={polygon.camera}
zoneName={polygon.name}
updateLabelFilter={(objects) => {
// console.log(objects);
}}
/>
</FormItem>
<div className="flex my-3">
<Separator className="bg-secondary" />
</div>
<FormItem>
<FormLabel>Alerts and Detections</FormLabel>
<FormDescription>
When an object enters this zone, ensure it is marked as an alert
or detection.
</FormDescription>
<FormControl>
<div className="flex flex-col gap-2.5">
<div className="flex flex-row justify-between items-center">
<Label
className="mx-2 text-primary cursor-pointer"
htmlFor="mark_alert"
>
Required for Alert
</Label>
<Switch
className="ml-1"
id="mark_alert"
checked={false}
onCheckedChange={(isChecked) => {
if (isChecked) {
return;
}
}}
/>
</div>
<div className="flex flex-row justify-between items-center">
<Label
className="mx-2 text-primary cursor-pointer"
htmlFor="mark_detection"
>
Required for Detection
</Label>
<Switch
className="ml-1"
id="mark_detection"
checked={false}
onCheckedChange={(isChecked) => {
if (isChecked) {
return;
}
}}
/>
</div>
</div>
</FormControl>
</FormItem>
<div className="flex flex-row gap-2 pt-5">
<Button className="flex flex-1" onClick={onCancel}>
Cancel
</Button>
<Button variant="select" className="flex flex-1" type="submit">
Save
</Button>
</div>
</form>
</Form>
</>
);
}

View File

@ -23,6 +23,103 @@ import FilterCheckBox from "@/components/filter/FilterCheckBox";
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter"; import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
import { PolygonType } from "@/types/canvas"; import { PolygonType } from "@/types/canvas";
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 [isEditing, setIsEditing] = useState(false);
const [unsavedChanges, setUnsavedChanges] = useState(false);
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);
const [filterZoneMask, setFilterZoneMask] = useState<PolygonType[]>();
return (
<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">
{!isEditing && (
<ZoneMaskFilterButton
selectedZoneMask={filterZoneMask}
updateZoneMaskFilter={setFilterZoneMask}
/>
)}
{/* {isEditing && page == "masks / zones" && (<PolygonEditControls /)} */}
<CameraSelectButton
allCameras={cameras}
selectedCamera={selectedCamera}
setSelectedCamera={setSelectedCamera}
/>
</div>
)}
</div>
<div className="mt-2 flex flex-col items-start w-full h-dvh md:pb-24">
{page == "general" && <General />}
{page == "objects" && <></>}
{page == "masks / zones" && (
<MasksAndZones
selectedCamera={selectedCamera}
selectedZoneMask={filterZoneMask}
isEditing={isEditing}
setIsEditing={setIsEditing}
unsavedChanges={unsavedChanges}
setUnsavedChanges={setUnsavedChanges}
/>
)}
{page == "motion tuner" && <MotionTuner />}
</div>
</div>
);
}
type CameraSelectButtonProps = { type CameraSelectButtonProps = {
allCameras: CameraConfig[]; allCameras: CameraConfig[];
selectedCamera: string; selectedCamera: string;
@ -111,90 +208,3 @@ function CameraSelectButton({
</DropdownMenu> </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);
const [filterZoneMask, setFilterZoneMask] = useState<PolygonType[]>();
return (
<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">
<ZoneMaskFilterButton
selectedZoneMask={filterZoneMask}
updateZoneMaskFilter={setFilterZoneMask}
/>
<CameraSelectButton
allCameras={cameras}
selectedCamera={selectedCamera}
setSelectedCamera={setSelectedCamera}
/>
</div>
)}
</div>
<div className="mt-2 flex flex-col items-start w-full h-dvh md:pb-24">
{page == "general" && <General />}
{page == "objects" && <></>}
{page == "masks / zones" && (
<MasksAndZones
selectedCamera={selectedCamera}
selectedZoneMask={filterZoneMask}
/>
)}
{page == "motion tuner" && <MotionTuner />}
</div>
</div>
);
}

View File

@ -4,8 +4,9 @@ export type Polygon = {
camera: string; camera: string;
name: string; name: string;
type: PolygonType; type: PolygonType;
objects: string[];
points: number[][]; points: number[][];
isFinished: boolean; isFinished: boolean;
// isUnsaved: boolean; isUnsaved: boolean;
color: number[]; color: number[];
}; };