From bc6b2d56598f97904d369ed793a9c53358157159 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 21 Jun 2024 16:28:45 -0600 Subject: [PATCH] Abstract filters to separate components --- .../filter/CalendarFilterButton.tsx | 79 +++ .../components/filter/CamerasFilterButton.tsx | 177 +++++++ .../components/filter/ReviewFilterGroup.tsx | 254 +-------- .../components/filter/SearchFilterGroup.tsx | 491 ++++++++++++++++++ web/src/pages/Search.tsx | 5 +- web/src/pages/SubmitPlus.tsx | 6 +- 6 files changed, 759 insertions(+), 253 deletions(-) create mode 100644 web/src/components/filter/CalendarFilterButton.tsx create mode 100644 web/src/components/filter/CamerasFilterButton.tsx create mode 100644 web/src/components/filter/SearchFilterGroup.tsx diff --git a/web/src/components/filter/CalendarFilterButton.tsx b/web/src/components/filter/CalendarFilterButton.tsx new file mode 100644 index 000000000..112a9d2a5 --- /dev/null +++ b/web/src/components/filter/CalendarFilterButton.tsx @@ -0,0 +1,79 @@ +import { useFormattedTimestamp } from "@/hooks/use-date-utils"; +import { ReviewSummary } from "@/types/review"; +import { Button } from "../ui/button"; +import { FaCalendarAlt } from "react-icons/fa"; +import ReviewActivityCalendar from "../overlay/ReviewActivityCalendar"; +import { DropdownMenuSeparator } from "../ui/dropdown-menu"; +import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; +import { isMobile } from "react-device-detect"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; + +type CalendarFilterButtonProps = { + reviewSummary?: ReviewSummary; + day?: Date; + defaultText: string; + updateSelectedDay: (day?: Date) => void; +}; +export default function CalendarFilterButton({ + reviewSummary, + day, + defaultText, + updateSelectedDay, +}: CalendarFilterButtonProps) { + const selectedDate = useFormattedTimestamp( + day == undefined ? 0 : day?.getTime() / 1000 + 1, + "%b %-d", + ); + + const trigger = ( + + ); + const content = ( + <> + + +
+ +
+ + ); + + if (isMobile) { + return ( + + {trigger} + {content} + + ); + } + + return ( + + {trigger} + {content} + + ); +} diff --git a/web/src/components/filter/CamerasFilterButton.tsx b/web/src/components/filter/CamerasFilterButton.tsx new file mode 100644 index 000000000..b1878bf12 --- /dev/null +++ b/web/src/components/filter/CamerasFilterButton.tsx @@ -0,0 +1,177 @@ +import { Button } from "../ui/button"; +import { CameraGroupConfig } from "@/types/frigateConfig"; +import { useState } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { isMobile } from "react-device-detect"; +import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; +import FilterSwitch from "./FilterSwitch"; +import { FaVideo } from "react-icons/fa"; + +type CameraFilterButtonProps = { + allCameras: string[]; + groups: [string, CameraGroupConfig][]; + selectedCameras: string[] | undefined; + updateCameraFilter: (cameras: string[] | undefined) => void; +}; +export function CamerasFilterButton({ + allCameras, + groups, + selectedCameras, + updateCameraFilter, +}: CameraFilterButtonProps) { + const [open, setOpen] = useState(false); + const [currentCameras, setCurrentCameras] = useState( + selectedCameras, + ); + + const trigger = ( + + ); + const content = ( + <> + {isMobile && ( + <> + + Cameras + + + + )} +
+ { + if (isChecked) { + setCurrentCameras(undefined); + } + }} + /> + {groups.length > 0 && ( + <> + + {groups.map(([name, conf]) => { + return ( +
setCurrentCameras([...conf.cameras])} + > + {name} +
+ ); + })} + + )} + +
+ {allCameras.map((item) => ( + { + if (isChecked) { + const updatedCameras = currentCameras + ? [...currentCameras] + : []; + + updatedCameras.push(item); + setCurrentCameras(updatedCameras); + } else { + const updatedCameras = currentCameras + ? [...currentCameras] + : []; + + // can not deselect the last item + if (updatedCameras.length > 1) { + updatedCameras.splice(updatedCameras.indexOf(item), 1); + setCurrentCameras(updatedCameras); + } + } + }} + /> + ))} +
+
+ +
+ + +
+ + ); + + if (isMobile) { + return ( + { + if (!open) { + setCurrentCameras(selectedCameras); + } + + setOpen(open); + }} + > + {trigger} + + {content} + + + ); + } + + return ( + { + if (!open) { + setCurrentCameras(selectedCameras); + } + + setOpen(open); + }} + > + {trigger} + {content} + + ); +} diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index a76b81db6..407d2c723 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -1,36 +1,24 @@ import { Button } from "../ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import useSWR from "swr"; -import { CameraGroupConfig, FrigateConfig } from "@/types/frigateConfig"; +import { FrigateConfig } from "@/types/frigateConfig"; import { useCallback, useMemo, useState } from "react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "../ui/dropdown-menu"; +import { DropdownMenuSeparator } from "../ui/dropdown-menu"; import { ReviewFilter, ReviewSeverity, ReviewSummary } from "@/types/review"; import { getEndOfDayTimestamp } from "@/utils/dateUtil"; -import { useFormattedTimestamp } from "@/hooks/use-date-utils"; -import { - FaCalendarAlt, - FaCheckCircle, - FaFilter, - FaRunning, - FaVideo, -} from "react-icons/fa"; +import { FaCheckCircle, FaFilter, FaRunning } from "react-icons/fa"; import { isDesktop, isMobile } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Switch } from "../ui/switch"; import { Label } from "../ui/label"; -import ReviewActivityCalendar from "../overlay/ReviewActivityCalendar"; import MobileReviewSettingsDrawer, { DrawerFeatures, } from "../overlay/MobileReviewSettingsDrawer"; import useOptimisticState from "@/hooks/use-optimistic-state"; import FilterSwitch from "./FilterSwitch"; import { FilterList } from "@/types/filter"; +import CalendarFilterButton from "./CalendarFilterButton"; +import { CamerasFilterButton } from "./CamerasFilterButton"; const REVIEW_FILTERS = [ "cameras", @@ -204,6 +192,7 @@ export default function ReviewFilterGroup({ ? undefined : new Date(filter.after * 1000) } + defaultText="Last 24 Hours" updateSelectedDay={onUpdateSelectedDay} /> )} @@ -254,169 +243,6 @@ export default function ReviewFilterGroup({ ); } -type CameraFilterButtonProps = { - allCameras: string[]; - groups: [string, CameraGroupConfig][]; - selectedCameras: string[] | undefined; - updateCameraFilter: (cameras: string[] | undefined) => void; -}; -export function CamerasFilterButton({ - allCameras, - groups, - selectedCameras, - updateCameraFilter, -}: CameraFilterButtonProps) { - const [open, setOpen] = useState(false); - const [currentCameras, setCurrentCameras] = useState( - selectedCameras, - ); - - const trigger = ( - - ); - const content = ( - <> - {isMobile && ( - <> - - Cameras - - - - )} -
- { - if (isChecked) { - setCurrentCameras(undefined); - } - }} - /> - {groups.length > 0 && ( - <> - - {groups.map(([name, conf]) => { - return ( -
setCurrentCameras([...conf.cameras])} - > - {name} -
- ); - })} - - )} - -
- {allCameras.map((item) => ( - { - if (isChecked) { - const updatedCameras = currentCameras - ? [...currentCameras] - : []; - - updatedCameras.push(item); - setCurrentCameras(updatedCameras); - } else { - const updatedCameras = currentCameras - ? [...currentCameras] - : []; - - // can not deselect the last item - if (updatedCameras.length > 1) { - updatedCameras.splice(updatedCameras.indexOf(item), 1); - setCurrentCameras(updatedCameras); - } - } - }} - /> - ))} -
-
- -
- - -
- - ); - - if (isMobile) { - return ( - { - if (!open) { - setCurrentCameras(selectedCameras); - } - - setOpen(open); - }} - > - {trigger} - - {content} - - - ); - } - - return ( - { - if (!open) { - setCurrentCameras(selectedCameras); - } - - setOpen(open); - }} - > - {trigger} - {content} - - ); -} - type ShowReviewedFilterProps = { showReviewed?: 0 | 1; setShowReviewed: (reviewed?: 0 | 1) => void; @@ -458,74 +284,6 @@ function ShowReviewFilter({ ); } -type CalendarFilterButtonProps = { - reviewSummary?: ReviewSummary; - day?: Date; - updateSelectedDay: (day?: Date) => void; -}; -function CalendarFilterButton({ - reviewSummary, - day, - updateSelectedDay, -}: CalendarFilterButtonProps) { - const selectedDate = useFormattedTimestamp( - day == undefined ? 0 : day?.getTime() / 1000 + 1, - "%b %-d", - ); - - const trigger = ( - - ); - const content = ( - <> - - -
- -
- - ); - - if (isMobile) { - return ( - - {trigger} - {content} - - ); - } - - return ( - - {trigger} - {content} - - ); -} - type GeneralFilterButtonProps = { allLabels: string[]; selectedLabels: string[] | undefined; diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx new file mode 100644 index 000000000..b48aef63e --- /dev/null +++ b/web/src/components/filter/SearchFilterGroup.tsx @@ -0,0 +1,491 @@ +import { Button } from "../ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { useCallback, useMemo, useState } from "react"; +import { DropdownMenuSeparator } from "../ui/dropdown-menu"; +import { ReviewFilter, ReviewSeverity } from "@/types/review"; +import { getEndOfDayTimestamp } from "@/utils/dateUtil"; +import { FaFilter } from "react-icons/fa"; +import { isDesktop, isMobile } from "react-device-detect"; +import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; +import { Switch } from "../ui/switch"; +import { Label } from "../ui/label"; +import MobileReviewSettingsDrawer, { + DrawerFeatures, +} from "../overlay/MobileReviewSettingsDrawer"; +import FilterSwitch from "./FilterSwitch"; +import { FilterList } from "@/types/filter"; +import CalendarFilterButton from "./CalendarFilterButton"; +import { CamerasFilterButton } from "./CamerasFilterButton"; + +const SEARCH_FILTERS = ["cameras", "date", "general"] as const; +type SearchFilters = (typeof SEARCH_FILTERS)[number]; +const DEFAULT_REVIEW_FILTERS: SearchFilters[] = ["cameras", "date", "general"]; + +type SearchFilterGroupProps = { + filters?: SearchFilters[]; + filter?: ReviewFilter; + filterList?: FilterList; + onUpdateFilter: (filter: ReviewFilter) => void; + setMotionOnly: React.Dispatch>; +}; + +export default function SearchFilterGroup({ + filters = DEFAULT_REVIEW_FILTERS, + filter, + filterList, + onUpdateFilter, +}: SearchFilterGroupProps) { + const { data: config } = useSWR("config"); + + const allLabels = useMemo(() => { + if (filterList?.labels) { + return filterList.labels; + } + + if (!config) { + return []; + } + + const labels = new Set(); + const cameras = filter?.cameras || Object.keys(config.cameras); + + cameras.forEach((camera) => { + if (camera == "birdseye") { + return; + } + const cameraConfig = config.cameras[camera]; + cameraConfig.objects.track.forEach((label) => { + labels.add(label); + }); + + if (cameraConfig.audio.enabled_in_config) { + cameraConfig.audio.listen.forEach((label) => { + labels.add(label); + }); + } + }); + + return [...labels].sort(); + }, [config, filterList, filter]); + + const allZones = useMemo(() => { + if (filterList?.zones) { + return filterList.zones; + } + + if (!config) { + return []; + } + + const zones = new Set(); + const cameras = filter?.cameras || Object.keys(config.cameras); + + cameras.forEach((camera) => { + if (camera == "birdseye") { + return; + } + const cameraConfig = config.cameras[camera]; + cameraConfig.review.alerts.required_zones.forEach((zone) => { + zones.add(zone); + }); + cameraConfig.review.detections.required_zones.forEach((zone) => { + zones.add(zone); + }); + }); + + return [...zones].sort(); + }, [config, filterList, filter]); + + const filterValues = useMemo( + () => ({ + cameras: Object.keys(config?.cameras || {}), + labels: Object.values(allLabels || {}), + zones: Object.values(allZones || {}), + }), + [config, allLabels, allZones], + ); + + const groups = useMemo(() => { + if (!config) { + return []; + } + + return Object.entries(config.camera_groups).sort( + (a, b) => a[1].order - b[1].order, + ); + }, [config]); + + const mobileSettingsFeatures = useMemo(() => { + const features: DrawerFeatures[] = []; + + if (filters.includes("date")) { + features.push("calendar"); + } + + if (filters.includes("general")) { + features.push("filter"); + } + + return features; + }, [filters]); + + // handle updating filters + + const onUpdateSelectedDay = useCallback( + (day?: Date) => { + onUpdateFilter({ + ...filter, + after: day == undefined ? undefined : day.getTime() / 1000, + before: day == undefined ? undefined : getEndOfDayTimestamp(day), + }); + }, + [filter, onUpdateFilter], + ); + + return ( +
+ {filters.includes("cameras") && ( + { + onUpdateFilter({ ...filter, cameras: newCameras }); + }} + /> + )} + {isDesktop && filters.includes("date") && ( + + )} + {isDesktop && filters.includes("general") && ( + { + onUpdateFilter({ ...filter, showAll }); + }} + updateLabelFilter={(newLabels) => { + onUpdateFilter({ ...filter, labels: newLabels }); + }} + updateZoneFilter={(newZones) => + onUpdateFilter({ ...filter, zones: newZones }) + } + /> + )} + {isMobile && mobileSettingsFeatures.length > 0 && ( + {}} + setRange={() => {}} + /> + )} +
+ ); +} + +type GeneralFilterButtonProps = { + allLabels: string[]; + selectedLabels: string[] | undefined; + currentSeverity?: ReviewSeverity; + showAll: boolean; + allZones: string[]; + selectedZones?: string[]; + setShowAll: (showAll: boolean) => void; + updateLabelFilter: (labels: string[] | undefined) => void; + updateZoneFilter: (zones: string[] | undefined) => void; +}; +function GeneralFilterButton({ + allLabels, + selectedLabels, + currentSeverity, + showAll, + allZones, + selectedZones, + setShowAll, + updateLabelFilter, + updateZoneFilter, +}: GeneralFilterButtonProps) { + const [open, setOpen] = useState(false); + const [currentLabels, setCurrentLabels] = useState( + selectedLabels, + ); + const [currentZones, setCurrentZones] = useState( + selectedZones, + ); + + const trigger = ( + + ); + const content = ( + setOpen(false)} + /> + ); + + if (isMobile) { + return ( + { + if (!open) { + setCurrentLabels(selectedLabels); + } + + setOpen(open); + }} + > + {trigger} + + {content} + + + ); + } + + return ( + { + if (!open) { + setCurrentLabels(selectedLabels); + } + + setOpen(open); + }} + > + {trigger} + {content} + + ); +} + +type GeneralFilterContentProps = { + allLabels: string[]; + selectedLabels: string[] | undefined; + currentLabels: string[] | undefined; + currentSeverity?: ReviewSeverity; + showAll?: boolean; + allZones?: string[]; + selectedZones?: string[]; + currentZones?: string[]; + setShowAll?: (showAll: boolean) => void; + updateLabelFilter: (labels: string[] | undefined) => void; + setCurrentLabels: (labels: string[] | undefined) => void; + updateZoneFilter?: (zones: string[] | undefined) => void; + setCurrentZones?: (zones: string[] | undefined) => void; + onClose: () => void; +}; +export function GeneralFilterContent({ + allLabels, + selectedLabels, + currentLabels, + currentSeverity, + showAll, + allZones, + selectedZones, + currentZones, + setShowAll, + updateLabelFilter, + setCurrentLabels, + updateZoneFilter, + setCurrentZones, + onClose, +}: GeneralFilterContentProps) { + return ( + <> +
+ {currentSeverity && setShowAll && ( +
+ + + +
+ )} +
+ + { + if (isChecked) { + setCurrentLabels(undefined); + } + }} + /> +
+
+ {allLabels.map((item) => ( + { + if (isChecked) { + const updatedLabels = currentLabels ? [...currentLabels] : []; + + updatedLabels.push(item); + setCurrentLabels(updatedLabels); + } else { + const updatedLabels = currentLabels ? [...currentLabels] : []; + + // can not deselect the last item + if (updatedLabels.length > 1) { + updatedLabels.splice(updatedLabels.indexOf(item), 1); + setCurrentLabels(updatedLabels); + } + } + }} + /> + ))} +
+ + {allZones && setCurrentZones && ( + <> + +
+ + { + if (isChecked) { + setCurrentZones(undefined); + } + }} + /> +
+
+ {allZones.map((item) => ( + { + if (isChecked) { + const updatedZones = currentZones + ? [...currentZones] + : []; + + updatedZones.push(item); + setCurrentZones(updatedZones); + } else { + const updatedZones = currentZones + ? [...currentZones] + : []; + + // can not deselect the last item + if (updatedZones.length > 1) { + updatedZones.splice(updatedZones.indexOf(item), 1); + setCurrentZones(updatedZones); + } + } + }} + /> + ))} +
+ + )} +
+ +
+ + +
+ + ); +} diff --git a/web/src/pages/Search.tsx b/web/src/pages/Search.tsx index 4b5809c9a..ec1d4e1ea 100644 --- a/web/src/pages/Search.tsx +++ b/web/src/pages/Search.tsx @@ -1,3 +1,4 @@ +import SearchFilterGroup from "@/components/filter/SearchFilterGroup"; import { Input } from "@/components/ui/input"; import { Toaster } from "@/components/ui/sonner"; import { useState } from "react"; @@ -16,7 +17,9 @@ export default function Search() { placeholder="Search for a specific detection..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} - /> + /> + +
diff --git a/web/src/pages/SubmitPlus.tsx b/web/src/pages/SubmitPlus.tsx index b4c9f2d6f..1233e348b 100644 --- a/web/src/pages/SubmitPlus.tsx +++ b/web/src/pages/SubmitPlus.tsx @@ -1,8 +1,6 @@ import { baseUrl } from "@/api/baseUrl"; -import { - CamerasFilterButton, - GeneralFilterContent, -} from "@/components/filter/ReviewFilterGroup"; +import { CamerasFilterButton } from "@/components/filter/CamerasFilterButton"; +import { GeneralFilterContent } from "@/components/filter/ReviewFilterGroup"; import Chip from "@/components/indicators/Chip"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { Button } from "@/components/ui/button";