diff --git a/web/package-lock.json b/web/package-lock.json index a09f066ea..ab9283144 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -21,6 +21,7 @@ "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", @@ -1650,6 +1651,29 @@ } } }, + "node_modules/@radix-ui/react-separator": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.3.tgz", + "integrity": "sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slider": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.1.2.tgz", @@ -2568,11 +2592,6 @@ "@types/react": "*" } }, - "node_modules/@types/scheduler": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" - }, "node_modules/@types/semver": { "version": "7.5.6", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", diff --git a/web/package.json b/web/package.json index c39567e4c..a2144bdce 100644 --- a/web/package.json +++ b/web/package.json @@ -26,6 +26,7 @@ "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", diff --git a/web/src/components/settings/AccountSettings.tsx b/web/src/components/menu/AccountSettings.tsx similarity index 100% rename from web/src/components/settings/AccountSettings.tsx rename to web/src/components/menu/AccountSettings.tsx diff --git a/web/src/components/settings/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx similarity index 100% rename from web/src/components/settings/GeneralSettings.tsx rename to web/src/components/menu/GeneralSettings.tsx diff --git a/web/src/components/navigation/Bottombar.tsx b/web/src/components/navigation/Bottombar.tsx index 2ef8c2e8d..e21556aaa 100644 --- a/web/src/components/navigation/Bottombar.tsx +++ b/web/src/components/navigation/Bottombar.tsx @@ -6,8 +6,8 @@ import { FrigateStats } from "@/types/stats"; import { useFrigateStats } from "@/api/ws"; import { useMemo } from "react"; import useStats from "@/hooks/use-stats"; -import GeneralSettings from "../settings/GeneralSettings"; -import AccountSettings from "../settings/AccountSettings"; +import GeneralSettings from "../menu/GeneralSettings"; +import AccountSettings from "../menu/AccountSettings"; import useNavigation from "@/hooks/use-navigation"; function Bottombar() { diff --git a/web/src/components/navigation/Sidebar.tsx b/web/src/components/navigation/Sidebar.tsx index e4b4d9c81..ea895a12f 100644 --- a/web/src/components/navigation/Sidebar.tsx +++ b/web/src/components/navigation/Sidebar.tsx @@ -2,8 +2,8 @@ import Logo from "../Logo"; import NavItem from "./NavItem"; import { CameraGroupSelector } from "../filter/CameraGroupSelector"; import { useLocation } from "react-router-dom"; -import GeneralSettings from "../settings/GeneralSettings"; -import AccountSettings from "../settings/AccountSettings"; +import GeneralSettings from "../menu/GeneralSettings"; +import AccountSettings from "../menu/AccountSettings"; import useNavigation from "@/hooks/use-navigation"; function Sidebar() { diff --git a/web/src/components/settings/General.tsx b/web/src/components/settings/General.tsx new file mode 100644 index 000000000..4d8465299 --- /dev/null +++ b/web/src/components/settings/General.tsx @@ -0,0 +1,40 @@ +import Heading from "@/components/ui/heading"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; + +export default function General() { + return ( + <> + Settings +
+ {}} /> + +
+ +
+ +
+ + ); +} diff --git a/web/src/components/settings/Zones.tsx b/web/src/components/settings/MasksAndZones.tsx similarity index 66% rename from web/src/components/settings/Zones.tsx rename to web/src/components/settings/MasksAndZones.tsx index 955149075..e038eed01 100644 --- a/web/src/components/settings/Zones.tsx +++ b/web/src/components/settings/MasksAndZones.tsx @@ -1,13 +1,4 @@ -import Heading from "@/components/ui/heading"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; import { Table, TableBody, @@ -23,12 +14,16 @@ import ActivityIndicator from "@/components/indicators/activity-indicator"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PolygonCanvas } from "./PolygonCanvas"; import { Polygon } from "@/types/canvas"; -import { interpolatePoints } from "@/utils/canvasUtil"; +import { interpolatePoints, toRGBColorString } from "@/utils/canvasUtil"; import { isDesktop } from "react-device-detect"; -import ZoneControls, { ZoneObjectSelector } from "./ZoneControls"; +import ZoneControls, { + NewZoneButton, + ZoneObjectSelector, +} from "./NewZoneButton"; import { Skeleton } from "../ui/skeleton"; import { useResizeObserver } from "@/hooks/resize-observer"; -import { LuPencil } from "react-icons/lu"; +import { LuCopy, LuPencil, LuPlusSquare, LuTrash } from "react-icons/lu"; +import { FaDrawPolygon } from "react-icons/fa"; const parseCoordinates = (coordinatesString: string) => { const coordinates = coordinatesString.split(","); @@ -49,7 +44,15 @@ export type ZoneObjects = { objects: string[]; }; -export default function SettingsZones() { +type MasksAndZoneProps = { + selectedCamera: string; + setSelectedCamera: React.Dispatch>; +}; + +export default function MasksAndZones({ + selectedCamera, + setSelectedCamera, +}: MasksAndZoneProps) { const { data: config } = useSWR("config"); const [zonePolygons, setZonePolygons] = useState([]); const [zoneObjects, setZoneObjects] = useState([]); @@ -68,8 +71,6 @@ export default function SettingsZones() { .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); }, [config]); - const [selectedCamera, setSelectedCamera] = useState(cameras[0].name); - const cameraConfig = useMemo(() => { if (config && selectedCamera) { return config.cameras[selectedCamera]; @@ -137,7 +138,7 @@ export default function SettingsZones() { [setZoneObjects], ); - const grow = useMemo(() => { + const growe = useMemo(() => { if (!cameraConfig) { return; } @@ -157,14 +158,51 @@ export default function SettingsZones() { } }, [cameraConfig]); - const handleCameraChange = useCallback( - (camera: string) => { - setSelectedCamera(camera); - setActivePolygonIndex(null); + const getCameraAspect = useCallback( + (cam: string) => { + if (!config) { + return undefined; + } + + const camera = config.cameras[cam]; + + if (!camera) { + return undefined; + } + + return camera.detect.width / camera.detect.height; }, - [setSelectedCamera, setActivePolygonIndex], + [config], ); + const mainCameraAspect = useMemo(() => { + const aspectRatio = getCameraAspect(selectedCamera); + + if (!aspectRatio) { + return "normal"; + } else if (aspectRatio > 2) { + return "wide"; + } else if (aspectRatio < 16 / 9) { + return "tall"; + } else { + return "normal"; + } + }, [getCameraAspect, selectedCamera]); + + const grow = useMemo(() => { + if (mainCameraAspect == "wide") { + return "w-full aspect-wide"; + } else if (mainCameraAspect == "tall") { + if (isDesktop) { + return "size-full aspect-tall flex flex-col justify-center"; + } else { + return "size-full"; + } + } else { + return "w-full aspect-video"; + } + }, [mainCameraAspect]); + const [{ width: containerWidth, height: containerHeight }] = useResizeObserver(containerRef); @@ -173,13 +211,16 @@ export default function SettingsZones() { : { width: 1, height: 1 }; const aspectRatio = width / height; - const stretch = false; - const fitAspect = 0.75; + const stretch = true; + const fitAspect = 16 / 9; + // console.log(containerRef.current?.clientHeight); const scaledHeight = useMemo(() => { const scaledHeight = aspectRatio < (fitAspect ?? 0) - ? Math.floor(containerHeight) + ? Math.floor( + Math.min(containerHeight, containerRef.current?.clientHeight), + ) : Math.floor(containerWidth / aspectRatio); const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height); @@ -244,57 +285,86 @@ export default function SettingsZones() { console.log("component zone objects", zoneObjects); }, [zoneObjects]); + useEffect(() => { + if (selectedCamera) { + setActivePolygonIndex(null); + } + }, [selectedCamera]); + if (!cameraConfig && !selectedCamera) { return ; } return ( -
- Zones -
- -
- + <> {cameraConfig && ( -
-
-
- {cameraConfig ? ( - - ) : ( - - )} +
+
+
+
-
-
- +
+
Zones
+ +
+ {zonePolygons.map((polygon, index) => ( +
+
+ + {polygon.name} +
+
+
setActivePolygonIndex(index)} + > + +
+ +
{ + setZonePolygons((oldPolygons) => { + return oldPolygons.filter((_, i) => i !== index); + }); + setActivePolygonIndex(null); + }} + > + +
+
+
+ ))} + {/*
Name @@ -372,10 +442,29 @@ export default function SettingsZones() { 0, )} + */} + +
+
+ {cameraConfig ? ( + + ) : ( + + )}
)} - + ); } diff --git a/web/src/components/settings/ZoneControls.tsx b/web/src/components/settings/NewZoneButton.tsx similarity index 68% rename from web/src/components/settings/ZoneControls.tsx rename to web/src/components/settings/NewZoneButton.tsx index a8f762fec..6c4ce3625 100644 --- a/web/src/components/settings/ZoneControls.tsx +++ b/web/src/components/settings/NewZoneButton.tsx @@ -16,6 +16,7 @@ import { Button } from "../ui/button"; import { ATTRIBUTES, FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; import { isMobile } from "react-device-detect"; +import { LuPlusSquare } from "react-icons/lu"; type ZoneObjectSelectorProps = { camera: string; @@ -126,7 +127,7 @@ export function ZoneObjectSelector({ ); } -type ZoneControlsProps = { +type NewZoneButtonProps = { camera: string; polygons: Polygon[]; setPolygons: React.Dispatch>; @@ -134,13 +135,13 @@ type ZoneControlsProps = { setActivePolygonIndex: React.Dispatch>; }; -export function ZoneControls({ +export function NewZoneButton({ camera, polygons, setPolygons, activePolygonIndex, setActivePolygonIndex, -}: ZoneControlsProps) { +}: NewZoneButtonProps) { const { data: config } = useSWR("config"); const [zoneName, setZoneName] = useState(); const [invalidName, setInvalidName] = useState(); @@ -190,75 +191,76 @@ export function ZoneControls({ }; return ( -
-
- - - { - setDialogOpen(open); - if (!open) { - setZoneName(""); - } - }} - > - - {isMobile && } - New Zone - - Enter a unique label for your zone. Do not include spaces, and - don't use the name of a camera. - - <> - { - setInvalidName( - Object.keys(config.cameras).includes(e.target.value) || - e.target.value.includes(" ") || - polygons - .map((item) => item.name) - .includes(e.target.value), - ); + */} - setZoneName(e.target.value); + + { + setDialogOpen(open); + if (!open) { + setZoneName(""); + } + }} + > + + {isMobile && } + New Zone + + Enter a unique label for your zone. Do not include spaces, and don't + use the name of a camera. + + <> + { + setInvalidName( + Object.keys(config.cameras).includes(e.target.value) || + e.target.value.includes(" ") || + polygons.map((item) => item.name).includes(e.target.value), + ); + + setZoneName(e.target.value); + }} + /> + {invalidName && ( +
Invalid zone name.
+ )} + + - - -
-
-
+ > + Continue + + + + +
); } -export default ZoneControls; +export default NewZoneButton; diff --git a/web/src/components/settings/PolygonDrawer.tsx b/web/src/components/settings/PolygonDrawer.tsx index bb3a52a27..87adf112a 100644 --- a/web/src/components/settings/PolygonDrawer.tsx +++ b/web/src/components/settings/PolygonDrawer.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from "react"; import { Line, Circle, Group } from "react-konva"; -import { minMax, dragBoundFunc } from "@/utils/canvasUtil"; +import { minMax, toRGBColorString, dragBoundFunc } from "@/utils/canvasUtil"; import type { KonvaEventObject } from "konva/lib/Node"; import Konva from "konva"; import { Vector2d } from "konva/lib/types"; @@ -78,11 +78,7 @@ export default function PolygonDrawer({ const colorString = useCallback( (darkened: boolean) => { - if (color.length !== 3) { - return "rgb(220,0,0,0.5)"; - } - - return `rgba(${color[2]},${color[1]},${color[0]},${darkened ? "0.8" : "0.5"})`; + return toRGBColorString(color, darkened); }, [color], ); diff --git a/web/src/components/ui/separator.tsx b/web/src/components/ui/separator.tsx new file mode 100644 index 000000000..6d7f12265 --- /dev/null +++ b/web/src/components/ui/separator.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 9838cd9e5..40c1ef5cb 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -1,75 +1,191 @@ -import Heading from "@/components/ui/heading"; -import { Label } from "@/components/ui/label"; import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +// import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import MotionTuner from "@/components/settings/MotionTuner"; -import SettingsZones from "@/components/settings/Zones"; +import MasksAndZones from "@/components/settings/MasksAndZones"; +import { Button } from "@/components/ui/button"; +import { useMemo, useState } from "react"; +import useOptimisticState from "@/hooks/use-optimistic-state"; +import Logo from "@/components/Logo"; +import { isMobile } from "react-device-detect"; +import { FaVideo } from "react-icons/fa"; +import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; +import useSWR from "swr"; +import General from "@/components/settings/General"; +import FilterCheckBox from "@/components/filter/FilterCheckBox"; -function General() { - return ( - <> - Settings -
- {}} /> - +type CameraSelectButtonProps = { + allCameras: CameraConfig[]; + selectedCamera: string; + setSelectedCamera: React.Dispatch>; +}; + +function CameraSelectButton({ + allCameras, + selectedCamera, + setSelectedCamera, +}: CameraSelectButtonProps) { + const [open, setOpen] = useState(false); + + const trigger = ( + + ); + const content = ( + <> + {isMobile && ( + <> + + Camera + + + + )} +
+ {allCameras.map((item) => ( + { + if (isChecked) { + setSelectedCamera(item.name); + setOpen(false); + } + }} + /> + ))}
); + + if (isMobile) { + return ( + { + if (!open) { + setSelectedCamera(selectedCamera); + } + + setOpen(open); + }} + > + {trigger} + + {content} + + + ); + } + + return ( + { + if (!open) { + setSelectedCamera(selectedCamera); + } + + setOpen(open); + }} + > + {trigger} + {content} + + ); } export default function Settings() { + const settingsViews = [ + "general", + "objects", + "masks / zones", + "motion tuner", + ] as const; + + type SettingsType = (typeof settingsViews)[number]; + const [page, setPage] = useState("general"); + const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); + + const { data: config } = useSWR("config"); + + const cameras = useMemo(() => { + if (!config) { + return []; + } + + return Object.values(config.cameras) + .filter((conf) => conf.ui.dashboard && conf.enabled) + .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); + }, [config]); + + const [selectedCamera, setSelectedCamera] = useState(cameras[0].name); + return ( -
-
-
- - - General - Objects - Zones - Masks - Motion - - - - - Objects - - - - Masks - - - - -
+
+
+ {isMobile && ( + + )} + { + if (value) { + setPageToggle(value); + } + }} + > + {Object.values(settingsViews).map((item) => ( + +
{item}
+
+ ))} +
+ {(page == "objects" || + page == "masks / zones" || + page == "motion tuner") && ( +
+ +
+ )} +
+
+ {page == "general" && } + {page == "objects" && <>} + {page == "masks / zones" && ( + + )} + {page == "motion tuner" && }
); diff --git a/web/src/utils/canvasUtil.ts b/web/src/utils/canvasUtil.ts index 8fb3e5d45..d9c26d936 100644 --- a/web/src/utils/canvasUtil.ts +++ b/web/src/utils/canvasUtil.ts @@ -63,3 +63,11 @@ export const interpolatePoints = ( return newPoints; }; + +export const toRGBColorString = (color: number[], darkened: boolean) => { + if (color.length !== 3) { + return "rgb(220,0,0,0.5)"; + } + + return `rgba(${color[2]},${color[1]},${color[0]},${darkened ? "0.9" : "0.5"})`; +};