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 { 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}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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(
|
||||||
|
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]);
|
}, [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([
|
setSuggestions([
|
||||||
...(searchHistory?.map((search) => search.name) ?? []),
|
...(searchHistory?.map((search) => search.name) ?? []),
|
||||||
...availableFilters,
|
...availableFilters,
|
||||||
"before",
|
|
||||||
"after",
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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}`;
|
||||||
|
}
|
||||||
|
@ -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" />
|
||||||
|
Loading…
Reference in New Issue
Block a user