From 25819584bdcbbedea487a5af404adda2396a3906 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 25 Sep 2024 10:05:40 -0600 Subject: [PATCH] Add ability to filter search by time range (#13946) * Add ability to filter by time range * Cleanup * Handle input with tags * fix input for time_range filter * fix before and after filters * clean up * Ensure the default value works as expected * Handle time range in am/pm based on browser * Fix arrow * Fix text * Handle midnight case * fix width * Fix bg * Fix bg * Fix mobile spacing * y spacing * remove left padding --------- Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> --- .../filter/CalendarFilterButton.tsx | 22 +- .../components/filter/ReviewFilterGroup.tsx | 32 +- .../components/filter/SearchFilterGroup.tsx | 343 +++++++++++------- web/src/components/input/InputWithTags.tsx | 76 +++- .../overlay/dialog/PlatformAwareDialog.tsx | 44 +++ web/src/hooks/use-date-utils.ts | 50 +++ web/src/hooks/use-suggestions.ts | 2 - web/src/pages/Explore.tsx | 18 +- web/src/types/search.ts | 27 +- web/src/utils/dateUtil.ts | 45 +++ web/src/views/search/SearchView.tsx | 27 +- 11 files changed, 501 insertions(+), 185 deletions(-) create mode 100644 web/src/components/overlay/dialog/PlatformAwareDialog.tsx diff --git a/web/src/components/filter/CalendarFilterButton.tsx b/web/src/components/filter/CalendarFilterButton.tsx index 5c98aca79..5ed404e21 100644 --- a/web/src/components/filter/CalendarFilterButton.tsx +++ b/web/src/components/filter/CalendarFilterButton.tsx @@ -13,6 +13,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { DateRangePicker } from "../ui/calendar-range"; import { DateRange } from "react-day-picker"; import { useState } from "react"; +import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog"; type CalendarFilterButtonProps = { reviewSummary?: ReviewSummary; @@ -24,6 +25,7 @@ export default function CalendarFilterButton({ day, updateSelectedDay, }: CalendarFilterButtonProps) { + const [open, setOpen] = useState(false); const selectedDate = useFormattedTimestamp( day == undefined ? 0 : day?.getTime() / 1000 + 1, "%b %-d", @@ -65,20 +67,14 @@ export default function CalendarFilterButton({ ); - if (isMobile) { - return ( - - {trigger} - {content} - - ); - } - return ( - - {trigger} - {content} - + ); } diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 95790b171..6d3ee010a 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -1,5 +1,4 @@ import { Button } from "../ui/button"; -import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -8,7 +7,6 @@ import { ReviewFilter, ReviewSeverity, ReviewSummary } from "@/types/review"; import { getEndOfDayTimestamp } from "@/utils/dateUtil"; 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 MobileReviewSettingsDrawer, { @@ -19,6 +17,7 @@ import FilterSwitch from "./FilterSwitch"; import { FilterList } from "@/types/filter"; import CalendarFilterButton from "./CalendarFilterButton"; import { CamerasFilterButton } from "./CamerasFilterButton"; +import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog"; const REVIEW_FILTERS = [ "cameras", @@ -367,28 +366,10 @@ function GeneralFilterButton({ /> ); - if (isMobile) { - return ( - { - if (!open) { - setCurrentLabels(selectedLabels); - } - - setOpen(open); - }} - > - {trigger} - - {content} - - - ); - } - return ( - { if (!open) { @@ -397,10 +378,7 @@ function GeneralFilterButton({ setOpen(open); }} - > - {trigger} - {content} - + /> ); } diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx index 834e9e99b..54618aefc 100644 --- a/web/src/components/filter/SearchFilterGroup.tsx +++ b/web/src/components/filter/SearchFilterGroup.tsx @@ -6,38 +6,29 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { DropdownMenuSeparator } from "../ui/dropdown-menu"; import { getEndOfDayTimestamp } from "@/utils/dateUtil"; 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 FilterSwitch from "./FilterSwitch"; import { FilterList } from "@/types/filter"; import { CalendarRangeFilterButton } from "./CalendarFilterButton"; import { CamerasFilterButton } from "./CamerasFilterButton"; -import { SearchFilter, SearchSource } from "@/types/search"; +import { + DEFAULT_SEARCH_FILTERS, + SearchFilter, + SearchFilters, + SearchSource, + DEFAULT_TIME_RANGE_AFTER, + DEFAULT_TIME_RANGE_BEFORE, +} from "@/types/search"; import { DateRange } from "react-day-picker"; import { cn } from "@/lib/utils"; import SubFilterIcon from "../icons/SubFilterIcon"; import { FaLocationDot } from "react-icons/fa6"; import { MdLabel } from "react-icons/md"; import SearchSourceIcon from "../icons/SearchSourceIcon"; - -const SEARCH_FILTERS = [ - "cameras", - "date", - "general", - "zone", - "sub", - "source", -] as const; -type SearchFilters = (typeof SEARCH_FILTERS)[number]; -const DEFAULT_REVIEW_FILTERS: SearchFilters[] = [ - "cameras", - "date", - "general", - "zone", - "sub", - "source", -]; +import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog"; +import { FaArrowRight, FaClock } from "react-icons/fa"; +import { useFormattedHour } from "@/hooks/use-date-utils"; type SearchFilterGroupProps = { className: string; @@ -48,7 +39,7 @@ type SearchFilterGroupProps = { }; export default function SearchFilterGroup({ className, - filters = DEFAULT_REVIEW_FILTERS, + filters = DEFAULT_SEARCH_FILTERS, filter, filterList, onUpdateFilter, @@ -182,6 +173,15 @@ export default function SearchFilterGroup({ updateSelectedRange={onUpdateSelectedRange} /> )} + {filters.includes("time") && ( + + onUpdateFilter({ ...filter, time_range }) + } + /> + )} {filters.includes("zone") && allZones.length > 0 && ( - onUpdateFilter({ ...filter, subLabels: newSubLabels }) + onUpdateFilter({ ...filter, sub_labels: newSubLabels }) } /> )} @@ -291,28 +291,11 @@ function GeneralFilterButton({ /> ); - if (isMobile) { - return ( - { - if (!open) { - setCurrentLabels(selectedLabels); - } - - setOpen(open); - }} - > - {trigger} - - {content} - - - ); - } - return ( - { if (!open) { @@ -321,10 +304,7 @@ function GeneralFilterButton({ setOpen(open); }} - > - {trigger} - {content} - + /> ); } @@ -418,6 +398,186 @@ export function GeneralFilterContent({ ); } +type TimeRangeFilterButtonProps = { + config?: FrigateConfig; + timeRange?: string; + updateTimeRange: (range: string | undefined) => void; +}; +function TimeRangeFilterButton({ + config, + timeRange, + updateTimeRange, +}: TimeRangeFilterButtonProps) { + const [open, setOpen] = useState(false); + const [startOpen, setStartOpen] = useState(false); + const [endOpen, setEndOpen] = useState(false); + + const [afterHour, beforeHour] = useMemo(() => { + if (!timeRange || !timeRange.includes(",")) { + return [DEFAULT_TIME_RANGE_AFTER, DEFAULT_TIME_RANGE_BEFORE]; + } + + return timeRange.split(","); + }, [timeRange]); + + const [selectedAfterHour, setSelectedAfterHour] = useState(afterHour); + const [selectedBeforeHour, setSelectedBeforeHour] = useState(beforeHour); + + // format based on locale + + const formattedAfter = useFormattedHour(config, afterHour); + const formattedBefore = useFormattedHour(config, beforeHour); + const formattedSelectedAfter = useFormattedHour(config, selectedAfterHour); + const formattedSelectedBefore = useFormattedHour(config, selectedBeforeHour); + + const trigger = ( + + ); + const content = ( +
+
+ { + if (!open) { + setStartOpen(false); + } + }} + > + + + + + { + const clock = e.target.value; + const [hour, minute, _] = clock.split(":"); + setSelectedAfterHour(`${hour}:${minute}`); + }} + /> + + + + { + if (!open) { + setEndOpen(false); + } + }} + > + + + + + { + const clock = e.target.value; + const [hour, minute, _] = clock.split(":"); + setSelectedBeforeHour(`${hour}:${minute}`); + }} + /> + + + +
+
+ + +
+
+ ); + + return ( + { + setOpen(open); + }} + /> + ); +} + type ZoneFilterButtonProps = { allZones: string[]; selectedZones?: string[]; @@ -485,28 +645,10 @@ function ZoneFilterButton({ /> ); - if (isMobile) { - return ( - { - if (!open) { - setCurrentZones(selectedZones); - } - - setOpen(open); - }} - > - {trigger} - - {content} - - - ); - } - return ( - { if (!open) { @@ -515,10 +657,7 @@ function ZoneFilterButton({ setOpen(open); }} - > - {trigger} - {content} - + /> ); } @@ -679,28 +818,10 @@ function SubFilterButton({ /> ); - if (isMobile) { - return ( - { - if (!open) { - setCurrentSubLabels(selectedSubLabels); - } - - setOpen(open); - }} - > - {trigger} - - {content} - - - ); - } - return ( - { if (!open) { @@ -709,10 +830,7 @@ function SubFilterButton({ setOpen(open); }} - > - {trigger} - {content} - + /> ); } @@ -863,32 +981,13 @@ function SearchTypeButton({ /> ); - if (isMobile) { - return ( - { - setOpen(open); - }} - > - {trigger} - - {content} - - - ); - } - return ( - { - setOpen(open); - }} - > - {trigger} - {content} - + onOpenChange={setOpen} + /> ); } diff --git a/web/src/components/input/InputWithTags.tsx b/web/src/components/input/InputWithTags.tsx index 802e4acaa..062a38d13 100644 --- a/web/src/components/input/InputWithTags.tsx +++ b/web/src/components/input/InputWithTags.tsx @@ -35,9 +35,13 @@ import { SaveSearchDialog } from "./SaveSearchDialog"; import { DeleteSearchDialog } from "./DeleteSearchDialog"; import { convertLocalDateToTimestamp, + convertTo12Hour, getIntlDateFormat, + isValidTimeRange, } from "@/utils/dateUtil"; import { toast } from "sonner"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; type InputWithTagsProps = { filters: SearchFilter; @@ -56,6 +60,10 @@ export default function InputWithTags({ setSearch, allSuggestions, }: InputWithTagsProps) { + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + const [inputValue, setInputValue] = useState(search || ""); const [currentFilterType, setCurrentFilterType] = useState( null, @@ -180,7 +188,11 @@ export default function InputWithTags({ const createFilter = useCallback( (type: FilterType, value: string) => { - if (allSuggestions[type as FilterType]?.includes(value)) { + if ( + allSuggestions[type as FilterType]?.includes(value) || + type == "before" || + type == "after" + ) { const newFilters = { ...filters }; let timestamp = 0; @@ -222,6 +234,26 @@ export default function InputWithTags({ newFilters[type] = timestamp / 1000; } break; + case "time_range": + if (!value.includes(",")) { + toast.error( + "The correct format is after,before. Example: 15:00,18:00.", + { + position: "top-center", + }, + ); + return; + } + + if (!isValidTimeRange(value)) { + toast.error("Time range is not valid.", { + position: "top-center", + }); + return; + } + + newFilters[type] = value; + break; case "search_type": if (!newFilters.search_type) newFilters.search_type = []; if ( @@ -256,6 +288,30 @@ export default function InputWithTags({ [filters, setFilters, allSuggestions], ); + function formatFilterValues( + filterType: string, + filterValues: number | string, + ): string { + if (filterType === "before" || filterType === "after") { + return new Date( + (filterType === "before" + ? (filterValues as number) + 1 + : (filterValues as number)) * 1000, + ).toLocaleDateString(window.navigator?.language || "en-US"); + } else if (filterType === "time_range") { + const [startTime, endTime] = (filterValues as string).split(","); + return `${ + config?.ui.time_format === "24hour" + ? startTime + : convertTo12Hour(startTime) + } - ${ + config?.ui.time_format === "24hour" ? endTime : convertTo12Hour(endTime) + }`; + } else { + return filterValues as string; + } + } + // handlers const handleFilterCreation = useCallback( @@ -303,11 +359,7 @@ export default function InputWithTags({ ]; // Check if filter type is valid - if ( - filterType in allSuggestions || - filterType === "before" || - filterType === "after" - ) { + if (filterType in allSuggestions) { setCurrentFilterType(filterType); if (filterType === "before" || filterType === "after") { @@ -604,16 +656,8 @@ export default function InputWithTags({ key={filterType} className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm capitalize text-green-800" > - {filterType}: - {filterType === "before" || filterType === "after" - ? new Date( - (filterType === "before" - ? (filterValues as number) + 1 - : (filterValues as number)) * 1000, - ).toLocaleDateString( - window.navigator?.language || "en-US", - ) - : filterValues} + {filterType.replaceAll("_", " ")}:{" "} + {formatFilterValues(filterType, filterValues)}