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 { isDesktop, isMobile } from "react-device-detect"; import { toRGBColorString } from "@/utils/canvasUtil"; import { Polygon, PolygonType } from "@/types/canvas"; import { useCallback, useMemo, useState } from "react"; import axios from "axios"; import { Toaster } from "@/components/ui/sonner"; import { toast } from "sonner"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { reviewQueries } from "@/utils/zoneEdutUtil"; import IconWrapper from "../ui/icon-wrapper"; import { buttonVariants } from "../ui/button"; import { Trans, useTranslation } from "react-i18next"; import ActivityIndicator from "../indicators/activity-indicator"; import { cn } from "@/lib/utils"; import { useMotionMaskState, useObjectMaskState, useZoneState } from "@/api/ws"; type PolygonItemProps = { polygon: Polygon; index: number; hoveredPolygonIndex: number | null; setHoveredPolygonIndex: (index: number | null) => void; setActivePolygonIndex: (index: number | undefined) => void; setEditPane: (type: PolygonType) => void; handleCopyCoordinates: (index: number) => void; isLoading: boolean; setIsLoading: (loading: boolean) => void; loadingPolygonIndex: number | undefined; setLoadingPolygonIndex: (index: number | undefined) => void; }; export default function PolygonItem({ polygon, index, hoveredPolygonIndex, setHoveredPolygonIndex, setActivePolygonIndex, setEditPane, handleCopyCoordinates, isLoading, setIsLoading, loadingPolygonIndex, setLoadingPolygonIndex, }: PolygonItemProps) { const { t } = useTranslation("views/settings"); const { data: config, mutate: updateConfig } = useSWR("config"); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const { payload: motionMaskState, send: sendMotionMaskState } = useMotionMaskState(polygon.camera, polygon.name); const { payload: objectMaskState, send: sendObjectMaskState } = useObjectMaskState(polygon.camera, polygon.name); const { payload: zoneState, send: sendZoneState } = useZoneState( polygon.camera, polygon.name, ); const isPolygonEnabled = useMemo(() => { const wsState = polygon.type === "zone" ? zoneState : polygon.type === "motion_mask" ? motionMaskState : objectMaskState; const wsEnabled = wsState === "ON" ? true : wsState === "OFF" ? false : undefined; return wsEnabled ?? polygon.enabled ?? true; }, [ polygon.enabled, polygon.type, zoneState, motionMaskState, objectMaskState, ]); const cameraConfig = useMemo(() => { if (polygon?.camera && config) { return config.cameras[polygon.camera]; } }, [polygon, config]); const polygonTypeIcons = { zone: FaDrawPolygon, motion_mask: FaObjectGroup, object_mask: BsPersonBoundingBox, }; const PolygonItemIcon = polygon ? polygonTypeIcons[polygon.type] : undefined; const saveToConfig = useCallback( async (polygon: Polygon) => { if (!polygon || !cameraConfig) { return; } const updateTopicType = polygon.type === "zone" ? "zones" : polygon.type === "motion_mask" ? "motion" : polygon.type === "object_mask" ? "objects" : polygon.type; setIsLoading(true); setLoadingPolygonIndex(index); if (polygon.type === "zone") { // Zones use query string format const { alertQueries, detectionQueries } = reviewQueries( polygon.name, false, false, polygon.camera, cameraConfig?.review.alerts.required_zones || [], cameraConfig?.review.detections.required_zones || [], ); const url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`; await axios .put(`config/set?${url}`, { requires_restart: 0, update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`, }) .then((res) => { if (res.status === 200) { toast.success( t("masksAndZones.form.polygonDrawing.delete.success", { name: polygon?.friendly_name ?? polygon?.name, }), { position: "top-center" }, ); updateConfig(); } else { toast.error( t("toast.save.error.title", { ns: "common", errorMessage: res.statusText, }), { position: "top-center" }, ); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("toast.save.error.title", { errorMessage, ns: "common" }), { position: "top-center" }, ); }) .finally(() => { setIsLoading(false); }); return; } // Motion masks and object masks use JSON body format // eslint-disable-next-line @typescript-eslint/no-explicit-any let configUpdate: any = {}; if (polygon.type === "motion_mask") { // Delete mask from motion.mask dict by setting it to undefined configUpdate = { cameras: { [polygon.camera]: { motion: { mask: { [polygon.name]: null, // Setting to null will delete the key }, }, }, }, }; } if (polygon.type === "object_mask") { // Determine if this is a global mask or object-specific mask const isGlobalMask = !polygon.objects.length; if (isGlobalMask) { configUpdate = { cameras: { [polygon.camera]: { objects: { mask: { [polygon.name]: null, // Setting to null will delete the key }, }, }, }, }; } else { configUpdate = { cameras: { [polygon.camera]: { objects: { filters: { [polygon.objects[0]]: { mask: { [polygon.name]: null, // Setting to null will delete the key }, }, }, }, }, }, }; } } await axios .put("config/set", { config_data: configUpdate, requires_restart: 0, update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`, }) .then((res) => { if (res.status === 200) { toast.success( t("masksAndZones.form.polygonDrawing.delete.success", { name: polygon?.friendly_name ?? polygon?.name, }), { position: "top-center" }, ); updateConfig(); } else { toast.error( t("toast.save.error.title", { ns: "common", errorMessage: res.statusText, }), { position: "top-center" }, ); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("toast.save.error.title", { errorMessage, ns: "common" }), { position: "top-center" }, ); }) .finally(() => { setIsLoading(false); setLoadingPolygonIndex(undefined); }); }, [ updateConfig, cameraConfig, t, setIsLoading, index, setLoadingPolygonIndex, ], ); const handleDelete = () => { setActivePolygonIndex(undefined); saveToConfig(polygon); }; const handleToggleEnabled = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); // Prevent toggling if disabled in config if (polygon.enabled_in_config === false) { return; } if (!polygon) { return; } const isEnabled = isPolygonEnabled; const nextState = isEnabled ? "OFF" : "ON"; if (polygon.type === "zone") { sendZoneState(nextState); return; } if (polygon.type === "motion_mask") { sendMotionMaskState(nextState); return; } if (polygon.type === "object_mask") { sendObjectMaskState(nextState); } }, [ isPolygonEnabled, polygon, sendZoneState, sendMotionMaskState, sendObjectMaskState, ], ); return ( <>
setHoveredPolygonIndex(index)} onMouseLeave={() => setHoveredPolygonIndex(null)} style={{ backgroundColor: hoveredPolygonIndex === index ? toRGBColorString(polygon.color, false) : "", }} >
{PolygonItemIcon && (isLoading && loadingPolygonIndex === index ? (
) : ( {polygon.enabled_in_config === false ? t("masksAndZones.disabledInConfig", { ns: "views/settings", }) : isPolygonEnabled ? t("button.disable", { ns: "common" }) : t("button.enable", { ns: "common" })} ))}

{polygon.friendly_name ?? polygon.name} {!isPolygonEnabled && " (disabled)"}

setDeleteDialogOpen(!deleteDialogOpen)} > {t("masksAndZones.form.polygonDrawing.delete.title")} masksAndZones.form.polygonDrawing.delete.desc {t("button.cancel", { ns: "common" })} {t("button.delete", { ns: "common" })} {isMobile && ( <> { setActivePolygonIndex(index); setEditPane(polygon.type); }} > {t("button.edit", { ns: "common" })} handleCopyCoordinates(index)} > {t("button.copy", { ns: "common" })} setDeleteDialogOpen(true)} > {t("button.delete", { ns: "common" })} )} {!isMobile && hoveredPolygonIndex === index && (
{ if (!isLoading) { setActivePolygonIndex(index); setEditPane(polygon.type); } }} /> {t("button.edit", { ns: "common" })} { if (!isLoading) { handleCopyCoordinates(index); } }} /> {t("button.copyCoordinates", { ns: "common" })} !isLoading && setDeleteDialogOpen(true)} /> {t("button.delete", { ns: "common" })}
)}
); }