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..23164dc73 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -19,6 +19,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 +368,10 @@ function GeneralFilterButton({ /> ); - if (isMobile) { - return ( - { - if (!open) { - setCurrentLabels(selectedLabels); - } - - setOpen(open); - }} - > - {trigger} - - {content} - - - ); - } - return ( - { if (!open) { @@ -397,10 +380,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..64a0fc699 100644 --- a/web/src/components/filter/SearchFilterGroup.tsx +++ b/web/src/components/filter/SearchFilterGroup.tsx @@ -6,38 +6,26 @@ 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, +} 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"; type SearchFilterGroupProps = { className: string; @@ -48,7 +36,7 @@ type SearchFilterGroupProps = { }; export default function SearchFilterGroup({ className, - filters = DEFAULT_REVIEW_FILTERS, + filters = DEFAULT_SEARCH_FILTERS, filter, filterList, onUpdateFilter, @@ -182,6 +170,14 @@ export default function SearchFilterGroup({ updateSelectedRange={onUpdateSelectedRange} /> )} + {filters.includes("time") && ( + + onUpdateFilter({ ...filter, timeRange }) + } + /> + )} {filters.includes("zone") && allZones.length > 0 && ( ); - if (isMobile) { - return ( - { - if (!open) { - setCurrentLabels(selectedLabels); - } - - setOpen(open); - }} - > - {trigger} - - {content} - - - ); - } - return ( - { if (!open) { @@ -321,10 +300,7 @@ function GeneralFilterButton({ setOpen(open); }} - > - {trigger} - {content} - + /> ); } @@ -418,6 +394,164 @@ export function GeneralFilterContent({ ); } +type TimeRangeFilterButtonProps = { + timeRange?: string; + updateTimeRange: (range: string | undefined) => void; +}; +function TimeRangeFilterButton({ + 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 ["00:00", "24:00"]; + } + + return timeRange.split(","); + }, [timeRange]); + + const [selectedAfterHour, setSelectedAfterHour] = useState(afterHour); + const [selectedBeforeHour, setSelectedBeforeHour] = useState(beforeHour); + + const trigger = ( + + + + {timeRange ? `${afterHour} - ${beforeHour}` : "All Times"} + + + ); + const content = ( + + + { + if (!open) { + setStartOpen(false); + } + }} + > + + { + setStartOpen(true); + setEndOpen(false); + }} + > + {selectedAfterHour} + + + + { + const clock = e.target.value; + const [hour, minute, _] = clock.split(":"); + setSelectedAfterHour(`${hour}:${minute}`); + }} + /> + + + + { + if (!open) { + setEndOpen(false); + } + }} + > + + { + setEndOpen(true); + setStartOpen(false); + }} + > + {selectedBeforeHour} + + + + { + const clock = e.target.value; + const [hour, minute, _] = clock.split(":"); + setSelectedBeforeHour(`${hour}:${minute}`); + }} + /> + + + + + + { + if (selectedAfterHour == "00:00" && selectedBeforeHour == "24:00") { + updateTimeRange(undefined); + } else { + updateTimeRange(`${selectedAfterHour},${selectedBeforeHour}`); + } + + setOpen(false); + }} + > + Apply + + { + setSelectedAfterHour("00:00"); + setSelectedBeforeHour("24:00"); + }} + > + Reset + + + + ); + + return ( + { + setOpen(open); + }} + /> + ); +} + type ZoneFilterButtonProps = { allZones: string[]; selectedZones?: string[]; @@ -485,28 +619,10 @@ function ZoneFilterButton({ /> ); - if (isMobile) { - return ( - { - if (!open) { - setCurrentZones(selectedZones); - } - - setOpen(open); - }} - > - {trigger} - - {content} - - - ); - } - return ( - { if (!open) { @@ -515,10 +631,7 @@ function ZoneFilterButton({ setOpen(open); }} - > - {trigger} - {content} - + /> ); } @@ -679,28 +792,10 @@ function SubFilterButton({ /> ); - if (isMobile) { - return ( - { - if (!open) { - setCurrentSubLabels(selectedSubLabels); - } - - setOpen(open); - }} - > - {trigger} - - {content} - - - ); - } - return ( - { if (!open) { @@ -709,10 +804,7 @@ function SubFilterButton({ setOpen(open); }} - > - {trigger} - {content} - + /> ); } @@ -863,32 +955,13 @@ function SearchTypeButton({ /> ); - if (isMobile) { - return ( - { - setOpen(open); - }} - > - {trigger} - - {content} - - - ); - } - return ( - { - setOpen(open); - }} - > - {trigger} - {content} - + onOpenChange={setOpen} + /> ); } diff --git a/web/src/components/overlay/dialog/PlatformAwareDialog.tsx b/web/src/components/overlay/dialog/PlatformAwareDialog.tsx new file mode 100644 index 000000000..1de4af132 --- /dev/null +++ b/web/src/components/overlay/dialog/PlatformAwareDialog.tsx @@ -0,0 +1,45 @@ +import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { isMobile } from "react-device-detect"; + +type PlatformAwareDialogProps = { + trigger: JSX.Element; + content: JSX.Element; + triggerClassName?: string; + contentClassName?: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}; +export default function PlatformAwareDialog({ + trigger, + content, + triggerClassName = "", + contentClassName = "", + open, + onOpenChange, +}: PlatformAwareDialogProps) { + if (isMobile) { + return ( + + {trigger} + + {content} + const [open, setOpen] = useState(false); + + + ); + } + + return ( + + + {trigger} + + {content} + + ); +} diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index a653b176f..7f2204e56 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -1,9 +1,12 @@ import { useEventUpdate } from "@/api/ws"; import { useApiFilterArgs } from "@/hooks/use-api-filter"; +import { useTimezone } from "@/hooks/use-date-utils"; +import { FrigateConfig } from "@/types/frigateConfig"; import { SearchFilter, SearchQuery, SearchResult } from "@/types/search"; import SearchView from "@/views/search/SearchView"; import { useCallback, useEffect, useMemo, useState } from "react"; import { TbExclamationCircle } from "react-icons/tb"; +import useSWR from "swr"; import useSWRInfinite from "swr/infinite"; const API_LIMIT = 25; @@ -11,6 +14,12 @@ const API_LIMIT = 25; export default function Explore() { // search field handler + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + + const timezone = useTimezone(config); + const [search, setSearch] = useState(""); const [searchFilter, setSearchFilter, searchSearchParams] = @@ -65,9 +74,11 @@ export default function Explore() { zones: searchSearchParams["zones"], before: searchSearchParams["before"], after: searchSearchParams["after"], + time_range: searchSearchParams["timeRange"], search_type: searchSearchParams["search_type"], limit: Object.keys(searchSearchParams).length == 0 ? API_LIMIT : undefined, + timezone, in_progress: 0, include_thumbnails: 0, }, @@ -94,7 +105,7 @@ export default function Explore() { include_thumbnails: 0, }, ]; - }, [searchTerm, searchSearchParams, similaritySearch]); + }, [searchTerm, searchSearchParams, similaritySearch, timezone]); // paging diff --git a/web/src/types/search.ts b/web/src/types/search.ts index c83e1aed2..4a181e65e 100644 --- a/web/src/types/search.ts +++ b/web/src/types/search.ts @@ -1,3 +1,23 @@ +const SEARCH_FILTERS = [ + "cameras", + "date", + "time", + "general", + "zone", + "sub", + "source", +] as const; +export type SearchFilters = (typeof SEARCH_FILTERS)[number]; +export const DEFAULT_SEARCH_FILTERS: SearchFilters[] = [ + "cameras", + "date", + "time", + "general", + "zone", + "sub", + "source", +]; + export type SearchSource = "similarity" | "thumbnail" | "description"; export type SearchResult = { @@ -36,6 +56,7 @@ export type SearchFilter = { zones?: string[]; before?: number; after?: number; + timeRange?: string; search_type?: SearchSource[]; event_id?: string; }; diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index 8faae24b4..2804d2e83 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -11,7 +11,13 @@ import { } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { FrigateConfig } from "@/types/frigateConfig"; -import { SearchFilter, SearchResult, SearchSource } from "@/types/search"; +import { + DEFAULT_SEARCH_FILTERS, + SearchFilter, + SearchFilters, + SearchResult, + SearchSource, +} from "@/types/search"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isMobileOnly } from "react-device-detect"; import { LuImage, LuSearchX, LuText } from "react-icons/lu"; @@ -131,6 +137,20 @@ export default function SearchView({ const [searchDetail, setSearchDetail] = useState(); + const selectedFilters = useMemo(() => { + const filters = [...DEFAULT_SEARCH_FILTERS]; + + if ( + searchFilter && + (searchFilter?.query?.length || searchFilter?.event_id?.length) + ) { + const index = filters.indexOf("time"); + filters.splice(index, 1); + } + + return filters; + }, [searchFilter]); + // search interaction const [selectedIndex, setSelectedIndex] = useState(null); @@ -300,6 +320,7 @@ export default function SearchView({ "w-full justify-between md:justify-start lg:justify-end", )} filter={searchFilter} + filters={selectedFilters as SearchFilters[]} onUpdateFilter={onUpdateFilter} />