From d0aefc21215b7e5077ca259c3d756ee1390c4a02 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Thu, 25 Apr 2024 18:19:31 -0500
Subject: [PATCH] Camera group dialog changes and fixes (#11117)
* camera group dialog changes and fixes
* use drawer on mobile
* spacing
---
.../components/filter/CameraGroupSelector.tsx | 757 ++++++++++++------
web/src/components/settings/ZoneEditPane.tsx | 8 -
web/src/pages/Live.tsx | 15 +-
web/src/views/live/LiveDashboardView.tsx | 4 +-
4 files changed, 550 insertions(+), 234 deletions(-)
diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx
index c147ab638..f410e8e8a 100644
--- a/web/src/components/filter/CameraGroupSelector.tsx
+++ b/web/src/components/filter/CameraGroupSelector.tsx
@@ -3,7 +3,7 @@ import {
FrigateConfig,
GROUP_ICONS,
} from "@/types/frigateConfig";
-import { isDesktop } from "react-device-detect";
+import { isDesktop, isMobile } from "react-device-detect";
import useSWR from "swr";
import { MdHome } from "react-icons/md";
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
@@ -11,19 +11,54 @@ import { Button } from "../ui/button";
import { useCallback, useMemo, useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { getIconForGroup } from "@/utils/iconUtil";
-import { LuPencil, LuPlus, LuTrash } from "react-icons/lu";
+import { LuPencil, LuPlus } from "react-icons/lu";
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
+import { Drawer, DrawerContent } from "../ui/drawer";
import { Input } from "../ui/input";
+import { Separator } from "../ui/separator";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
import {
DropdownMenu,
DropdownMenuContent,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSeparator,
+ DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "../ui/alert-dialog";
import axios from "axios";
import FilterSwitch from "./FilterSwitch";
+import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi";
+import IconWrapper from "../ui/icon-wrapper";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { Toaster } from "@/components/ui/sonner";
+import { toast } from "sonner";
+import ActivityIndicator from "../indicators/activity-indicator";
+import { ScrollArea, ScrollBar } from "../ui/scroll-area";
type CameraGroupSelectorProps = {
className?: string;
@@ -71,70 +106,79 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
const [addGroup, setAddGroup] = useState(false);
+ const Scroller = isMobile ? ScrollArea : "div";
+
return (
-
+ <>
-
-
-
-
-
-
- All Cameras
-
-
- {groups.map(([name, config]) => {
- return (
-
+
+
+
- {name}
+ All Cameras
- );
- })}
- {isDesktop && (
-
- )}
-
+ {groups.map(([name, config]) => {
+ return (
+
+
+
+
+
+ {name}
+
+
+ );
+ })}
+
+
+ {isMobile && }
+
+
+ >
);
}
@@ -142,195 +186,462 @@ type NewGroupDialogProps = {
open: boolean;
setOpen: (open: boolean) => void;
currentGroups: [string, CameraGroupConfig][];
+ activeGroup?: string;
+ setGroup: (value: string | undefined, replace?: boolean | undefined) => void;
};
-function NewGroupDialog({ open, setOpen, currentGroups }: NewGroupDialogProps) {
+function NewGroupDialog({
+ open,
+ setOpen,
+ currentGroups,
+ activeGroup,
+ setGroup,
+}: NewGroupDialogProps) {
+ const { mutate: updateConfig } = useSWR("config");
+
+ // editing group and state
+
+ const [editingGroupName, setEditingGroupName] = useState("");
+
+ const editingGroup = useMemo(() => {
+ if (currentGroups && editingGroupName !== undefined) {
+ return currentGroups.find(
+ ([groupName]) => groupName === editingGroupName,
+ );
+ } else {
+ return undefined;
+ }
+ }, [currentGroups, editingGroupName]);
+
+ const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
+ const [isLoading, setIsLoading] = useState(false);
+
+ // callbacks
+
+ const onDeleteGroup = useCallback(
+ async (name: string) => {
+ // TODO: reset order on groups when deleting
+
+ await axios
+ .put(`config/set?camera_groups.${name}`, { requires_restart: 0 })
+ .then((res) => {
+ if (res.status === 200) {
+ if (activeGroup == name) {
+ // deleting current group
+ setGroup("default");
+ }
+ updateConfig();
+ } else {
+ setOpen(false);
+ setEditState("none");
+ toast.error(`Failed to save config changes: ${res.statusText}`, {
+ position: "top-center",
+ });
+ }
+ })
+ .catch((error) => {
+ setOpen(false);
+ setEditState("none");
+ toast.error(
+ `Failed to save config changes: ${error.response.data.message}`,
+ { position: "top-center" },
+ );
+ })
+ .finally(() => {
+ setIsLoading(false);
+ });
+ },
+ [updateConfig, activeGroup, setGroup, setOpen],
+ );
+
+ const onSave = () => {
+ setOpen(false);
+ setEditState("none");
+ };
+
+ const onCancel = () => {
+ setEditingGroupName("");
+ setEditState("none");
+ };
+
+ const onEditGroup = useCallback((group: [string, CameraGroupConfig]) => {
+ setEditingGroupName(group[0]);
+ setEditState("edit");
+ }, []);
+
+ const Overlay = isDesktop ? Dialog : Drawer;
+ const Content = isDesktop ? DialogContent : DrawerContent;
+
+ return (
+ <>
+
+ {
+ setEditState("none");
+ setOpen(open);
+ }}
+ >
+
+
+ {editState === "none" && (
+ <>
+
+ Camera Groups
+
+
+ {currentGroups.map((group) => (
+
onDeleteGroup(group[0])}
+ onEditGroup={() => onEditGroup(group)}
+ />
+ ))}
+ >
+ )}
+
+ {editState != "none" && (
+ <>
+
+
+ {editState == "add" ? "Add" : "Edit"} Camera Group
+
+
+
+ >
+ )}
+
+
+
+ >
+ );
+}
+
+type CameraGroupRowProps = {
+ group: [string, CameraGroupConfig];
+ onDeleteGroup: () => void;
+ onEditGroup: () => void;
+};
+
+export function CameraGroupRow({
+ group,
+ onDeleteGroup,
+ onEditGroup,
+}: CameraGroupRowProps) {
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+
+ if (!group) {
+ return;
+ }
+
+ return (
+ <>
+
+
+
setDeleteDialogOpen(!deleteDialogOpen)}
+ >
+
+
+ Confirm Delete
+
+
+ Are you sure you want to delete the camera group{" "}
+ {group[0]}?
+
+
+ Cancel
+
+ Delete
+
+
+
+
+
+ {isMobile && (
+ <>
+
+
+
+
+
+ Edit
+ setDeleteDialogOpen(true)}>
+ Delete
+
+
+
+ >
+ )}
+ {!isMobile && (
+
+
+
+
+
+ Edit
+
+
+
+
+ setDeleteDialogOpen(true)}
+ />
+
+ Delete
+
+
+ )}
+
+ >
+ );
+}
+
+type CameraGroupEditProps = {
+ currentGroups: [string, CameraGroupConfig][];
+ editingGroup?: [string, CameraGroupConfig];
+ isLoading: boolean;
+ setIsLoading: React.Dispatch>;
+ onSave?: () => void;
+ onCancel?: () => void;
+};
+
+export function CameraGroupEdit({
+ currentGroups,
+ editingGroup,
+ isLoading,
+ setIsLoading,
+ onSave,
+ onCancel,
+}: CameraGroupEditProps) {
const { data: config, mutate: updateConfig } =
useSWR("config");
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
- // add fields
+ const formSchema = z.object({
+ name: z
+ .string()
+ .min(2, {
+ message: "Camera group name must be at least 2 characters.",
+ })
+ .transform((val: string) => val.trim().replace(/\s+/g, "_"))
+ .refine(
+ (value: string) => {
+ return (
+ editingGroup !== undefined ||
+ !currentGroups.map((group) => group[0]).includes(value)
+ );
+ },
+ {
+ message: "Camera group name already exists.",
+ },
+ ),
+ cameras: z.array(z.string()).min(2, {
+ message: "You must select at least two cameras.",
+ }),
+ icon: z.string(),
+ });
- const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
- const [newTitle, setNewTitle] = useState("");
- const [icon, setIcon] = useState("");
- const [cameras, setCameras] = useState([]);
-
- // validation
-
- const [error, setError] = useState("");
-
- const onCreateGroup = useCallback(async () => {
- if (!newTitle) {
- setError("A title must be selected");
- return;
- }
-
- if (!icon) {
- setError("An icon must be selected");
- return;
- }
-
- if (!cameras || cameras.length < 2) {
- setError("At least 2 cameras must be selected");
- return;
- }
-
- setError("");
- const orderQuery = `camera_groups.${newTitle}.order=${currentGroups.length}`;
- const iconQuery = `camera_groups.${newTitle}.icon=${icon}`;
- const cameraQueries = cameras
- .map((cam) => `&camera_groups.${newTitle}.cameras=${cam}`)
- .join("");
-
- const req = axios.put(
- `config/set?${orderQuery}&${iconQuery}${cameraQueries}`,
- { requires_restart: 0 },
- );
-
- setOpen(false);
-
- if ((await req).status == 200) {
- setNewTitle("");
- setIcon("");
- setCameras([]);
- updateConfig();
- }
- }, [currentGroups, cameras, newTitle, icon, setOpen, updateConfig]);
-
- const onDeleteGroup = useCallback(
- async (name: string) => {
- const req = axios.put(`config/set?camera_groups.${name}`, {
- requires_restart: 0,
- });
-
- if ((await req).status == 200) {
- updateConfig();
+ const onSubmit = useCallback(
+ async (values: z.infer) => {
+ if (!values) {
+ return;
}
+
+ setIsLoading(true);
+
+ const order =
+ editingGroup === undefined
+ ? currentGroups.length + 1
+ : editingGroup[1].order;
+
+ const orderQuery = `camera_groups.${values.name}.order=${order}`;
+ const iconQuery = `camera_groups.${values.name}.icon=${values.icon}`;
+ const cameraQueries = values.cameras
+ .map((cam) => `&camera_groups.${values.name}.cameras=${cam}`)
+ .join("");
+
+ axios
+ .put(`config/set?${orderQuery}&${iconQuery}${cameraQueries}`, {
+ requires_restart: 0,
+ })
+ .then((res) => {
+ if (res.status === 200) {
+ toast.success(`Camera group (${values.name}) has been saved.`, {
+ position: "top-center",
+ });
+ updateConfig();
+ if (onSave) {
+ onSave();
+ }
+ } else {
+ toast.error(`Failed to save config changes: ${res.statusText}`, {
+ position: "top-center",
+ });
+ }
+ })
+ .catch((error) => {
+ toast.error(
+ `Failed to save config changes: ${error.response.data.message}`,
+ { position: "top-center" },
+ );
+ })
+ .finally(() => {
+ setIsLoading(false);
+ });
},
- [updateConfig],
+ [currentGroups, setIsLoading, onSave, updateConfig, editingGroup],
);
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ mode: "onChange",
+ defaultValues: {
+ name: (editingGroup && editingGroup[0]) ?? "",
+ icon: editingGroup && editingGroup[1].icon,
+ cameras: editingGroup && editingGroup[1].cameras,
+ },
+ });
+
return (
-
+
+ ))}
+
+
+ )}
+ />
+
+
+ (
+
+ Icon
+
+
+
+
+
+ )}
+ />
+
+
+
+
+
+
+
+
+
);
}
diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx
index 803d172d8..f72cc3907 100644
--- a/web/src/components/settings/ZoneEditPane.tsx
+++ b/web/src/components/settings/ZoneEditPane.tsx
@@ -551,14 +551,6 @@ export function ZoneObjectSelector({
const labels = new Set();
- // Object.values(config.cameras).forEach((camera) => {
- // camera.objects.track.forEach((label) => {
- // if (!ATTRIBUTE_LABELS.includes(label)) {
- // labels.add(label);
- // }
- // });
- // });
-
cameraConfig.objects.track.forEach((label) => {
if (!ATTRIBUTE_LABELS.includes(label)) {
labels.add(label);
diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx
index 4be6b2c16..387aa3856 100644
--- a/web/src/pages/Live.tsx
+++ b/web/src/pages/Live.tsx
@@ -38,7 +38,13 @@ function Live() {
// settings
const includesBirdseye = useMemo(() => {
- if (config && cameraGroup && cameraGroup != "default") {
+ if (
+ config &&
+ Object.keys(config.camera_groups).length &&
+ cameraGroup &&
+ config.camera_groups[cameraGroup] &&
+ cameraGroup != "default"
+ ) {
return config.camera_groups[cameraGroup].cameras.includes("birdseye");
} else {
return false;
@@ -50,7 +56,12 @@ function Live() {
return [];
}
- if (cameraGroup && cameraGroup != "default") {
+ if (
+ Object.keys(config.camera_groups).length &&
+ cameraGroup &&
+ config.camera_groups[cameraGroup] &&
+ cameraGroup != "default"
+ ) {
const group = config.camera_groups[cameraGroup];
return Object.values(config.cameras)
.filter((conf) => conf.enabled && group.cameras.includes(conf.name))
diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx
index 3c186656a..31be2dce1 100644
--- a/web/src/views/live/LiveDashboardView.tsx
+++ b/web/src/views/live/LiveDashboardView.tsx
@@ -144,7 +144,9 @@ export default function LiveDashboardView({
{isMobile && (