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 = ( + + + + {timeRange ? `${formattedAfter} - ${formattedBefore}` : "All Times"} + + + ); + const content = ( + + + { + if (!open) { + setStartOpen(false); + } + }} + > + + { + setStartOpen(true); + setEndOpen(false); + }} + > + {formattedSelectedAfter} + + + + { + const clock = e.target.value; + const [hour, minute, _] = clock.split(":"); + setSelectedAfterHour(`${hour}:${minute}`); + }} + /> + + + + { + if (!open) { + setEndOpen(false); + } + }} + > + + { + setEndOpen(true); + setStartOpen(false); + }} + > + {formattedSelectedBefore} + + + + { + const clock = e.target.value; + const [hour, minute, _] = clock.split(":"); + setSelectedBeforeHour(`${hour}:${minute}`); + }} + /> + + + + + + { + if ( + selectedAfterHour == DEFAULT_TIME_RANGE_AFTER && + selectedBeforeHour == DEFAULT_TIME_RANGE_BEFORE + ) { + updateTimeRange(undefined); + } else { + updateTimeRange(`${selectedAfterHour},${selectedBeforeHour}`); + } + + setOpen(false); + }} + > + Apply + + { + setSelectedAfterHour(DEFAULT_TIME_RANGE_AFTER); + setSelectedBeforeHour(DEFAULT_TIME_RANGE_BEFORE); + }} + > + Reset + + + + ); + + 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)} removeFilter( diff --git a/web/src/components/overlay/dialog/PlatformAwareDialog.tsx b/web/src/components/overlay/dialog/PlatformAwareDialog.tsx new file mode 100644 index 000000000..8c57fd15f --- /dev/null +++ b/web/src/components/overlay/dialog/PlatformAwareDialog.tsx @@ -0,0 +1,44 @@ +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} + + + ); + } + + return ( + + + {trigger} + + {content} + + ); +} diff --git a/web/src/hooks/use-date-utils.ts b/web/src/hooks/use-date-utils.ts index 00f523920..2285a0ed2 100644 --- a/web/src/hooks/use-date-utils.ts +++ b/web/src/hooks/use-date-utils.ts @@ -43,3 +43,53 @@ export function useTimezone(config: FrigateConfig | undefined) { ); }, [config]); } + +function use24HourTime(config: FrigateConfig | undefined) { + const localeUses24HourTime = useMemo( + () => + new Intl.DateTimeFormat(undefined, { + hour: "numeric", + }) + ?.formatToParts(new Date(2020, 0, 1, 13)) + ?.find((part) => part.type === "hour")?.value?.length === 2, + [], + ); + + return useMemo(() => { + if (!config) { + return false; + } + + if (config.ui.time_format != "browser") { + return config.ui.time_format == "24hour"; + } + + return localeUses24HourTime; + }, [config, localeUses24HourTime]); +} + +export function useFormattedHour( + config: FrigateConfig | undefined, + time: string, // hour is assumed to be in 24 hour format per the Date object +) { + const hour24 = use24HourTime(config); + + return useMemo(() => { + if (hour24) { + return time; + } + + const [hour, minute] = time.includes(":") ? time.split(":") : [time, "00"]; + const hourNum = parseInt(hour); + + if (hourNum < 12) { + if (hourNum == 0) { + return `12:${minute} AM`; + } + + return `${hourNum}:${minute} AM`; + } else { + return `${hourNum - 12}:${minute} PM`; + } + }, [hour24, time]); +} diff --git a/web/src/hooks/use-suggestions.ts b/web/src/hooks/use-suggestions.ts index 9222c866c..28e8f3fca 100644 --- a/web/src/hooks/use-suggestions.ts +++ b/web/src/hooks/use-suggestions.ts @@ -48,8 +48,6 @@ export default function useSuggestions( setSuggestions([ ...(searchHistory?.map((search) => search.name) ?? []), ...availableFilters, - "before", - "after", ]); } }, diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index a653b176f..df69fc579 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] = @@ -61,13 +70,15 @@ export default function Explore() { { cameras: searchSearchParams["cameras"], labels: searchSearchParams["labels"], - sub_labels: searchSearchParams["subLabels"], + sub_labels: searchSearchParams["sub_labels"], zones: searchSearchParams["zones"], before: searchSearchParams["before"], after: searchSearchParams["after"], + time_range: searchSearchParams["time_range"], search_type: searchSearchParams["search_type"], limit: Object.keys(searchSearchParams).length == 0 ? API_LIMIT : undefined, + timezone, in_progress: 0, include_thumbnails: 0, }, @@ -85,16 +96,17 @@ export default function Explore() { query: similaritySearch ? undefined : searchTerm, cameras: searchSearchParams["cameras"], labels: searchSearchParams["labels"], - sub_labels: searchSearchParams["subLabels"], + sub_labels: searchSearchParams["sub_labels"], zones: searchSearchParams["zones"], before: searchSearchParams["before"], after: searchSearchParams["after"], + time_range: searchSearchParams["time_range"], search_type: searchSearchParams["search_type"], event_id: searchSearchParams["event_id"], 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..50521878e 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 = { @@ -32,14 +52,18 @@ export type SearchFilter = { query?: string; cameras?: string[]; labels?: string[]; - subLabels?: string[]; + sub_labels?: string[]; zones?: string[]; before?: number; after?: number; + time_range?: string; search_type?: SearchSource[]; event_id?: string; }; +export const DEFAULT_TIME_RANGE_AFTER = "00:00"; +export const DEFAULT_TIME_RANGE_BEFORE = "23:59"; + export type SearchQueryParams = { cameras?: string[]; labels?: string[]; @@ -53,6 +77,7 @@ export type SearchQueryParams = { include_thumbnails?: number; query?: string; page?: number; + time_range?: string; }; export type SearchQuery = [string, SearchQueryParams] | null; diff --git a/web/src/utils/dateUtil.ts b/web/src/utils/dateUtil.ts index deb1dc850..9e0b25c5c 100644 --- a/web/src/utils/dateUtil.ts +++ b/web/src/utils/dateUtil.ts @@ -373,3 +373,48 @@ export function getIntlDateFormat() { }, [] as string[]) .join(""); } + +export function formatDateToLocaleString(daysOffset: number = 0): string { + const date = new Date(); + date.setDate(date.getDate() + daysOffset); + + return new Intl.DateTimeFormat(window.navigator.language, { + day: "2-digit", + month: "2-digit", + year: "numeric", + }) + .format(date) + .replace(/[^\d]/g, ""); +} + +export function isValidTimeRange(rangeString: string): boolean { + const range = rangeString.split(","); + + if (range.length !== 2) { + return false; + } + + const toMinutes = (time: string): number => { + const [h, m] = time.split(":").map(Number); + return h * 60 + m; + }; + + const isValidTime = (time: string): boolean => + /^(?:([01]\d|2[0-3]):([0-5]\d)|24:00)$/.test(time); + + const [startTime, endTime] = range; + + return ( + isValidTime(startTime) && + isValidTime(endTime) && + toMinutes(startTime) < toMinutes(endTime) + ); +} + +export function convertTo12Hour(time: string) { + const [hours, minutes] = time.split(":"); + const hour = parseInt(hours, 10); + const ampm = hour >= 12 ? "PM" : "AM"; + const hour12 = hour % 12 || 12; + return `${hour12}:${minutes} ${ampm}`; +} diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index 8faae24b4..0a41163f6 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"; @@ -24,6 +30,7 @@ import scrollIntoView from "scroll-into-view-if-needed"; import InputWithTags from "@/components/input/InputWithTags"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { isEqual } from "lodash"; +import { formatDateToLocaleString } from "@/utils/dateUtil"; type SearchViewProps = { search: string; @@ -114,6 +121,9 @@ export default function SearchView({ zones: Object.values(allZones || {}), sub_labels: allSubLabels, search_type: ["thumbnail", "description"] as SearchSource[], + time_range: ["00:00,24:00"], + before: [formatDateToLocaleString()], + after: [formatDateToLocaleString(-5)], }), [config, allLabels, allZones, allSubLabels], ); @@ -131,6 +141,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 +324,7 @@ export default function SearchView({ "w-full justify-between md:justify-start lg:justify-end", )} filter={searchFilter} + filters={selectedFilters as SearchFilters[]} onUpdateFilter={onUpdateFilter} />