Add ability to filter by time range

This commit is contained in:
Nicolas Mowen 2024-09-24 16:13:27 -06:00
parent 8c540d7210
commit 52b562eed8
7 changed files with 307 additions and 160 deletions

View File

@ -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 (
<Drawer>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent>{content}</DrawerContent>
</Drawer>
);
}
return (
<Popover>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent className="w-auto">{content}</PopoverContent>
</Popover>
<PlatformAwareDialog
trigger={trigger}
content={content}
contentClassName="w-auto"
open={open}
onOpenChange={setOpen}
/>
);
}

View File

@ -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,9 +368,10 @@ function GeneralFilterButton({
/>
);
if (isMobile) {
return (
<Drawer
<PlatformAwareDialog
trigger={trigger}
content={content}
open={open}
onOpenChange={(open) => {
if (!open) {
@ -378,29 +380,7 @@ function GeneralFilterButton({
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<Popover
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentLabels(selectedLabels);
}
setOpen(open);
}}
>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent>{content}</PopoverContent>
</Popover>
/>
);
}

View File

@ -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") && (
<TimeRangeFilterButton
timeRange={filter?.timeRange}
updateTimeRange={(timeRange) =>
onUpdateFilter({ ...filter, timeRange })
}
/>
)}
{filters.includes("zone") && allZones.length > 0 && (
<ZoneFilterButton
allZones={filterValues.zones}
@ -291,9 +287,11 @@ function GeneralFilterButton({
/>
);
if (isMobile) {
return (
<Drawer
<PlatformAwareDialog
trigger={trigger}
content={content}
contentClassName={isDesktop ? "" : "max-h-[75dvh] overflow-hidden p-4"}
open={open}
onOpenChange={(open) => {
if (!open) {
@ -302,29 +300,7 @@ function GeneralFilterButton({
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden p-4">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<Popover
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentLabels(selectedLabels);
}
setOpen(open);
}}
>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent>{content}</PopoverContent>
</Popover>
/>
);
}
@ -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 = (
<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 ? `${afterHour} - ${beforeHour}` : "All Times"}
</div>
</Button>
);
const content = (
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
<div
className={`mt-3 flex items-center rounded-lg bg-secondary text-secondary-foreground ${isDesktop ? "mx-8 gap-2 px-2" : "pl-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);
}}
>
{selectedAfterHour}
</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);
}}
>
{selectedBeforeHour}
</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}
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 == "00:00" && selectedBeforeHour == "24:00") {
updateTimeRange(undefined);
} else {
updateTimeRange(`${selectedAfterHour},${selectedBeforeHour}`);
}
setOpen(false);
}}
>
Apply
</Button>
<Button
onClick={() => {
setSelectedAfterHour("00:00");
setSelectedBeforeHour("24:00");
}}
>
Reset
</Button>
</div>
</div>
);
return (
<PlatformAwareDialog
trigger={trigger}
content={content}
open={open}
onOpenChange={(open) => {
setOpen(open);
}}
/>
);
}
type ZoneFilterButtonProps = {
allZones: string[];
selectedZones?: string[];
@ -485,9 +619,10 @@ function ZoneFilterButton({
/>
);
if (isMobile) {
return (
<Drawer
<PlatformAwareDialog
trigger={trigger}
content={content}
open={open}
onOpenChange={(open) => {
if (!open) {
@ -496,29 +631,7 @@ function ZoneFilterButton({
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden p-4">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<Popover
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentZones(selectedZones);
}
setOpen(open);
}}
>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent>{content}</PopoverContent>
</Popover>
/>
);
}
@ -679,9 +792,10 @@ function SubFilterButton({
/>
);
if (isMobile) {
return (
<Drawer
<PlatformAwareDialog
trigger={trigger}
content={content}
open={open}
onOpenChange={(open) => {
if (!open) {
@ -690,29 +804,7 @@ function SubFilterButton({
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden p-4">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<Popover
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentSubLabels(selectedSubLabels);
}
setOpen(open);
}}
>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent>{content}</PopoverContent>
</Popover>
/>
);
}
@ -863,32 +955,13 @@ function SearchTypeButton({
/>
);
if (isMobile) {
return (
<Drawer
<PlatformAwareDialog
trigger={trigger}
content={content}
open={open}
onOpenChange={(open) => {
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden p-4">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<Popover
open={open}
onOpenChange={(open) => {
setOpen(open);
}}
>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent>{content}</PopoverContent>
</Popover>
onOpenChange={setOpen}
/>
);
}

View File

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

View File

@ -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<FrigateConfig>("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

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 SearchResult = {
@ -36,6 +56,7 @@ export type SearchFilter = {
zones?: string[];
before?: number;
after?: number;
timeRange?: string;
search_type?: SearchSource[];
event_id?: string;
};

View File

@ -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<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
const [selectedIndex, setSelectedIndex] = useState<number | null>(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}
/>
<ScrollBar orientation="horizontal" className="h-0" />