mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
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:
parent
4c24b70d47
commit
25819584bd
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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 (
|
||||
<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 (
|
||||
<Popover
|
||||
<PlatformAwareDialog
|
||||
trigger={trigger}
|
||||
content={content}
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
@ -397,10 +378,7 @@ function GeneralFilterButton({
|
||||
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent>{content}</PopoverContent>
|
||||
</Popover>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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") && (
|
||||
<TimeRangeFilterButton
|
||||
config={config}
|
||||
timeRange={filter?.time_range}
|
||||
updateTimeRange={(time_range) =>
|
||||
onUpdateFilter({ ...filter, time_range })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{filters.includes("zone") && allZones.length > 0 && (
|
||||
<ZoneFilterButton
|
||||
allZones={filterValues.zones}
|
||||
@ -203,9 +203,9 @@ export default function SearchFilterGroup({
|
||||
{filters.includes("sub") && (
|
||||
<SubFilterButton
|
||||
allSubLabels={allSubLabels}
|
||||
selectedSubLabels={filter?.subLabels}
|
||||
selectedSubLabels={filter?.sub_labels}
|
||||
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 (
|
||||
<Popover
|
||||
<PlatformAwareDialog
|
||||
trigger={trigger}
|
||||
content={content}
|
||||
contentClassName={isDesktop ? "" : "max-h-[75dvh] overflow-hidden p-4"}
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
@ -321,10 +304,7 @@ function GeneralFilterButton({
|
||||
|
||||
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 = {
|
||||
allZones: 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 (
|
||||
<Popover
|
||||
<PlatformAwareDialog
|
||||
trigger={trigger}
|
||||
content={content}
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
@ -515,10 +657,7 @@ function ZoneFilterButton({
|
||||
|
||||
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 (
|
||||
<Popover
|
||||
<PlatformAwareDialog
|
||||
trigger={trigger}
|
||||
content={content}
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
@ -709,10 +830,7 @@ function SubFilterButton({
|
||||
|
||||
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 (
|
||||
<Popover
|
||||
<PlatformAwareDialog
|
||||
trigger={trigger}
|
||||
content={content}
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent>{content}</PopoverContent>
|
||||
</Popover>
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const [inputValue, setInputValue] = useState(search || "");
|
||||
const [currentFilterType, setCurrentFilterType] = useState<FilterType | null>(
|
||||
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)}
|
||||
<button
|
||||
onClick={() =>
|
||||
removeFilter(
|
||||
|
44
web/src/components/overlay/dialog/PlatformAwareDialog.tsx
Normal file
44
web/src/components/overlay/dialog/PlatformAwareDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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]);
|
||||
}
|
||||
|
@ -48,8 +48,6 @@ export default function useSuggestions(
|
||||
setSuggestions([
|
||||
...(searchHistory?.map((search) => search.name) ?? []),
|
||||
...availableFilters,
|
||||
"before",
|
||||
"after",
|
||||
]);
|
||||
}
|
||||
},
|
||||
|
@ -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] =
|
||||
@ -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
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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}`;
|
||||
}
|
||||
|
@ -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<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 +324,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" />
|
||||
|
Loading…
Reference in New Issue
Block a user