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>
This commit is contained in:
Nicolas Mowen 2024-09-25 10:05:40 -06:00 committed by GitHub
parent 4c24b70d47
commit 25819584bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 501 additions and 185 deletions

View File

@ -13,6 +13,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { DateRangePicker } from "../ui/calendar-range"; import { DateRangePicker } from "../ui/calendar-range";
import { DateRange } from "react-day-picker"; import { DateRange } from "react-day-picker";
import { useState } from "react"; import { useState } from "react";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
type CalendarFilterButtonProps = { type CalendarFilterButtonProps = {
reviewSummary?: ReviewSummary; reviewSummary?: ReviewSummary;
@ -24,6 +25,7 @@ export default function CalendarFilterButton({
day, day,
updateSelectedDay, updateSelectedDay,
}: CalendarFilterButtonProps) { }: CalendarFilterButtonProps) {
const [open, setOpen] = useState(false);
const selectedDate = useFormattedTimestamp( const selectedDate = useFormattedTimestamp(
day == undefined ? 0 : day?.getTime() / 1000 + 1, day == undefined ? 0 : day?.getTime() / 1000 + 1,
"%b %-d", "%b %-d",
@ -65,20 +67,14 @@ export default function CalendarFilterButton({
</> </>
); );
if (isMobile) {
return (
<Drawer>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent>{content}</DrawerContent>
</Drawer>
);
}
return ( return (
<Popover> <PlatformAwareDialog
<PopoverTrigger asChild>{trigger}</PopoverTrigger> trigger={trigger}
<PopoverContent className="w-auto">{content}</PopoverContent> content={content}
</Popover> contentClassName="w-auto"
open={open}
onOpenChange={setOpen}
/>
); );
} }

View File

@ -1,5 +1,4 @@
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
@ -8,7 +7,6 @@ import { ReviewFilter, ReviewSeverity, ReviewSummary } from "@/types/review";
import { getEndOfDayTimestamp } from "@/utils/dateUtil"; import { getEndOfDayTimestamp } from "@/utils/dateUtil";
import { FaCheckCircle, FaFilter, FaRunning } from "react-icons/fa"; import { FaCheckCircle, FaFilter, FaRunning } from "react-icons/fa";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
import MobileReviewSettingsDrawer, { import MobileReviewSettingsDrawer, {
@ -19,6 +17,7 @@ import FilterSwitch from "./FilterSwitch";
import { FilterList } from "@/types/filter"; import { FilterList } from "@/types/filter";
import CalendarFilterButton from "./CalendarFilterButton"; import CalendarFilterButton from "./CalendarFilterButton";
import { CamerasFilterButton } from "./CamerasFilterButton"; import { CamerasFilterButton } from "./CamerasFilterButton";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
const REVIEW_FILTERS = [ const REVIEW_FILTERS = [
"cameras", "cameras",
@ -367,28 +366,10 @@ function GeneralFilterButton({
/> />
); );
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentLabels(selectedLabels);
}
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden">
{content}
</DrawerContent>
</Drawer>
);
}
return ( return (
<Popover <PlatformAwareDialog
trigger={trigger}
content={content}
open={open} open={open}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
@ -397,10 +378,7 @@ function GeneralFilterButton({
setOpen(open); setOpen(open);
}} }}
> />
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent>{content}</PopoverContent>
</Popover>
); );
} }

View File

