polygon item component, switch color, object form, hover cards

This commit is contained in:
Josh Hawkins 2024-04-15 10:12:41 -05:00
parent 673541b2b9
commit 5be3cf81ea
11 changed files with 405 additions and 282 deletions

View File

@ -4,33 +4,26 @@ 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, PolygonType } from "@/types/canvas"; import { Polygon, PolygonType } from "@/types/canvas";
import { interpolatePoints, toRGBColorString } from "@/utils/canvasUtil"; import { interpolatePoints } from "@/utils/canvasUtil";
import { 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, LuPlus } from "react-icons/lu"; import { LuExternalLink, LuInfo, LuPlus } from "react-icons/lu";
import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa"; import {
import { BsPersonBoundingBox } from "react-icons/bs"; HoverCard,
import { HiTrash } from "react-icons/hi"; HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
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";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import Heading from "../ui/heading"; import Heading from "../ui/heading";
import ZoneEditPane from "./ZoneEditPane"; import ZoneEditPane from "./ZoneEditPane";
import MotionMaskEditPane from "./MotionMaskEditPane"; import MotionMaskEditPane from "./MotionMaskEditPane";
import ObjectMaskEditPane from "./ObjectMaskEditPane"; import ObjectMaskEditPane from "./ObjectMaskEditPane";
import PolygonItem from "./PolygonItem";
import { Link } from "react-router-dom";
const parseCoordinates = (coordinatesString: string) => { const parseCoordinates = (coordinatesString: string) => {
const coordinates = coordinatesString.split(","); const coordinates = coordinatesString.split(",");
@ -45,25 +38,11 @@ const parseCoordinates = (coordinatesString: string) => {
return points; return points;
}; };
type PolygonItemProps = { // export type ZoneObjects = {
polygon: Polygon; // camera: string;
setAllPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>; // zoneName: string;
index: number; // objects: string[];
activePolygonIndex: number | undefined; // };
hoveredPolygonIndex: number | null;
setHoveredPolygonIndex: (index: number | null) => void;
deleteDialogOpen: boolean;
setDeleteDialogOpen: (open: boolean) => void;
setActivePolygonIndex: (index: number | undefined) => void;
setEditPane: (type: PolygonType) => void;
handleCopyCoordinates: (index: number) => void;
};
export type ZoneObjects = {
camera: string;
zoneName: string;
objects: string[];
};
type MasksAndZoneProps = { type MasksAndZoneProps = {
selectedCamera: string; selectedCamera: string;
@ -85,14 +64,14 @@ export default function MasksAndZones({
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
>(undefined); >(undefined);
const [hoveredPolygonIndex, setHoveredPolygonIndex] = useState<number | null>( const [hoveredPolygonIndex, setHoveredPolygonIndex] = useState<number | null>(
null, null,
); );
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
// const polygonTypes = [ // const polygonTypes = [
// "zone", // "zone",
@ -104,15 +83,15 @@ export default function MasksAndZones({
// type EditPaneType = (typeof polygonTypes)[number]; // type EditPaneType = (typeof polygonTypes)[number];
const [editPane, setEditPane] = useState<PolygonType | undefined>(undefined); const [editPane, setEditPane] = useState<PolygonType | undefined>(undefined);
const cameras = useMemo(() => { // const cameras = useMemo(() => {
if (!config) { // if (!config) {
return []; // return [];
} // }
return Object.values(config.cameras) // return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard && conf.enabled) // .filter((conf) => conf.ui.dashboard && conf.enabled)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); // .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]); // }, [config]);
const cameraConfig = useMemo(() => { const cameraConfig = useMemo(() => {
if (config && selectedCamera) { if (config && selectedCamera) {
@ -147,23 +126,23 @@ export default function MasksAndZones({
// [setZoneObjects], // [setZoneObjects],
// ); // );
const saveZoneObjects = useCallback( // const saveZoneObjects = useCallback(
(camera: string, zoneName: string, objects?: string[]) => { // (camera: string, zoneName: string, objects?: string[]) => {
setZoneObjects((prevZoneObjects) => { // setZoneObjects((prevZoneObjects) => {
const updatedZoneObjects = prevZoneObjects.map((zoneObject) => { // const updatedZoneObjects = prevZoneObjects.map((zoneObject) => {
if ( // if (
zoneObject.camera === camera && // zoneObject.camera === camera &&
zoneObject.zoneName === zoneName // zoneObject.zoneName === zoneName
) { // ) {
return { ...zoneObject, objects: objects || [] }; // return { ...zoneObject, objects: objects || [] };
} // }
return zoneObject; // return zoneObject;
}); // });
return updatedZoneObjects; // return updatedZoneObjects;
}); // });
}, // },
[setZoneObjects], // [setZoneObjects],
); // );
const [{ width: containerWidth, height: containerHeight }] = const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef); useResizeObserver(containerRef);
@ -200,6 +179,7 @@ export default function MasksAndZones({
}, [config, selectedCamera]); }, [config, selectedCamera]);
const stretch = true; const stretch = true;
// TODO: mobile / portrait cams
const fitAspect = 16 / 9; const fitAspect = 16 / 9;
const scaledHeight = useMemo(() => { const scaledHeight = useMemo(() => {
@ -272,18 +252,18 @@ export default function MasksAndZones({
}; };
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
console.log("handling cancel"); // console.log("handling cancel");
setEditPane(undefined); setEditPane(undefined);
console.log("all", allPolygons); // console.log("all", allPolygons);
console.log("editing", editingPolygons); // console.log("editing", editingPolygons);
// setAllPolygons(allPolygons.filter((poly) => !poly.isUnsaved)); // setAllPolygons(allPolygons.filter((poly) => !poly.isUnsaved));
setEditingPolygons([...allPolygons]); setEditingPolygons([...allPolygons]);
setActivePolygonIndex(undefined); setActivePolygonIndex(undefined);
setHoveredPolygonIndex(null); setHoveredPolygonIndex(null);
}, [allPolygons, editingPolygons]); }, [allPolygons]);
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
console.log("handling save"); // console.log("handling save");
setAllPolygons([...(editingPolygons ?? [])]); setAllPolygons([...(editingPolygons ?? [])]);
setActivePolygonIndex(undefined); setActivePolygonIndex(undefined);
setEditPane(undefined); setEditPane(undefined);
@ -398,7 +378,7 @@ export default function MasksAndZones({
: [], : [],
); );
console.log("setting all and editing"); // console.log("setting all and editing");
setAllPolygons([ setAllPolygons([
...zones, ...zones,
...motionMasks, ...motionMasks,
@ -428,7 +408,7 @@ export default function MasksAndZones({
if (editPane === undefined) { if (editPane === undefined) {
setEditingPolygons([...allPolygons]); setEditingPolygons([...allPolygons]);
setIsEditing(false); setIsEditing(false);
console.log("edit pane undefined, all", allPolygons); // console.log("edit pane undefined, all", allPolygons);
} else { } else {
setIsEditing(true); setIsEditing(true);
} }
@ -463,7 +443,7 @@ export default function MasksAndZones({
{cameraConfig && editingPolygons && ( {cameraConfig && editingPolygons && (
<div className="flex flex-col md:flex-row size-full"> <div className="flex flex-col md:flex-row size-full">
<Toaster position="top-center" /> <Toaster position="top-center" />
<div className="flex flex-col h-full w-full overflow-y-auto mt-2 md:mt-0 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 h-full w-full overflow-y-auto mt-2 md:mt-0 mb-10 md:mb-0 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={editingPolygons} polygons={editingPolygons}
@ -499,9 +479,33 @@ export default function MasksAndZones({
<div className="flex flex-col w-full"> <div className="flex flex-col w-full">
{(selectedZoneMask === undefined || {(selectedZoneMask === undefined ||
selectedZoneMask.includes("zone" as PolygonType)) && ( selectedZoneMask.includes("zone" as PolygonType)) && (
<div className="mt-0 pt-0"> <div className="mt-0 pt-0 last:pb-3 last:border-b-[1px] last:border-secondary">
<div className="flex flex-row justify-between items-center my-3"> <div className="flex flex-row justify-between items-center my-3">
<div className="text-md">Zones</div> <HoverCard>
<HoverCardTrigger asChild>
<div className="text-md cursor-default">Zones</div>
</HoverCardTrigger>
<HoverCardContent>
<div className="flex flex-col gap-2 text-sm text-primary-variant my-2">
<p>
Zones allow you to define a specific area of the
frame so you can determine whether or not an
object is within a particular area.
</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/zones"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Documentation{" "}
<LuExternalLink className="size-3 ml-2 inline-flex" />
</Link>
</div>
</div>
</HoverCardContent>
</HoverCard>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@ -530,8 +534,6 @@ export default function MasksAndZones({
activePolygonIndex={activePolygonIndex} activePolygonIndex={activePolygonIndex}
hoveredPolygonIndex={hoveredPolygonIndex} hoveredPolygonIndex={hoveredPolygonIndex}
setHoveredPolygonIndex={setHoveredPolygonIndex} setHoveredPolygonIndex={setHoveredPolygonIndex}
deleteDialogOpen={deleteDialogOpen}
setDeleteDialogOpen={setDeleteDialogOpen}
setActivePolygonIndex={setActivePolygonIndex} setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane} setEditPane={setEditPane}
setAllPolygons={setAllPolygons} setAllPolygons={setAllPolygons}
@ -544,9 +546,36 @@ export default function MasksAndZones({
selectedZoneMask.includes( selectedZoneMask.includes(
"motion_mask" as PolygonType, "motion_mask" as PolygonType,
)) && ( )) && (
<div className="first:mt-0 mt-3 first:pt-0 pt-3 border-t-[1px] first:border-transparent border-secondary"> <div className="first:mt-0 mt-3 first:pt-0 pt-3 last:pb-3 border-t-[1px] last:border-b-[1px] first:border-transparent border-secondary">
<div className="flex flex-row justify-between items-center my-3"> <div className="flex flex-row justify-between items-center my-3">
<div className="text-md">Motion Masks</div> <HoverCard>
<HoverCardTrigger asChild>
<div className="text-md cursor-default">
Motion Masks
</div>
</HoverCardTrigger>
<HoverCardContent>
<div className="flex flex-col gap-2 text-sm text-primary-variant my-2">
<p>
Motion masks are used to prevent unwanted types
of motion from triggering detection. Over
masking will make it more difficult for objects
to be tracked.
</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/masks#motion-masks"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Documentation{" "}
<LuExternalLink className="size-3 ml-2 inline-flex" />
</Link>
</div>
</div>
</HoverCardContent>
</HoverCard>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@ -577,8 +606,6 @@ export default function MasksAndZones({
activePolygonIndex={activePolygonIndex} activePolygonIndex={activePolygonIndex}
hoveredPolygonIndex={hoveredPolygonIndex} hoveredPolygonIndex={hoveredPolygonIndex}
setHoveredPolygonIndex={setHoveredPolygonIndex} setHoveredPolygonIndex={setHoveredPolygonIndex}
deleteDialogOpen={deleteDialogOpen}
setDeleteDialogOpen={setDeleteDialogOpen}
setActivePolygonIndex={setActivePolygonIndex} setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane} setEditPane={setEditPane}
setAllPolygons={setAllPolygons} setAllPolygons={setAllPolygons}
@ -591,9 +618,35 @@ export default function MasksAndZones({
selectedZoneMask.includes( selectedZoneMask.includes(
"object_mask" as PolygonType, "object_mask" as PolygonType,
)) && ( )) && (
<div className="first:mt-0 mt-3 first:pt-0 pt-3 border-t-[1px] first:border-transparent border-secondary"> <div className="first:mt-0 mt-3 first:pt-0 pt-3 last:pb-3 border-t-[1px] last:border-b-[1px] first:border-transparent border-secondary">
<div className="flex flex-row justify-between items-center my-3"> <div className="flex flex-row justify-between items-center my-3">
<div className="text-md">Object Masks</div> <HoverCard>
<HoverCardTrigger asChild>
<div className="text-md cursor-default">
Object Masks
</div>
</HoverCardTrigger>
<HoverCardContent>
<div className="flex flex-col gap-2 text-sm text-primary-variant my-2">
<p>
Object filter masks are used to filter out false
positives for a given object type based on
location.
</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/masks#object-filter-masks"
target="_blank"
rel="noopener noreferrer"
className="inline"
>
Documentation{" "}
<LuExternalLink className="size-3 ml-2 inline-flex" />
</Link>
</div>
</div>
</HoverCardContent>
</HoverCard>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@ -624,8 +677,6 @@ export default function MasksAndZones({
activePolygonIndex={activePolygonIndex} activePolygonIndex={activePolygonIndex}
hoveredPolygonIndex={hoveredPolygonIndex} hoveredPolygonIndex={hoveredPolygonIndex}
setHoveredPolygonIndex={setHoveredPolygonIndex} setHoveredPolygonIndex={setHoveredPolygonIndex}
deleteDialogOpen={deleteDialogOpen}
setDeleteDialogOpen={setDeleteDialogOpen}
setActivePolygonIndex={setActivePolygonIndex} setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane} setEditPane={setEditPane}
setAllPolygons={setAllPolygons} setAllPolygons={setAllPolygons}
@ -719,7 +770,7 @@ export default function MasksAndZones({
</div> </div>
<div <div
ref={containerRef} ref={containerRef}
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 max-h-[50%] md:max-h-full"
> >
<div className="flex flex-row justify-center mx-auto size-full"> <div className="flex flex-row justify-center mx-auto size-full">
{cameraConfig && {cameraConfig &&
@ -746,148 +797,3 @@ 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 ${
hoveredPolygonIndex === 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);
}}
>
<Tooltip>
<TooltipTrigger>
<LuPencil
className={`size-4 ${
hoveredPolygonIndex === index
? "text-primary"
: "text-muted-foreground"
}`}
/>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
</div>
<div
className="cursor-pointer"
onClick={() => handleCopyCoordinates(index)}
>
<Tooltip>
<TooltipTrigger>
<LuCopy
className={`size-4 ${
hoveredPolygonIndex === index
? "text-primary"
: "text-muted-foreground"
}`}
/>
</TooltipTrigger>
<TooltipContent>Copy coordinates</TooltipContent>
</Tooltip>
</div>
<div
className="cursor-pointer"
onClick={() => setDeleteDialogOpen(true)}
>
<Tooltip>
<TooltipTrigger>
<HiTrash
className={`size-4 ${
hoveredPolygonIndex === index
? "text-primary fill-primary"
: "text-muted-foreground fill-muted-foreground"
}`}
/>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
</div>
)}
</div>
);
}

View File

@ -12,7 +12,7 @@ import {
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ATTRIBUTES, FrigateConfig } from "@/types/frigateConfig"; import { ATTRIBUTE_LABELS, 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";

View File

@ -20,7 +20,7 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ATTRIBUTES, FrigateConfig } from "@/types/frigateConfig"; import { ATTRIBUTE_LABELS, 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";
@ -66,6 +66,7 @@ export default function ObjectMaskEditPane({
const formSchema = z const formSchema = z
.object({ .object({
objects: z.string(),
polygon: z.object({ isFinished: z.boolean() }), polygon: z.object({ isFinished: z.boolean() }),
}) })
.refine(() => polygon?.isFinished === true, { .refine(() => polygon?.isFinished === true, {
@ -77,6 +78,7 @@ export default function ObjectMaskEditPane({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
mode: "onChange", mode: "onChange",
defaultValues: { defaultValues: {
objects: polygon?.objects[0] ?? "all_labels",
polygon: { isFinished: polygon?.isFinished ?? false }, polygon: { isFinished: polygon?.isFinished ?? false },
}, },
}); });
@ -120,19 +122,37 @@ export default function ObjectMaskEditPane({
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="objects"
render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Objects</FormLabel> <FormLabel>Objects</FormLabel>
<FormDescription> <Select
The object type that that applies to this object mask. onValueChange={field.onChange}
</FormDescription> defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an object type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<ZoneObjectSelector <ZoneObjectSelector
camera={polygon.camera} camera={polygon.camera}
zoneName={polygon.name}
updateLabelFilter={(objects) => { updateLabelFilter={(objects) => {
// console.log(objects); // console.log(objects);
}} }}
/> />
</SelectContent>
</Select>
<FormDescription>
The object type that that applies to this object mask.
</FormDescription>
<FormMessage />
</FormItem> </FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="polygon.isFinished" name="polygon.isFinished"
@ -158,13 +178,11 @@ export default function ObjectMaskEditPane({
type ZoneObjectSelectorProps = { type ZoneObjectSelectorProps = {
camera: string; camera: string;
zoneName: string;
updateLabelFilter: (labels: string[] | undefined) => void; updateLabelFilter: (labels: string[] | undefined) => void;
}; };
export function ZoneObjectSelector({ export function ZoneObjectSelector({
camera, camera,
zoneName,
updateLabelFilter, updateLabelFilter,
}: ZoneObjectSelectorProps) { }: ZoneObjectSelectorProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -184,7 +202,7 @@ export function ZoneObjectSelector({
Object.values(config.cameras).forEach((camera) => { Object.values(config.cameras).forEach((camera) => {
camera.objects.track.forEach((label) => { camera.objects.track.forEach((label) => {
if (!ATTRIBUTES.includes(label)) { if (!ATTRIBUTE_LABELS.includes(label)) {
labels.add(label); labels.add(label);
} }
}); });
@ -201,13 +219,13 @@ export function ZoneObjectSelector({
const labels = new Set<string>(); const labels = new Set<string>();
cameraConfig.objects.track.forEach((label) => { cameraConfig.objects.track.forEach((label) => {
if (!ATTRIBUTES.includes(label)) { if (!ATTRIBUTE_LABELS.includes(label)) {
labels.add(label); labels.add(label);
} }
}); });
return [...labels].sort() || []; return [...labels].sort() || [];
}, [cameraConfig, zoneName]); }, [cameraConfig]);
const [currentLabels, setCurrentLabels] = useState<string[] | undefined>( const [currentLabels, setCurrentLabels] = useState<string[] | undefined>(
cameraLabels.every((label, index) => label === allLabels[index]) cameraLabels.every((label, index) => label === allLabels[index])
@ -221,27 +239,15 @@ export function ZoneObjectSelector({
return ( return (
<> <>
<div className="h-auto overflow-y-auto overflow-x-hidden">
<div className="my-2.5 flex flex-col gap-2.5">
<Select>
<SelectTrigger>
<SelectValue placeholder="Select object type" />
</SelectTrigger>
<SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem value="all_labels">All object types</SelectItem> <SelectItem value="all_labels">All object types</SelectItem>
<SelectSeparator className="bg-secondary" /> <SelectSeparator className="bg-secondary" />
{allLabels.map((item) => ( {allLabels.map((item) => (
<SelectItem key={item} value={item}> <SelectItem key={item} value={item}>
{item.replaceAll("_", " ").charAt(0).toUpperCase() + {item.replaceAll("_", " ").charAt(0).toUpperCase() + item.slice(1)}
item.slice(1)}
</SelectItem> </SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
</SelectContent>
</Select>
</div>
</div>
</> </>
); );
} }

View File

@ -0,0 +1,200 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { LuCopy, LuPencil } from "react-icons/lu";
import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa";
import { BsPersonBoundingBox } from "react-icons/bs";
import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi";
import { isMobile } from "react-device-detect";
import { toRGBColorString } from "@/utils/canvasUtil";
import { Polygon, PolygonType } from "@/types/canvas";
import { useState } from "react";
type PolygonItemProps = {
polygon: Polygon;
setAllPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
index: number;
activePolygonIndex: number | undefined;
hoveredPolygonIndex: number | null;
setHoveredPolygonIndex: (index: number | null) => void;
setActivePolygonIndex: (index: number | undefined) => void;
setEditPane: (type: PolygonType) => void;
handleCopyCoordinates: (index: number) => void;
};
export default function PolygonItem({
polygon,
setAllPolygons,
index,
activePolygonIndex,
hoveredPolygonIndex,
setHoveredPolygonIndex,
setActivePolygonIndex,
setEditPane,
handleCopyCoordinates,
}: PolygonItemProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const polygonTypeIcons = {
zone: FaDrawPolygon,
motion_mask: FaObjectGroup,
object_mask: BsPersonBoundingBox,
};
const PolygonItemIcon = polygon ? polygonTypeIcons[polygon.type] : undefined;
const handleDelete = (index: number) => {
setAllPolygons((oldPolygons) => {
return oldPolygons.filter((_, i) => i !== index);
});
setActivePolygonIndex(undefined);
};
return (
<div
key={index}
className="flex p-1 rounded-lg flex-row items-center justify-between mx-2 my-1.5 transition-background duration-100"
data-index={index}
onMouseEnter={() => setHoveredPolygonIndex(index)}
onMouseLeave={() => setHoveredPolygonIndex(null)}
style={{
backgroundColor:
hoveredPolygonIndex === index
? toRGBColorString(polygon.color, false)
: "",
}}
>
<div
className={`flex items-center ${
hoveredPolygonIndex === index
? "text-primary"
: "text-primary-variant"
}`}
>
{PolygonItemIcon && (
<PolygonItemIcon
className="size-5 mr-2"
style={{
fill: toRGBColorString(polygon.color, true),
color: toRGBColorString(polygon.color, true),
}}
/>
)}
<p className="cursor-default">{polygon.name}</p>
</div>
<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={() => handleDelete(index)}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{isMobile && (
<>
<DropdownMenu>
<DropdownMenuTrigger>
<HiOutlineDotsVertical className="size-5" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => {
setActivePolygonIndex(index);
setEditPane(polygon.type);
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopyCoordinates(index)}>
Copy
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
{!isMobile && hoveredPolygonIndex === index && (
<div className="flex flex-row gap-2 items-center">
<div
className="cursor-pointer size-[15px]"
onClick={() => {
setActivePolygonIndex(index);
setEditPane(polygon.type);
}}
>
<Tooltip>
<TooltipTrigger>
<LuPencil
className={`size-[15px] ${
hoveredPolygonIndex === index && "text-primary-variant"
}`}
/>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
</div>
<div
className="cursor-pointer size-[15px]"
onClick={() => handleCopyCoordinates(index)}
>
<Tooltip>
<TooltipTrigger>
<LuCopy
className={`size-[15px] ${
hoveredPolygonIndex === index && "text-primary-variant"
}`}
/>
</TooltipTrigger>
<TooltipContent>Copy coordinates</TooltipContent>
</Tooltip>
</div>
<div
className="cursor-pointer size-[15px]"
onClick={() => setDeleteDialogOpen(true)}
>
<Tooltip>
<TooltipTrigger>
<HiTrash
className={`size-[15px] ${
hoveredPolygonIndex === index &&
"text-primary-variant fill-primary-variant"
}`}
/>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
</div>
)}
</div>
);
}

View File

@ -12,7 +12,7 @@ import {
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ATTRIBUTES, FrigateConfig } from "@/types/frigateConfig"; import { ATTRIBUTE_LABELS, 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";
@ -344,7 +344,7 @@ export function ZoneObjectSelector({
Object.values(config.cameras).forEach((camera) => { Object.values(config.cameras).forEach((camera) => {
camera.objects.track.forEach((label) => { camera.objects.track.forEach((label) => {
if (!ATTRIBUTES.includes(label)) { if (!ATTRIBUTE_LABELS.includes(label)) {
labels.add(label); labels.add(label);
} }
}); });
@ -361,14 +361,14 @@ export function ZoneObjectSelector({
const labels = new Set<string>(); const labels = new Set<string>();
cameraConfig.objects.track.forEach((label) => { cameraConfig.objects.track.forEach((label) => {
if (!ATTRIBUTES.includes(label)) { if (!ATTRIBUTE_LABELS.includes(label)) {
labels.add(label); labels.add(label);
} }
}); });
if (cameraConfig.zones[zoneName]) { if (cameraConfig.zones[zoneName]) {
cameraConfig.zones[zoneName].objects.forEach((label) => { cameraConfig.zones[zoneName].objects.forEach((label) => {
if (!ATTRIBUTES.includes(label)) { if (!ATTRIBUTE_LABELS.includes(label)) {
labels.add(label); labels.add(label);
} }
}); });

View File

@ -18,6 +18,7 @@ const Switch = React.forwardRef<
<SwitchPrimitives.Thumb <SwitchPrimitives.Thumb
className={cn( className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-muted-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0", "pointer-events-none block h-5 w-5 rounded-full bg-muted-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
"data-[state=checked]:bg-background dark:data-[state=checked]:bg-primary",
)} )}
/> />
</SwitchPrimitives.Root> </SwitchPrimitives.Root>

View File

@ -63,9 +63,6 @@ export default function Settings() {
return ( return (
<div className="size-full p-2 flex flex-col"> <div className="size-full p-2 flex flex-col">
<div className="w-full h-11 relative flex justify-between items-center"> <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 <ToggleGroup
className="*:px-3 *:py-4 *:rounded-md" className="*:px-3 *:py-4 *:rounded-md"
type="single" type="single"
@ -107,7 +104,7 @@ export default function Settings() {
</div> </div>
)} )}
</div> </div>
<div className="mt-2 flex flex-col items-start w-full h-full md:h-dvh pb-9 md:pb-24"> <div className="mt-2 flex flex-col items-start w-full h-full md:h-dvh md:pb-24">
{page == "general" && <General />} {page == "general" && <General />}
{page == "objects" && <></>} {page == "objects" && <></>}
{page == "masks / zones" && ( {page == "masks / zones" && (

View File

@ -24,7 +24,7 @@ import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { DualThumbSlider } from "@/components/ui/slider"; import { DualThumbSlider } from "@/components/ui/slider";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import { ATTRIBUTES, FrigateConfig } from "@/types/frigateConfig"; import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig";
import axios from "axios"; import axios from "axios";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
@ -235,7 +235,7 @@ function PlusFilterGroup({
cameras.forEach((camera) => { cameras.forEach((camera) => {
const cameraConfig = config.cameras[camera]; const cameraConfig = config.cameras[camera];
cameraConfig.objects.track.forEach((label) => { cameraConfig.objects.track.forEach((label) => {
if (!ATTRIBUTES.includes(label)) { if (!ATTRIBUTE_LABELS.includes(label)) {
labels.add(label); labels.add(label);
} }
}); });

View File

@ -21,7 +21,13 @@ export interface BirdseyeConfig {
width: number; width: number;
} }
export const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"]; export const ATTRIBUTE_LABELS = [
"amazon",
"face",
"fedex",
"license_plate",
"ups",
];
export interface CameraConfig { export interface CameraConfig {
audio: { audio: {

View File

@ -53,6 +53,7 @@ module.exports = {
primary: { primary: {
DEFAULT: "hsl(var(--primary))", DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))", foreground: "hsl(var(--primary-foreground))",
variant: "hsl(var(--primary-variant))",
}, },
secondary: { secondary: {
DEFAULT: "hsl(var(--secondary))", DEFAULT: "hsl(var(--secondary))",

View File

@ -24,6 +24,9 @@
--primary: hsl(222.2, 37.4%, 11.2%); --primary: hsl(222.2, 37.4%, 11.2%);
--primary: 222.2 47.4% 11.2%; --primary: 222.2 47.4% 11.2%;
--primary-variant: hsl(222.2, 37.4%, 24.2%);
--primary-variant: 222.2 47.4% 24.2%;
--primary-foreground: hsl(210, 40%, 98%); --primary-foreground: hsl(210, 40%, 98%);
--primary-foreground: 210 40% 98%; --primary-foreground: 210 40% 98%;
@ -115,12 +118,15 @@
--popover: hsl(0, 0%, 15%); --popover: hsl(0, 0%, 15%);
--popover: 0, 0%, 15%; --popover: 0, 0%, 15%;
--popover-foreground: hsl(0, 0%, 100%); --popover-foreground: hsl(0, 0%, 98%);
--popover-foreground: 210 40% 98%; --popover-foreground: 0 0% 98%;
--primary: hsl(0, 0%, 91%); --primary: hsl(0, 0%, 91%);
--primary: 0 0% 91%; --primary: 0 0% 91%;
--primary-variant: hsl(0, 0%, 64%);
--primary-variant: 0 0% 64%;
--primary-foreground: hsl(0, 0%, 9%); --primary-foreground: hsl(0, 0%, 9%);
--primary-foreground: 0 0% 9%; --primary-foreground: 0 0% 9%;