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 ( + <> +
+
+

{group[0]}

+
+ 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 ( - { - setEditState("none"); - setNewTitle(""); - setIcon(""); - setCameras([]); - setOpen(open); - }} - > - - Camera Groups - {currentGroups.map((group) => ( -
- {group[0]} -
- - -
-
- ))} - {currentGroups.length > 0 && } - {editState == "none" && ( - - )} - {editState != "none" && ( - <> - setNewTitle(e.target.value)} - /> - - -
- {icon.length == 0 ? "Select Icon" : "Icon: "} - {icon ? getIconForGroup(icon) :
} -
- - - - {GROUP_ICONS.map((gIcon) => ( - - {getIconForGroup(gIcon)} - {gIcon} - - ))} - - - - - -
- {cameras.length == 0 - ? "Select Cameras" - : `${cameras.length} Cameras`} -
-
- - {[ - ...(birdseyeConfig?.enabled ? ["birdseye"] : []), - ...Object.keys(config?.cameras ?? {}), - ].map((camera) => ( +
+ + ( + + Name + + + + + + )} + /> + + + ( + + Cameras + Select cameras for this group. + {[ + ...(birdseyeConfig?.enabled ? ["birdseye"] : []), + ...Object.keys(config?.cameras ?? {}), + ].map((camera) => ( + { - if (checked) { - setCameras([...cameras, camera]); - } else { - const index = cameras.indexOf(camera); - setCameras([ - ...cameras.slice(0, index), - ...cameras.slice(index + 1), - ]); - } + const updatedCameras = checked + ? [...(field.value || []), camera] + : (field.value || []).filter((c) => c !== camera); + form.setValue("cameras", updatedCameras); }} /> - ))} - - - {error &&
{error}
} - - - )} - -
+ + ))} + + + )} + /> + + + ( + + 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 && (
- +
+ +