mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-12 22:25:24 +03:00
Abstract filters to separate components
This commit is contained in:
parent
f3b858364d
commit
bc6b2d5659
79
web/src/components/filter/CalendarFilterButton.tsx
Normal file
79
web/src/components/filter/CalendarFilterButton.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||
import { ReviewSummary } from "@/types/review";
|
||||
import { Button } from "../ui/button";
|
||||
import { FaCalendarAlt } from "react-icons/fa";
|
||||
import ReviewActivityCalendar from "../overlay/ReviewActivityCalendar";
|
||||
import { DropdownMenuSeparator } from "../ui/dropdown-menu";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
|
||||
type CalendarFilterButtonProps = {
|
||||
reviewSummary?: ReviewSummary;
|
||||
day?: Date;
|
||||
defaultText: string;
|
||||
updateSelectedDay: (day?: Date) => void;
|
||||
};
|
||||
export default function CalendarFilterButton({
|
||||
reviewSummary,
|
||||
day,
|
||||
defaultText,
|
||||
updateSelectedDay,
|
||||
}: CalendarFilterButtonProps) {
|
||||
const selectedDate = useFormattedTimestamp(
|
||||
day == undefined ? 0 : day?.getTime() / 1000 + 1,
|
||||
"%b %-d",
|
||||
);
|
||||
|
||||
const trigger = (
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
variant={day == undefined ? "default" : "select"}
|
||||
size="sm"
|
||||
>
|
||||
<FaCalendarAlt
|
||||
className={`${day == undefined ? "text-secondary-foreground" : "text-selected-foreground"}`}
|
||||
/>
|
||||
<div
|
||||
className={`hidden md:block ${day == undefined ? "text-primary" : "text-selected-foreground"}`}
|
||||
>
|
||||
{day == undefined ? defaultText : selectedDate}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
<ReviewActivityCalendar
|
||||
reviewSummary={reviewSummary}
|
||||
selectedDay={day}
|
||||
onSelect={updateSelectedDay}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex items-center justify-center p-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
updateSelectedDay(undefined);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent>{content}</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent>{content}</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
177
web/src/components/filter/CamerasFilterButton.tsx
Normal file
177
web/src/components/filter/CamerasFilterButton.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
import { Button } from "../ui/button";
|
||||
import { CameraGroupConfig } from "@/types/frigateConfig";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||
import FilterSwitch from "./FilterSwitch";
|
||||
import { FaVideo } from "react-icons/fa";
|
||||
|
||||
type CameraFilterButtonProps = {
|
||||
allCameras: string[];
|
||||
groups: [string, CameraGroupConfig][];
|
||||
selectedCameras: string[] | undefined;
|
||||
updateCameraFilter: (cameras: string[] | undefined) => void;
|
||||
};
|
||||
export function CamerasFilterButton({
|
||||
allCameras,
|
||||
groups,
|
||||
selectedCameras,
|
||||
updateCameraFilter,
|
||||
}: CameraFilterButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [currentCameras, setCurrentCameras] = useState<string[] | undefined>(
|
||||
selectedCameras,
|
||||
);
|
||||
|
||||
const trigger = (
|
||||
<Button
|
||||
className="flex items-center gap-2 capitalize"
|
||||
variant={selectedCameras?.length == undefined ? "default" : "select"}
|
||||
size="sm"
|
||||
>
|
||||
<FaVideo
|
||||
className={`${(selectedCameras?.length ?? 0) >= 1 ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||
/>
|
||||
<div
|
||||
className={`hidden md:block ${selectedCameras?.length ? "text-selected-foreground" : "text-primary"}`}
|
||||
>
|
||||
{selectedCameras == undefined
|
||||
? "All Cameras"
|
||||
: `${selectedCameras.includes("birdseye") ? selectedCameras.length - 1 : selectedCameras.length} Camera${selectedCameras.length !== 1 ? "s" : ""}`}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
{isMobile && (
|
||||
<>
|
||||
<DropdownMenuLabel className="flex justify-center">
|
||||
Cameras
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden p-4">
|
||||
<FilterSwitch
|
||||
isChecked={currentCameras == undefined}
|
||||
label="All Cameras"
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
setCurrentCameras(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{groups.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="mt-2" />
|
||||
{groups.map(([name, conf]) => {
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="w-full cursor-pointer rounded-lg px-2 py-1.5 text-sm capitalize text-primary hover:bg-muted"
|
||||
onClick={() => setCurrentCameras([...conf.cameras])}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator className="my-2" />
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{allCameras.map((item) => (
|
||||
<FilterSwitch
|
||||
key={item}
|
||||
isChecked={currentCameras?.includes(item) ?? false}
|
||||
label={item.replaceAll("_", " ")}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
const updatedCameras = currentCameras
|
||||
? [...currentCameras]
|
||||
: [];
|
||||
|
||||
updatedCameras.push(item);
|
||||
setCurrentCameras(updatedCameras);
|
||||
} else {
|
||||
const updatedCameras = currentCameras
|
||||
? [...currentCameras]
|
||||
: [];
|
||||
|
||||
// can not deselect the last item
|
||||
if (updatedCameras.length > 1) {
|
||||
updatedCameras.splice(updatedCameras.indexOf(item), 1);
|
||||
setCurrentCameras(updatedCameras);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator className="my-2" />
|
||||
<div className="flex items-center justify-evenly p-2">
|
||||
<Button
|
||||
variant="select"
|
||||
onClick={() => {
|
||||
updateCameraFilter(currentCameras);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setCurrentCameras(undefined);
|
||||
updateCameraFilter(undefined);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCurrentCameras(selectedCameras);
|
||||
}
|
||||
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent className="max-h-[75dvh] overflow-hidden">
|
||||
{content}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCurrentCameras(selectedCameras);
|
||||
}
|
||||
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>{content}</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@ -1,36 +1,24 @@
|
||||
import { Button } from "../ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import useSWR from "swr";
|
||||
import { CameraGroupConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { DropdownMenuSeparator } from "../ui/dropdown-menu";
|
||||
import { ReviewFilter, ReviewSeverity, ReviewSummary } from "@/types/review";
|
||||
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
|
||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||
import {
|
||||
FaCalendarAlt,
|
||||
FaCheckCircle,
|
||||
FaFilter,
|
||||
FaRunning,
|
||||
FaVideo,
|
||||
} from "react-icons/fa";
|
||||
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 ReviewActivityCalendar from "../overlay/ReviewActivityCalendar";
|
||||
import MobileReviewSettingsDrawer, {
|
||||
DrawerFeatures,
|
||||
} from "../overlay/MobileReviewSettingsDrawer";
|
||||
import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||
import FilterSwitch from "./FilterSwitch";
|
||||
import { FilterList } from "@/types/filter";
|
||||
import CalendarFilterButton from "./CalendarFilterButton";
|
||||
import { CamerasFilterButton } from "./CamerasFilterButton";
|
||||
|
||||
const REVIEW_FILTERS = [
|
||||
"cameras",
|
||||
@ -204,6 +192,7 @@ export default function ReviewFilterGroup({
|
||||
? undefined
|
||||
: new Date(filter.after * 1000)
|
||||
}
|
||||
defaultText="Last 24 Hours"
|
||||
updateSelectedDay={onUpdateSelectedDay}
|
||||
/>
|
||||
)}
|
||||
@ -254,169 +243,6 @@ export default function ReviewFilterGroup({
|
||||
);
|
||||
}
|
||||
|
||||
type CameraFilterButtonProps = {
|
||||
allCameras: string[];
|
||||
groups: [string, CameraGroupConfig][];
|
||||
selectedCameras: string[] | undefined;
|
||||
updateCameraFilter: (cameras: string[] | undefined) => void;
|
||||
};
|
||||
export function CamerasFilterButton({
|
||||
allCameras,
|
||||
groups,
|
||||
selectedCameras,
|
||||
updateCameraFilter,
|
||||
}: CameraFilterButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [currentCameras, setCurrentCameras] = useState<string[] | undefined>(
|
||||
selectedCameras,
|
||||
);
|
||||
|
||||
const trigger = (
|
||||
<Button
|
||||
className="flex items-center gap-2 capitalize"
|
||||
variant={selectedCameras?.length == undefined ? "default" : "select"}
|
||||
size="sm"
|
||||
>
|
||||
<FaVideo
|
||||
className={`${(selectedCameras?.length ?? 0) >= 1 ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||
/>
|
||||
<div
|
||||
className={`hidden md:block ${selectedCameras?.length ? "text-selected-foreground" : "text-primary"}`}
|
||||
>
|
||||
{selectedCameras == undefined
|
||||
? "All Cameras"
|
||||
: `${selectedCameras.includes("birdseye") ? selectedCameras.length - 1 : selectedCameras.length} Camera${selectedCameras.length !== 1 ? "s" : ""}`}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
{isMobile && (
|
||||
<>
|
||||
<DropdownMenuLabel className="flex justify-center">
|
||||
Cameras
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden p-4">
|
||||
<FilterSwitch
|
||||
isChecked={currentCameras == undefined}
|
||||
label="All Cameras"
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
setCurrentCameras(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{groups.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="mt-2" />
|
||||
{groups.map(([name, conf]) => {
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="w-full cursor-pointer rounded-lg px-2 py-1.5 text-sm capitalize text-primary hover:bg-muted"
|
||||
onClick={() => setCurrentCameras([...conf.cameras])}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator className="my-2" />
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{allCameras.map((item) => (
|
||||
<FilterSwitch
|
||||
key={item}
|
||||
isChecked={currentCameras?.includes(item) ?? false}
|
||||
label={item.replaceAll("_", " ")}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
const updatedCameras = currentCameras
|
||||
? [...currentCameras]
|
||||
: [];
|
||||
|
||||
updatedCameras.push(item);
|
||||
setCurrentCameras(updatedCameras);
|
||||
} else {
|
||||
const updatedCameras = currentCameras
|
||||
? [...currentCameras]
|
||||
: [];
|
||||
|
||||
// can not deselect the last item
|
||||
if (updatedCameras.length > 1) {
|
||||
updatedCameras.splice(updatedCameras.indexOf(item), 1);
|
||||
setCurrentCameras(updatedCameras);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator className="my-2" />
|
||||
<div className="flex items-center justify-evenly p-2">
|
||||
<Button
|
||||
variant="select"
|
||||
onClick={() => {
|
||||
updateCameraFilter(currentCameras);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setCurrentCameras(undefined);
|
||||
updateCameraFilter(undefined);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCurrentCameras(selectedCameras);
|
||||
}
|
||||
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent className="max-h-[75dvh] overflow-hidden">
|
||||
{content}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCurrentCameras(selectedCameras);
|
||||
}
|
||||
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>{content}</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
type ShowReviewedFilterProps = {
|
||||
showReviewed?: 0 | 1;
|
||||
setShowReviewed: (reviewed?: 0 | 1) => void;
|
||||
@ -458,74 +284,6 @@ function ShowReviewFilter({
|
||||
);
|
||||
}
|
||||
|
||||
type CalendarFilterButtonProps = {
|
||||
reviewSummary?: ReviewSummary;
|
||||
day?: Date;
|
||||
updateSelectedDay: (day?: Date) => void;
|
||||
};
|
||||
function CalendarFilterButton({
|
||||
reviewSummary,
|
||||
day,
|
||||
updateSelectedDay,
|
||||
}: CalendarFilterButtonProps) {
|
||||
const selectedDate = useFormattedTimestamp(
|
||||
day == undefined ? 0 : day?.getTime() / 1000 + 1,
|
||||
"%b %-d",
|
||||
);
|
||||
|
||||
const trigger = (
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
variant={day == undefined ? "default" : "select"}
|
||||
size="sm"
|
||||
>
|
||||
<FaCalendarAlt
|
||||
className={`${day == undefined ? "text-secondary-foreground" : "text-selected-foreground"}`}
|
||||
/>
|
||||
<div
|
||||
className={`hidden md:block ${day == undefined ? "text-primary" : "text-selected-foreground"}`}
|
||||
>
|
||||
{day == undefined ? "Last 24 Hours" : selectedDate}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
<ReviewActivityCalendar
|
||||
reviewSummary={reviewSummary}
|
||||
selectedDay={day}
|
||||
onSelect={updateSelectedDay}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex items-center justify-center p-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
updateSelectedDay(undefined);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent>{content}</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent>{content}</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
type GeneralFilterButtonProps = {
|
||||
allLabels: string[];
|
||||
selectedLabels: string[] | undefined;
|
||||
|
||||
491
web/src/components/filter/SearchFilterGroup.tsx
Normal file
491
web/src/components/filter/SearchFilterGroup.tsx
Normal file
@ -0,0 +1,491 @@
|
||||
import { Button } from "../ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { DropdownMenuSeparator } from "../ui/dropdown-menu";
|
||||
import { ReviewFilter, ReviewSeverity } from "@/types/review";
|
||||
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
|
||||
import { FaFilter } 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, {
|
||||
DrawerFeatures,
|
||||
} from "../overlay/MobileReviewSettingsDrawer";
|
||||
import FilterSwitch from "./FilterSwitch";
|
||||
import { FilterList } from "@/types/filter";
|
||||
import CalendarFilterButton from "./CalendarFilterButton";
|
||||
import { CamerasFilterButton } from "./CamerasFilterButton";
|
||||
|
||||
const SEARCH_FILTERS = ["cameras", "date", "general"] as const;
|
||||
type SearchFilters = (typeof SEARCH_FILTERS)[number];
|
||||
const DEFAULT_REVIEW_FILTERS: SearchFilters[] = ["cameras", "date", "general"];
|
||||
|
||||
type SearchFilterGroupProps = {
|
||||
filters?: SearchFilters[];
|
||||
filter?: ReviewFilter;
|
||||
filterList?: FilterList;
|
||||
onUpdateFilter: (filter: ReviewFilter) => void;
|
||||
setMotionOnly: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export default function SearchFilterGroup({
|
||||
filters = DEFAULT_REVIEW_FILTERS,
|
||||
filter,
|
||||
filterList,
|
||||
onUpdateFilter,
|
||||
}: SearchFilterGroupProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
const allLabels = useMemo<string[]>(() => {
|
||||
if (filterList?.labels) {
|
||||
return filterList.labels;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const labels = new Set<string>();
|
||||
const cameras = filter?.cameras || Object.keys(config.cameras);
|
||||
|
||||
cameras.forEach((camera) => {
|
||||
if (camera == "birdseye") {
|
||||
return;
|
||||
}
|
||||
const cameraConfig = config.cameras[camera];
|
||||
cameraConfig.objects.track.forEach((label) => {
|
||||
labels.add(label);
|
||||
});
|
||||
|
||||
if (cameraConfig.audio.enabled_in_config) {
|
||||
cameraConfig.audio.listen.forEach((label) => {
|
||||
labels.add(label);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return [...labels].sort();
|
||||
}, [config, filterList, filter]);
|
||||
|
||||
const allZones = useMemo<string[]>(() => {
|
||||
if (filterList?.zones) {
|
||||
return filterList.zones;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const zones = new Set<string>();
|
||||
const cameras = filter?.cameras || Object.keys(config.cameras);
|
||||
|
||||
cameras.forEach((camera) => {
|
||||
if (camera == "birdseye") {
|
||||
return;
|
||||
}
|
||||
const cameraConfig = config.cameras[camera];
|
||||
cameraConfig.review.alerts.required_zones.forEach((zone) => {
|
||||
zones.add(zone);
|
||||
});
|
||||
cameraConfig.review.detections.required_zones.forEach((zone) => {
|
||||
zones.add(zone);
|
||||
});
|
||||
});
|
||||
|
||||
return [...zones].sort();
|
||||
}, [config, filterList, filter]);
|
||||
|
||||
const filterValues = useMemo(
|
||||
() => ({
|
||||
cameras: Object.keys(config?.cameras || {}),
|
||||
labels: Object.values(allLabels || {}),
|
||||
zones: Object.values(allZones || {}),
|
||||
}),
|
||||
[config, allLabels, allZones],
|
||||
);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(config.camera_groups).sort(
|
||||
(a, b) => a[1].order - b[1].order,
|
||||
);
|
||||
}, [config]);
|
||||
|
||||
const mobileSettingsFeatures = useMemo<DrawerFeatures[]>(() => {
|
||||
const features: DrawerFeatures[] = [];
|
||||
|
||||
if (filters.includes("date")) {
|
||||
features.push("calendar");
|
||||
}
|
||||
|
||||
if (filters.includes("general")) {
|
||||
features.push("filter");
|
||||
}
|
||||
|
||||
return features;
|
||||
}, [filters]);
|
||||
|
||||
// handle updating filters
|
||||
|
||||
const onUpdateSelectedDay = useCallback(
|
||||
(day?: Date) => {
|
||||
onUpdateFilter({
|
||||
...filter,
|
||||
after: day == undefined ? undefined : day.getTime() / 1000,
|
||||
before: day == undefined ? undefined : getEndOfDayTimestamp(day),
|
||||
});
|
||||
},
|
||||
[filter, onUpdateFilter],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center gap-2">
|
||||
{filters.includes("cameras") && (
|
||||
<CamerasFilterButton
|
||||
allCameras={filterValues.cameras}
|
||||
groups={groups}
|
||||
selectedCameras={filter?.cameras}
|
||||
updateCameraFilter={(newCameras) => {
|
||||
onUpdateFilter({ ...filter, cameras: newCameras });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isDesktop && filters.includes("date") && (
|
||||
<CalendarFilterButton
|
||||
day={
|
||||
filter?.after == undefined
|
||||
? undefined
|
||||
: new Date(filter.after * 1000)
|
||||
}
|
||||
defaultText="All Dates"
|
||||
updateSelectedDay={onUpdateSelectedDay}
|
||||
/>
|
||||
)}
|
||||
{isDesktop && filters.includes("general") && (
|
||||
<GeneralFilterButton
|
||||
allLabels={filterValues.labels}
|
||||
selectedLabels={filter?.labels}
|
||||
showAll={filter?.showAll == true}
|
||||
allZones={filterValues.zones}
|
||||
selectedZones={filter?.zones}
|
||||
setShowAll={(showAll) => {
|
||||
onUpdateFilter({ ...filter, showAll });
|
||||
}}
|
||||
updateLabelFilter={(newLabels) => {
|
||||
onUpdateFilter({ ...filter, labels: newLabels });
|
||||
}}
|
||||
updateZoneFilter={(newZones) =>
|
||||
onUpdateFilter({ ...filter, zones: newZones })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isMobile && mobileSettingsFeatures.length > 0 && (
|
||||
<MobileReviewSettingsDrawer
|
||||
features={mobileSettingsFeatures}
|
||||
filter={filter}
|
||||
allLabels={allLabels}
|
||||
allZones={allZones}
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
// not applicable as exports are not used
|
||||
camera=""
|
||||
latestTime={0}
|
||||
currentTime={0}
|
||||
mode="none"
|
||||
setMode={() => {}}
|
||||
setRange={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type GeneralFilterButtonProps = {
|
||||
allLabels: string[];
|
||||
selectedLabels: string[] | undefined;
|
||||
currentSeverity?: ReviewSeverity;
|
||||
showAll: boolean;
|
||||
allZones: string[];
|
||||
selectedZones?: string[];
|
||||
setShowAll: (showAll: boolean) => void;
|
||||
updateLabelFilter: (labels: string[] | undefined) => void;
|
||||
updateZoneFilter: (zones: string[] | undefined) => void;
|
||||
};
|
||||
function GeneralFilterButton({
|
||||
allLabels,
|
||||
selectedLabels,
|
||||
currentSeverity,
|
||||
showAll,
|
||||
allZones,
|
||||
selectedZones,
|
||||
setShowAll,
|
||||
updateLabelFilter,
|
||||
updateZoneFilter,
|
||||
}: GeneralFilterButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [currentLabels, setCurrentLabels] = useState<string[] | undefined>(
|
||||
selectedLabels,
|
||||
);
|
||||
const [currentZones, setCurrentZones] = useState<string[] | undefined>(
|
||||
selectedZones,
|
||||
);
|
||||
|
||||
const trigger = (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={
|
||||
selectedLabels?.length || selectedZones?.length ? "select" : "default"
|
||||
}
|
||||
className="flex items-center gap-2 capitalize"
|
||||
>
|
||||
<FaFilter
|
||||
className={`${selectedLabels?.length || selectedZones?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||
/>
|
||||
<div
|
||||
className={`hidden md:block ${selectedLabels?.length || selectedZones?.length ? "text-selected-foreground" : "text-primary"}`}
|
||||
>
|
||||
Filter
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
const content = (
|
||||
<GeneralFilterContent
|
||||
allLabels={allLabels}
|
||||
selectedLabels={selectedLabels}
|
||||
currentLabels={currentLabels}
|
||||
currentSeverity={currentSeverity}
|
||||
showAll={showAll}
|
||||
allZones={allZones}
|
||||
selectedZones={selectedZones}
|
||||
currentZones={currentZones}
|
||||
setCurrentZones={setCurrentZones}
|
||||
updateZoneFilter={updateZoneFilter}
|
||||
setShowAll={setShowAll}
|
||||
updateLabelFilter={updateLabelFilter}
|
||||
setCurrentLabels={setCurrentLabels}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
);
|
||||
|
||||
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
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCurrentLabels(selectedLabels);
|
||||
}
|
||||
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent>{content}</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
type GeneralFilterContentProps = {
|
||||
allLabels: string[];
|
||||
selectedLabels: string[] | undefined;
|
||||
currentLabels: string[] | undefined;
|
||||
currentSeverity?: ReviewSeverity;
|
||||
showAll?: boolean;
|
||||
allZones?: string[];
|
||||
selectedZones?: string[];
|
||||
currentZones?: string[];
|
||||
setShowAll?: (showAll: boolean) => void;
|
||||
updateLabelFilter: (labels: string[] | undefined) => void;
|
||||
setCurrentLabels: (labels: string[] | undefined) => void;
|
||||
updateZoneFilter?: (zones: string[] | undefined) => void;
|
||||
setCurrentZones?: (zones: string[] | undefined) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
export function GeneralFilterContent({
|
||||
allLabels,
|
||||
selectedLabels,
|
||||
currentLabels,
|
||||
currentSeverity,
|
||||
showAll,
|
||||
allZones,
|
||||
selectedZones,
|
||||
currentZones,
|
||||
setShowAll,
|
||||
updateLabelFilter,
|
||||
setCurrentLabels,
|
||||
updateZoneFilter,
|
||||
setCurrentZones,
|
||||
onClose,
|
||||
}: GeneralFilterContentProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
|
||||
{currentSeverity && setShowAll && (
|
||||
<div className="my-2.5 flex flex-col gap-2.5">
|
||||
<FilterSwitch
|
||||
label="Alerts"
|
||||
disabled={currentSeverity == "alert"}
|
||||
isChecked={currentSeverity == "alert" ? true : showAll == true}
|
||||
onCheckedChange={setShowAll}
|
||||
/>
|
||||
<FilterSwitch
|
||||
label="Detections"
|
||||
disabled={currentSeverity == "detection"}
|
||||
isChecked={
|
||||
currentSeverity == "detection" ? true : showAll == true
|
||||
}
|
||||
onCheckedChange={setShowAll}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-5 mt-2.5 flex items-center justify-between">
|
||||
<Label
|
||||
className="mx-2 cursor-pointer text-primary"
|
||||
htmlFor="allLabels"
|
||||
>
|
||||
All Labels
|
||||
</Label>
|
||||
<Switch
|
||||
className="ml-1"
|
||||
id="allLabels"
|
||||
checked={currentLabels == undefined}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
setCurrentLabels(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="my-2.5 flex flex-col gap-2.5">
|
||||
{allLabels.map((item) => (
|
||||
<FilterSwitch
|
||||
label={item.replaceAll("_", " ")}
|
||||
isChecked={currentLabels?.includes(item) ?? false}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
const updatedLabels = currentLabels ? [...currentLabels] : [];
|
||||
|
||||
updatedLabels.push(item);
|
||||
setCurrentLabels(updatedLabels);
|
||||
} else {
|
||||
const updatedLabels = currentLabels ? [...currentLabels] : [];
|
||||
|
||||
// can not deselect the last item
|
||||
if (updatedLabels.length > 1) {
|
||||
updatedLabels.splice(updatedLabels.indexOf(item), 1);
|
||||
setCurrentLabels(updatedLabels);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{allZones && setCurrentZones && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="mb-5 mt-2.5 flex items-center justify-between">
|
||||
<Label
|
||||
className="mx-2 cursor-pointer text-primary"
|
||||
htmlFor="allZones"
|
||||
>
|
||||
All Zones
|
||||
</Label>
|
||||
<Switch
|
||||
className="ml-1"
|
||||
id="allZones"
|
||||
checked={currentZones == undefined}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
setCurrentZones(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="my-2.5 flex flex-col gap-2.5">
|
||||
{allZones.map((item) => (
|
||||
<FilterSwitch
|
||||
label={item.replaceAll("_", " ")}
|
||||
isChecked={currentZones?.includes(item) ?? false}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
const updatedZones = currentZones
|
||||
? [...currentZones]
|
||||
: [];
|
||||
|
||||
updatedZones.push(item);
|
||||
setCurrentZones(updatedZones);
|
||||
} else {
|
||||
const updatedZones = currentZones
|
||||
? [...currentZones]
|
||||
: [];
|
||||
|
||||
// can not deselect the last item
|
||||
if (updatedZones.length > 1) {
|
||||
updatedZones.splice(updatedZones.indexOf(item), 1);
|
||||
setCurrentZones(updatedZones);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex items-center justify-evenly p-2">
|
||||
<Button
|
||||
variant="select"
|
||||
onClick={() => {
|
||||
if (selectedLabels != currentLabels) {
|
||||
updateLabelFilter(currentLabels);
|
||||
}
|
||||
|
||||
if (updateZoneFilter && selectedZones != currentZones) {
|
||||
updateZoneFilter(currentZones);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setCurrentLabels(undefined);
|
||||
setCurrentZones?.(undefined);
|
||||
updateLabelFilter(undefined);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import SearchFilterGroup from "@/components/filter/SearchFilterGroup";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { useState } from "react";
|
||||
@ -16,7 +17,9 @@ export default function Search() {
|
||||
placeholder="Search for a specific detection..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
/>
|
||||
|
||||
<SearchFilterGroup />
|
||||
</div>
|
||||
|
||||
<div className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4">
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import {
|
||||
CamerasFilterButton,
|
||||
GeneralFilterContent,
|
||||
} from "@/components/filter/ReviewFilterGroup";
|
||||
import { CamerasFilterButton } from "@/components/filter/CamerasFilterButton";
|
||||
import { GeneralFilterContent } from "@/components/filter/ReviewFilterGroup";
|
||||
import Chip from "@/components/indicators/Chip";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user