@ -6,38 +6,29 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { DropdownMenuSeparator } from "../ui/dropdown-menu"; import { DropdownMenuSeparator } from "../ui/dropdown-menu";
import { getEndOfDayTimestamp } from "@/utils/dateUtil"; import { getEndOfDayTimestamp } from "@/utils/dateUtil";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
import FilterSwitch from "./FilterSwitch"; import FilterSwitch from "./FilterSwitch";
import { FilterList } from "@/types/filter"; import { FilterList } from "@/types/filter";
import { CalendarRangeFilterButton } from "./CalendarFilterButton"; import { CalendarRangeFilterButton } from "./CalendarFilterButton";
import { CamerasFilterButton } from "./CamerasFilterButton"; 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 { DateRange } from "react-day-picker";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import SubFilterIcon from "../icons/SubFilterIcon"; import SubFilterIcon from "../icons/SubFilterIcon";
import { FaLocationDot } from "react-icons/fa6"; import { FaLocationDot } from "react-icons/fa6";
import { MdLabel } from "react-icons/md"; import { MdLabel } from "react-icons/md";
import SearchSourceIcon from "../icons/SearchSourceIcon"; import SearchSourceIcon from "../icons/SearchSourceIcon";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
const SEARCH_FILTERS = [ import { FaArrowRight, FaClock } from "react-icons/fa";
"cameras", import { useFormattedHour } from "@/hooks/use-date-utils";
"date",
"general",
"zone",
"sub",
"source",
] as const;
type SearchFilters = (typeof SEARCH_FILTERS)[number];
const DEFAULT_REVIEW_FILTERS: SearchFilters[] = [
"cameras",
"date",
"general",
"zone",
"sub",
"source",
];
type SearchFilterGroupProps = { type SearchFilterGroupProps = {
className: string; className: string;
@ -48,7 +39,7 @@ type SearchFilterGroupProps = {
}; };
export default function SearchFilterGroup({ export default function SearchFilterGroup({
className, className,
filters = DEFAULT_REVIEW_FILTERS, filters = DEFAULT_SEARCH_FILTERS,
filter, filter,
filterList, filterList,
onUpdateFilter, onUpdateFilter,
@ -182,6 +173,15 @@ export default function SearchFilterGroup({
updateSelectedRange={onUpdateSelectedRange} updateSelectedRange={onUpdateSelectedRange}
/> />
)} )}
{filters.includes("time") && (
<TimeRangeFilterButton
config={config}
timeRange={filter?.time_range}
updateTimeRange={(time_range) =>
onUpdateFilter({ ...filter, time_range })
}
/>
)}
{filters.includes("zone") && allZones.length > 0 && ( {filters.includes("zone") && allZones.length > 0 && (
<ZoneFilterButton <ZoneFilterButton
allZones={filterValues.zones} allZones={filterValues.zones}
@ -203,9 +203,9 @@ export default function SearchFilterGroup({
{filters.includes("sub") && ( {filters.includes("sub") && (
<SubFilterButton <SubFilterButton
allSubLabels={allSubLabels} allSubLabels={allSubLabels}
selectedSubLabels={filter?.subLabels} selectedSubLabels={filter?.sub_labels}
updateSubLabelFilter={(newSubLabels) => updateSubLabelFilter={(newSubLabels) =>
onUpdateFilter({ ...filter, subLabels: newSubLabels }) onUpdateFilter({ ...filter, sub_labels: newSubLabels })
} }
/> />
)} )}
@ -291,28 +291,11 @@ function GeneralFilterButton({
/> />
); );
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentLabels(selectedLabels);
}
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden p-4">
{content}
</DrawerContent>
</Drawer>
);
}
return ( return (
<Popover <PlatformAwareDialog
trigger={trigger}
content={content}
contentClassName={isDesktop ? "" : "max-h-[75dvh] overflow-hidden p-4"}
open={open} open={open}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
@ -321,10 +304,7 @@ function GeneralFilterButton({
setOpen(open); setOpen(open);
}} }}
> />
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent>{content}</PopoverContent>
</Popover>
); );
} }
@ -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 = (
<Button
size="sm"
variant={timeRange ? "select" : "default"}
className="flex items-center gap-2 capitalize"
>
<FaClock
className={`${timeRange ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
<div
className={`${timeRange ? "text-selected-foreground" : "text-primary"}`}
>
{timeRange ? `${formattedAfter} - ${formattedBefore}` : "All Times"}
</div>
</Button>
);
const content = (
<div
className={cn(
"scrollbar-container flex h-auto max-h-[80dvh] flex-col overflow-y-auto overflow-x-hidden",
isDesktop ? "w-64" : "w-full gap-2 pt-2",
)}
>
<div
className={cn(
"mt-3 flex w-full items-center rounded-lg text-secondary-foreground",
isDesktop ? "mx-6 gap-2 px-2" : "justify-center gap-2",
)}
>
<Popover
open={startOpen}
onOpenChange={(open) => {
if (!open) {
setStartOpen(false);
}
}}
>
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"} `}
variant={startOpen ? "select" : "default"}
size="sm"
onClick={() => {
setStartOpen(true);
setEndOpen(false);
}}
>
{formattedSelectedAfter}
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col items-center">
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={selectedAfterHour}
step="60"
onChange={(e) => {
const clock = e.target.value;
const [hour, minute, _] = clock.split(":");
setSelectedAfterHour(`${hour}:${minute}`);
}}
/>
</PopoverContent>
</Popover>
<FaArrowRight className="size-4 text-primary" />
<Popover
open={endOpen}
onOpenChange={(open) => {
if (!open) {
setEndOpen(false);
}
}}
>
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
variant={endOpen ? "select" : "default"}
size="sm"
onClick={() => {
setEndOpen(true);
setStartOpen(false);
}}
>
{formattedSelectedBefore}
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col items-center">
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={
selectedBeforeHour == "24:00" ? "23:59" : selectedBeforeHour
}
step="60"
onChange={(e) => {
const clock = e.target.value;
const [hour, minute, _] = clock.split(":");
setSelectedBeforeHour(`${hour}:${minute}`);
}}
/>
</PopoverContent>
</Popover>
<DropdownMenuSeparator />
</div>
<div className="flex items-center justify-evenly p-2">
<Button
variant="select"
onClick={() => {
if (
selectedAfterHour == DEFAULT_TIME_RANGE_AFTER &&
selectedBeforeHour == DEFAULT_TIME_RANGE_BEFORE
) {
updateTimeRange(undefined);
} else {
updateTimeRange(`${selectedAfterHour},${selectedBeforeHour}`);
}
setOpen(false);
}}
>
Apply
</Button>
<Button
onClick={() => {
setSelectedAfterHour(DEFAULT_TIME_RANGE_AFTER);
setSelectedBeforeHour(DEFAULT_TIME_RANGE_BEFORE);
}}
>
Reset
</Button>
</div>
</div>
);
return (
<PlatformAwareDialog
trigger={trigger}
content={content}
open={open}
onOpenChange={(open) => {
setOpen(open);
}}
/>
);
}
type ZoneFilterButtonProps = { type ZoneFilterButtonProps = {
allZones: string[]; allZones: string[];
selectedZones?: string[]; selectedZones?: string[];
@ -485,28 +645,10 @@ function ZoneFilterButton({
/> />
); );
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentZones(selectedZones);
}
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden p-4">
{content}
</DrawerContent>
</Drawer>
);
}
return ( return (
<Popover <PlatformAwareDialog
trigger={trigger}
content={content}
open={open} open={open}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
@ -515,10 +657,7 @@ function ZoneFilterButton({
setOpen(open); setOpen(open);
}} }}
> />
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent>{content}</PopoverContent>
</Popover>
); );
} }
@ -679,28 +818,10 @@ function SubFilterButton({
/> />
); );
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentSubLabels(selectedSubLabels);
}
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden p-4">
{content}
</DrawerContent>
</Drawer>
);
}
return ( return (
<Popover <PlatformAwareDialog
trigger={trigger}
content={content}
open={open} open={open}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
@ -709,10 +830,7 @@ function SubFilterButton({
setOpen(open); setOpen(open);
}} }}
> />
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent>{content}</PopoverContent>
</Popover>
); );
} }
@ -863,32 +981,13 @@ function SearchTypeButton({
/> />
); );
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(open) => {
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden p-4">
{content}
</DrawerContent>
</Drawer>
);
}
return ( return (
<Popover <PlatformAwareDialog
trigger={trigger}
content={content}
open={open} open={open}
onOpenChange={(open) => { onOpenChange={setOpen}
setOpen(open); />
}}
>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent>{content}</PopoverContent>
</Popover>
); );
} }

