frigate/web/src/components/settings/PolygonItem.tsx
Josh Hawkins 6fdd65ddb5
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
UI tweaks (#23346)
* remove redundant per-view toasters in settings

* add variants to standardize dialog footer button layouts

* remove text-md

this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html

* make wizard footers consistent with dialog footers

* consistent destructive button style

remove text-white from individual buttons and add it to the variant
2026-05-29 16:00:30 -06:00

641 lines
21 KiB
TypeScript

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 { useCallback, useMemo, useState } from "react";
import axios from "axios";
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 "@/components/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";
import { getProfileColor } from "@/utils/profileColors";
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;
editingProfile?: string | null;
allProfileNames?: string[];
onDeleted?: () => void;
};
export default function PolygonItem({
polygon,
index,
hoveredPolygonIndex,
setHoveredPolygonIndex,
setActivePolygonIndex,
setEditPane,
handleCopyCoordinates,
isLoading,
setIsLoading,
loadingPolygonIndex,
setLoadingPolygonIndex,
editingProfile,
allProfileNames,
onDeleted,
}: PolygonItemProps) {
const { t } = useTranslation("views/settings");
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("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 isBasePolygon = !!editingProfile && polygon.polygonSource === "base";
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;
const updateTopic = editingProfile
? undefined
: `config/cameras/${polygon.camera}/${updateTopicType}`;
setIsLoading(true);
setLoadingPolygonIndex(index);
if (polygon.type === "zone") {
let url: string;
if (editingProfile) {
// Profile mode: just delete the profile zone
url = `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}`;
} else {
// Base mode: handle review queries
const { alertQueries, detectionQueries } = reviewQueries(
polygon.name,
false,
false,
polygon.camera,
cameraConfig?.review.alerts.required_zones || [],
cameraConfig?.review.detections.required_zones || [],
);
// Also delete from profiles that have overrides for this zone
let profileQueries = "";
if (allProfileNames && cameraConfig) {
for (const profileName of allProfileNames) {
if (
cameraConfig.profiles?.[profileName]?.zones?.[polygon.name] !==
undefined
) {
profileQueries += `&cameras.${polygon.camera}.profiles.${profileName}.zones.${polygon.name}`;
}
}
}
url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}${profileQueries}`;
}
await axios
.put(`config/set?${url}`, {
requires_restart: 0,
update_topic: updateTopic,
})
.then((res) => {
if (res.status === 200) {
toast.success(
t("masksAndZones.form.polygonDrawing.delete.success", {
name: polygon?.friendly_name ?? polygon?.name,
}),
{ position: "top-center" },
);
updateConfig();
onDeleted?.();
} 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
const deleteSection =
polygon.type === "motion_mask"
? { motion: { mask: { [polygon.name]: null } } }
: !polygon.objects.length
? { objects: { mask: { [polygon.name]: null } } }
: {
objects: {
filters: {
[polygon.objects[0]]: {
mask: { [polygon.name]: null },
},
},
},
};
let cameraUpdate: Record<string, unknown>;
if (editingProfile) {
cameraUpdate = { profiles: { [editingProfile]: deleteSection } };
} else {
// Base mode: also delete from profiles that have overrides for this mask
const profileDeletes: Record<string, unknown> = {};
if (allProfileNames && cameraConfig) {
for (const profileName of allProfileNames) {
const profileData = cameraConfig.profiles?.[profileName];
if (!profileData) continue;
const hasMask =
polygon.type === "motion_mask"
? profileData.motion?.mask?.[polygon.name] !== undefined
: polygon.type === "object_mask"
? profileData.objects?.mask?.[polygon.name] !== undefined ||
Object.values(profileData.objects?.filters || {}).some(
(f) => f?.mask?.[polygon.name] !== undefined,
)
: false;
if (hasMask) {
profileDeletes[profileName] = deleteSection;
}
}
}
cameraUpdate =
Object.keys(profileDeletes).length > 0
? { ...deleteSection, profiles: profileDeletes }
: deleteSection;
}
const configUpdate = {
cameras: {
[polygon.camera]: cameraUpdate,
},
};
await axios
.put("config/set", {
config_data: configUpdate,
requires_restart: 0,
update_topic: updateTopic,
})
.then((res) => {
if (res.status === 200) {
toast.success(
t("masksAndZones.form.polygonDrawing.delete.success", {
name: polygon?.friendly_name ?? polygon?.name,
}),
{ position: "top-center" },
);
updateConfig();
onDeleted?.();
} 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,
editingProfile,
allProfileNames,
onDeleted,
],
);
const handleDelete = () => {
setActivePolygonIndex(undefined);
saveToConfig(polygon);
};
const handleToggleEnabled = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
// Prevent toggling if disabled in config or if this is a base polygon in profile mode
if (polygon.enabled_in_config === false || isBasePolygon) {
return;
}
if (!polygon) {
return;
}
// Don't toggle via WS in profile mode
if (editingProfile) {
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,
isBasePolygon,
editingProfile,
],
);
return (
<>
<div
key={index}
className="transition-background relative my-1.5 flex flex-row items-center justify-between rounded-lg p-1 duration-100"
data-index={index}
onMouseEnter={() => setHoveredPolygonIndex(index)}
onMouseLeave={() => setHoveredPolygonIndex(null)}
style={{
backgroundColor:
hoveredPolygonIndex === index
? toRGBColorString(polygon.color, false)
: "",
}}
>
<div
className={`flex min-w-0 items-center ${
hoveredPolygonIndex === index
? "text-primary"
: "text-primary-variant"
}`}
>
{PolygonItemIcon &&
(isLoading && loadingPolygonIndex === index ? (
<div className="mr-2">
<ActivityIndicator className="size-5" />
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleToggleEnabled}
disabled={
isLoading ||
polygon.enabled_in_config === false ||
isBasePolygon ||
!!editingProfile
}
className="mr-2 shrink-0 cursor-pointer border-none bg-transparent p-0 transition-opacity hover:opacity-70 disabled:cursor-not-allowed disabled:opacity-50"
>
<PolygonItemIcon
className="size-5"
style={{
fill: toRGBColorString(polygon.color, isPolygonEnabled),
color: toRGBColorString(
polygon.color,
isPolygonEnabled,
),
}}
/>
</button>
</TooltipTrigger>
<TooltipContent>
{polygon.enabled_in_config === false
? t("masksAndZones.disabledInConfig", {
ns: "views/settings",
})
: isPolygonEnabled
? t("button.disable", { ns: "common" })
: t("button.enable", { ns: "common" })}
</TooltipContent>
</Tooltip>
))}
{editingProfile &&
(polygon.polygonSource === "profile" ||
polygon.polygonSource === "override") &&
allProfileNames && (
<span
className={cn(
"mr-1.5 inline-block h-2 w-2 shrink-0 rounded-full",
getProfileColor(editingProfile, allProfileNames).dot,
)}
/>
)}
<p
className={cn(
"cursor-default",
!isPolygonEnabled && "opacity-60",
polygon.enabled_in_config === false && "line-through",
isBasePolygon && "opacity-50",
)}
>
{polygon.friendly_name ?? polygon.name}
{!isPolygonEnabled && " (disabled)"}
{isBasePolygon && (
<span className="ml-1 text-xs text-muted-foreground">
{t("masksAndZones.profileBase", { ns: "views/settings" })}
</span>
)}
{polygon.polygonSource === "override" && (
<span className="ml-1 text-xs text-muted-foreground">
{t("masksAndZones.profileOverride", { ns: "views/settings" })}
</span>
)}
</p>
</div>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{polygon.polygonSource === "override"
? t("masksAndZones.form.polygonDrawing.revertOverride.title")
: t("masksAndZones.form.polygonDrawing.delete.title")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
{polygon.polygonSource === "override" ? (
<Trans
ns="views/settings"
values={{
type: t(
`masksAndZones.form.polygonDrawing.type.${polygon.type}`,
{ ns: "views/settings" },
),
name: polygon.friendly_name ?? polygon.name,
}}
>
masksAndZones.form.polygonDrawing.revertOverride.desc
</Trans>
) : (
<Trans
ns="views/settings"
values={{
type: t(
`masksAndZones.form.polygonDrawing.type.${polygon.type}`,
{ ns: "views/settings" },
),
name: polygon.friendly_name ?? polygon.name,
}}
>
masksAndZones.form.polygonDrawing.delete.desc
</Trans>
)}
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={handleDelete}
>
{polygon.polygonSource === "override"
? t("masksAndZones.form.polygonDrawing.revertOverride.title")
: t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{isMobile && (
<>
<DropdownMenu>
<DropdownMenuTrigger>
<HiOutlineDotsVertical className="size-5" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
aria-label={t("button.edit", { ns: "common" })}
disabled={isLoading}
onClick={() => {
setActivePolygonIndex(index);
setEditPane(polygon.type);
}}
>
{t("button.edit", { ns: "common" })}
</DropdownMenuItem>
<DropdownMenuItem
aria-label={t("button.copy", { ns: "common" })}
disabled={isLoading}
onClick={() => handleCopyCoordinates(index)}
>
{t("button.copy", { ns: "common" })}
</DropdownMenuItem>
<DropdownMenuItem
aria-label={t("button.delete", { ns: "common" })}
disabled={isLoading || isBasePolygon}
onClick={() => setDeleteDialogOpen(true)}
>
{t("button.delete", { ns: "common" })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
{!isMobile && hoveredPolygonIndex === index && (
<div
className="absolute inset-y-0 right-0 flex flex-row items-center gap-2 rounded-r-lg pl-8 pr-1"
style={{
background:
polygon.color.length === 3
? `linear-gradient(to right, transparent 0%, rgba(${polygon.color[2]},${polygon.color[1]},${polygon.color[0]},0.85) 40%)`
: "linear-gradient(to right, transparent 0%, rgba(220,0,0,0.85) 40%)",
}}
>
<Tooltip>
<TooltipTrigger asChild>
<IconWrapper
icon={LuPencil}
disabled={isLoading}
className={cn(
"size-[15px] cursor-pointer",
hoveredPolygonIndex === index && "text-primary-variant",
isLoading && "cursor-not-allowed opacity-50",
)}
onClick={() => {
if (!isLoading) {
setActivePolygonIndex(index);
setEditPane(polygon.type);
}
}}
/>
</TooltipTrigger>
<TooltipContent>
{t("button.edit", { ns: "common" })}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<IconWrapper
icon={LuCopy}
className={cn(
"size-[15px] cursor-pointer",
hoveredPolygonIndex === index && "text-primary-variant",
isLoading && "cursor-not-allowed opacity-50",
)}
onClick={() => {
if (!isLoading) {
handleCopyCoordinates(index);
}
}}
/>
</TooltipTrigger>
<TooltipContent>
{t("button.copyCoordinates", { ns: "common" })}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<IconWrapper
icon={HiTrash}
disabled={isLoading}
className={cn(
"size-[15px] cursor-pointer",
hoveredPolygonIndex === index &&
"fill-primary-variant text-primary-variant",
(isLoading || isBasePolygon) &&
"cursor-not-allowed opacity-50",
)}
onClick={() =>
!isLoading && !isBasePolygon && setDeleteDialogOpen(true)
}
/>
</TooltipTrigger>
<TooltipContent>
{polygon.polygonSource === "override"
? t("masksAndZones.form.polygonDrawing.revertOverride.title")
: t("button.delete", { ns: "common" })}
</TooltipContent>
</Tooltip>
</div>
)}
</div>
</>
);
}