View File

@ -35,9 +35,13 @@ import { SaveSearchDialog } from "./SaveSearchDialog";
import { DeleteSearchDialog } from "./DeleteSearchDialog"; import { DeleteSearchDialog } from "./DeleteSearchDialog";
import { import {
convertLocalDateToTimestamp, convertLocalDateToTimestamp,
convertTo12Hour,
getIntlDateFormat, getIntlDateFormat,
isValidTimeRange,
} from "@/utils/dateUtil"; } from "@/utils/dateUtil";
import { toast } from "sonner"; import { toast } from "sonner";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
type InputWithTagsProps = { type InputWithTagsProps = {
filters: SearchFilter; filters: SearchFilter;
@ -56,6 +60,10 @@ export default function InputWithTags({
setSearch, setSearch,
allSuggestions, allSuggestions,
}: InputWithTagsProps) { }: InputWithTagsProps) {
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const [inputValue, setInputValue] = useState(search || ""); const [inputValue, setInputValue] = useState(search || "");
const [currentFilterType, setCurrentFilterType] = useState<FilterType | null>( const [currentFilterType, setCurrentFilterType] = useState<FilterType | null>(
null, null,
@ -180,7 +188,11 @@ export default function InputWithTags({
const createFilter = useCallback( const createFilter = useCallback(
(type: FilterType, value: string) => { (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 }; const newFilters = { ...filters };
let timestamp = 0; let timestamp = 0;
@ -222,6 +234,26 @@ export default function InputWithTags({
newFilters[type] = timestamp / 1000; newFilters[type] = timestamp / 1000;
} }
break; 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": case "search_type":
if (!newFilters.search_type) newFilters.search_type = []; if (!newFilters.search_type) newFilters.search_type = [];
if ( if (
@ -256,6 +288,30 @@ export default function InputWithTags({
[filters, setFilters, allSuggestions], [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 // handlers
const handleFilterCreation = useCallback( const handleFilterCreation = useCallback(
@ -303,11 +359,7 @@ export default function InputWithTags({
]; ];
// Check if filter type is valid // Check if filter type is valid
if ( if (filterType in allSuggestions) {
filterType in allSuggestions ||
filterType === "before" ||
filterType === "after"
) {
setCurrentFilterType(filterType); setCurrentFilterType(filterType);
if (filterType === "before" || filterType === "after") { if (filterType === "before" || filterType === "after") {
@ -604,16 +656,8 @@ export default function InputWithTags({
key={filterType} 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" 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.replaceAll("_", " ")}:{" "}
{filterType === "before" || filterType === "after" {formatFilterValues(filterType, filterValues)}
? new Date(
(filterType === "before"
? (filterValues as number) + 1
: (filterValues as number)) * 1000,
).toLocaleDateString(
window.navigator?.language || "en-US",
)
: filterValues}
<button <button
onClick={() => onClick={() =>
removeFilter( removeFilter(

View File

@ -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 (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden p-4">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild className={triggerClassName}>
{trigger}
</PopoverTrigger>
<PopoverContent className={contentClassName}>{content}</PopoverContent>
</Popover>
);
}

View File

@ -43,3 +43,53 @@ export function useTimezone(config: FrigateConfig | undefined) {
); );
}, [config]); }, [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]);
}

View File

@ -48,8 +48,6 @@ export default function useSuggestions(
setSuggestions([ setSuggestions([
...(searchHistory?.map((search) => search.name) ?? []), ...(searchHistory?.map((search) => search.name) ?? []),
...availableFilters, ...availableFilters,
"before",
"after",
]); ]);
} }
}, },

View File

@ -1,9 +1,12 @@
import { useEventUpdate } from "@/api/ws"; import { useEventUpdate } from "@/api/ws";
import { useApiFilterArgs } from "@/hooks/use-api-filter"; 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 { SearchFilter, SearchQuery, SearchResult } from "@/types/search";
import SearchView from "@/views/search/SearchView"; import SearchView from "@/views/search/SearchView";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { TbExclamationCircle } from "react-icons/tb"; import { TbExclamationCircle } from "react-icons/tb";
import useSWR from "swr";
import useSWRInfinite from "swr/infinite"; import useSWRInfinite from "swr/infinite";
const API_LIMIT = 25; const API_LIMIT = 25;
@ -11,6 +14,12 @@ const API_LIMIT = 25;
export default function Explore() { export default function Explore() {
// search field handler // search field handler
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const timezone = useTimezone(config);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [searchFilter, setSearchFilter, searchSearchParams] = const [searchFilter, setSearchFilter, searchSearchParams] =
@ -61,13 +70,15 @@ export default function Explore() {
{ {
cameras: searchSearchParams["cameras"], cameras: searchSearchParams["cameras"],
labels: searchSearchParams["labels"], labels: searchSearchParams["labels"],
sub_labels: searchSearchParams["subLabels"], sub_labels: searchSearchParams["sub_labels"],
zones: searchSearchParams["zones"], zones: searchSearchParams["zones"],
before: searchSearchParams["before"], before: searchSearchParams["before"],
after: searchSearchParams["after"], after: searchSearchParams["after"],
time_range: searchSearchParams["time_range"],
search_type: searchSearchParams["search_type"], search_type: searchSearchParams["search_type"],
limit: limit:
Object.keys(searchSearchParams).length == 0 ? API_LIMIT : undefined, Object.keys(searchSearchParams).length == 0 ? API_LIMIT : undefined,
timezone,
in_progress: 0, in_progress: 0,
include_thumbnails: 0, include_thumbnails: 0,
}, },
@ -85,16 +96,17 @@ export default function Explore() {
query: similaritySearch ? undefined : searchTerm, query: similaritySearch ? undefined : searchTerm,
cameras: searchSearchParams["cameras"], cameras: searchSearchParams["cameras"],
labels: searchSearchParams["labels"], labels: searchSearchParams["labels"],
sub_labels: searchSearchParams["subLabels"], sub_labels: searchSearchParams["sub_labels"],
zones: searchSearchParams["zones"], zones: searchSearchParams["zones"],
before: searchSearchParams["before"], before: searchSearchParams["before"],
after: searchSearchParams["after"], after: searchSearchParams["after"],
time_range: searchSearchParams["time_range"],
search_type: searchSearchParams["search_type"], search_type: searchSearchParams["search_type"],
event_id: searchSearchParams["event_id"], event_id: searchSearchParams["event_id"],
include_thumbnails: 0, include_thumbnails: 0,
}, },
]; ];
}, [searchTerm, searchSearchParams, similaritySearch]); }, [searchTerm, searchSearchParams, similaritySearch, timezone]);
// paging // paging

View File

@ -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 SearchSource = "similarity" | "thumbnail" | "description";
export type SearchResult = { export type SearchResult = {
@ -32,14 +52,18 @@ export type SearchFilter = {
query?: string; query?: string;
cameras?: string[]; cameras?: string[];
labels?: string[]; labels?: string[];
subLabels?: string[]; sub_labels?: string[];
zones?: string[]; zones?: string[];
before?: number; before?: number;
after?: number; after?: number;
time_range?: string;
search_type?: SearchSource[]; search_type?: SearchSource[];
event_id?: string; event_id?: string;
}; };
export const DEFAULT_TIME_RANGE_AFTER = "00:00";
export const DEFAULT_TIME_RANGE_BEFORE = "23:59";
export type SearchQueryParams = { export type SearchQueryParams = {
cameras?: string[]; cameras?: string[];
labels?: string[]; labels?: string[];
@ -53,6 +77,7 @@ export type SearchQueryParams = {
include_thumbnails?: number; include_thumbnails?: number;
query?: string; query?: string;
page?: number; page?: number;
time_range?: string;
}; };
export type SearchQuery = [string, SearchQueryParams] | null; export type SearchQuery = [string, SearchQueryParams] | null;

View File

@ -373,3 +373,48 @@ export function getIntlDateFormat() {
}, [] as string[]) }, [] as string[])
.join(""); .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}`;
}

View File

@ -11,7 +11,13 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig"; 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isMobileOnly } from "react-device-detect"; import { isMobileOnly } from "react-device-detect";
import { LuImage, LuSearchX, LuText } from "react-icons/lu"; 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 InputWithTags from "@/components/input/InputWithTags";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import { formatDateToLocaleString } from "@/utils/dateUtil";
type SearchViewProps = { type SearchViewProps = {
search: string; search: string;
@ -114,6 +121,9 @@ export default function SearchView({
zones: Object.values(allZones || {}), zones: Object.values(allZones || {}),
sub_labels: allSubLabels, sub_labels: allSubLabels,
search_type: ["thumbnail", "description"] as SearchSource[], search_type: ["thumbnail", "description"] as SearchSource[],
time_range: ["00:00,24:00"],
before: [formatDateToLocaleString()],
after: [formatDateToLocaleString(-5)],
}), }),
[config, allLabels, allZones, allSubLabels], [config, allLabels, allZones, allSubLabels],
); );
@ -131,6 +141,20 @@ export default function SearchView({
const [searchDetail, setSearchDetail] = useState<SearchResult>(); const [searchDetail, setSearchDetail] = useState<SearchResult>();
const selectedFilters = useMemo<SearchFilters[]>(() => {
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 // search interaction
const [selectedIndex, setSelectedIndex] = useState<number | null>(null); const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
@ -300,6 +324,7 @@ export default function SearchView({
"w-full justify-between md:justify-start lg:justify-end", "w-full justify-between md:justify-start lg:justify-end",
)} )}
filter={searchFilter} filter={searchFilter}
filters={selectedFilters as SearchFilters[]}
onUpdateFilter={onUpdateFilter} onUpdateFilter={onUpdateFilter}
/> />
<ScrollBar orientation="horizontal" className="h-0" /> <ScrollBar orientation="horizontal" className="h-0" />