Embeddings UI updates (#14378)

* Handle Frigate+ submitted case

* Add search settings and rename general to ui settings

* Add platform aware sheet component

* use two columns on mobile view

* Add cameras page to more filters

* clean up search settings view

* Add time range to side filter

* better match with ui settings

* fix icon size

* use two columns on mobile view

* clean up search settings view

* Add zones and saving logic

* Add all filters to side panel

* better match with ui settings

* fix icon size

* Fix mobile fitler page

* Fix embeddings access

* Cleanup

* Fix scroll

* fix double scrollbars and add separators on mobile too

* two columns on mobile

* italics for emphasis

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
This commit is contained in:
Nicolas Mowen
2024-10-15 18:25:59 -06:00
committed by GitHub
parent b75efcbca2
commit 3f1ab66899
11 changed files with 919 additions and 798 deletions

View File

@@ -10,8 +10,6 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
@@ -33,6 +31,7 @@ import { baseUrl } from "@/api/baseUrl";
import axios from "axios";
import { toast } from "sonner";
import { MdImageSearch } from "react-icons/md";
import { isMobileOnly } from "react-device-detect";
type SearchThumbnailProps = {
searchResult: SearchResult;
@@ -109,7 +108,9 @@ export default function SearchThumbnailFooter({
showFrigatePlus ? (searchResult as unknown as Event) : undefined
}
onClose={() => setShowFrigatePlus(false)}
onEventUploaded={() => {}}
onEventUploaded={() => {
searchResult.plus_id = "submitted";
}}
/>
<div className="flex flex-col items-start text-xs">
@@ -122,10 +123,12 @@ export default function SearchThumbnailFooter({
)}
{formattedDate}
</div>
<div className="flex flex-row items-center justify-end gap-8 md:gap-4">
{config?.plus?.enabled &&
<div className="flex flex-row items-center justify-end gap-6 md:gap-4">
{!isMobileOnly &&
config?.plus?.enabled &&
searchResult.has_snapshot &&
searchResult.end_time && (
searchResult.end_time &&
!searchResult.plus_id && (
<Tooltip>
<TooltipTrigger>
<FrigatePlusIcon
@@ -154,10 +157,6 @@ export default function SearchThumbnailFooter({
<LuMoreVertical className="size-5 cursor-pointer text-primary" />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"}>
<DropdownMenuLabel className="mt-0.5">
Tracked Object Actions
</DropdownMenuLabel>
<DropdownMenuSeparator className="mt-1" />
{searchResult.has_clip && (
<DropdownMenuItem>
<a
@@ -189,6 +188,20 @@ export default function SearchThumbnailFooter({
<FaArrowsRotate className="mr-2 size-4" />
<span>View object lifecycle</span>
</DropdownMenuItem>
{isMobileOnly &&
config?.plus?.enabled &&
searchResult.has_snapshot &&
searchResult.end_time &&
!searchResult.plus_id && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => setShowFrigatePlus(true)}
>
<FrigatePlusIcon className="mr-2 size-4 cursor-pointer text-primary" />
<span>Submit to Frigate+</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="cursor-pointer"
onClick={() => setDeleteDialogOpen(true)}

View File

@@ -69,6 +69,70 @@ export function CamerasFilterButton({
</Button>
);
const content = (
<CamerasFilterContent
allCameras={allCameras}
groups={groups}
currentCameras={currentCameras}
setCurrentCameras={setCurrentCameras}
setOpen={setOpen}
updateCameraFilter={updateCameraFilter}
/>
);
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 CamerasFilterContentProps = {
allCameras: string[];
currentCameras: string[] | undefined;
groups: [string, CameraGroupConfig][];
setCurrentCameras: (cameras: string[] | undefined) => void;
setOpen: (open: boolean) => void;
updateCameraFilter: (cameras: string[] | undefined) => void;
};
export function CamerasFilterContent({
allCameras,
currentCameras,
groups,
setCurrentCameras,
setOpen,
updateCameraFilter,
}: CamerasFilterContentProps) {
return (
<>
{isMobile && (
<>
@@ -158,40 +222,4 @@ export function CamerasFilterButton({
</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>
);
}

View File

@@ -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";
@@ -10,25 +9,19 @@ 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 {
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";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
import { FaArrowRight, FaClock } from "react-icons/fa";
import { useFormattedHour } from "@/hooks/use-date-utils";
import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog";
import { CalendarRangeFilterButton } from "./CalendarFilterButton";
type SearchFilterGroupProps = {
className: string;
@@ -79,8 +72,6 @@ export default function SearchFilterGroup({
return [...labels].sort();
}, [config, filterList, filter]);
const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]);
const allZones = useMemo<string[]>(() => {
if (filterList?.zones) {
return filterList.zones;
@@ -159,6 +150,15 @@ export default function SearchFilterGroup({
}}
/>
)}
{filters.includes("general") && (
<GeneralFilterButton
allLabels={filterValues.labels}
selectedLabels={filter?.labels}
updateLabelFilter={(newLabels) => {
onUpdateFilter({ ...filter, labels: newLabels });
}}
/>
)}
{filters.includes("date") && (
<CalendarRangeFilterButton
range={
@@ -173,54 +173,12 @@ 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}
selectedZones={filter?.zones}
updateZoneFilter={(newZones) =>
onUpdateFilter({ ...filter, zones: newZones })
}
/>
)}
{filters.includes("general") && (
<GeneralFilterButton
allLabels={filterValues.labels}
selectedLabels={filter?.labels}
updateLabelFilter={(newLabels) => {
onUpdateFilter({ ...filter, labels: newLabels });
}}
/>
)}
{filters.includes("sub") && (
<SubFilterButton
allSubLabels={allSubLabels}
selectedSubLabels={filter?.sub_labels}
updateSubLabelFilter={(newSubLabels) =>
onUpdateFilter({ ...filter, sub_labels: newSubLabels })
}
/>
)}
{config?.semantic_search?.enabled &&
filters.includes("source") &&
!filter?.search_type?.includes("similarity") && (
<SearchTypeButton
selectedSearchSources={
filter?.search_type ?? ["thumbnail", "description"]
}
updateSearchSourceFilter={(newSearchSource) =>
onUpdateFilter({ ...filter, search_type: newSearchSource })
}
/>
)}
<SearchFilterDialog
config={config}
filter={filter}
filterValues={filterValues}
onUpdateFilter={onUpdateFilter}
/>
</div>
);
}
@@ -397,681 +355,3 @@ 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);
useEffect(() => {
setSelectedAfterHour(afterHour);
setSelectedBeforeHour(beforeHour);
// only refresh when state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeRange]);
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="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
<div className="my-5 flex flex-row items-center 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-row items-center justify-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>
</div>
<DropdownMenuSeparator />
<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);
updateTimeRange(undefined);
}}
>
Reset
</Button>
</div>
</div>
);
return (
<PlatformAwareDialog
trigger={trigger}
content={content}
open={open}
onOpenChange={(open) => {
setOpen(open);
}}
/>
);
}
type ZoneFilterButtonProps = {
allZones: string[];
selectedZones?: string[];
updateZoneFilter: (zones: string[] | undefined) => void;
};
function ZoneFilterButton({
allZones,
selectedZones,
updateZoneFilter,
}: ZoneFilterButtonProps) {
const [open, setOpen] = useState(false);
const [currentZones, setCurrentZones] = useState<string[] | undefined>(
selectedZones,
);
const buttonText = useMemo(() => {
if (isMobile) {
return "Zones";
}
if (!selectedZones || selectedZones.length == 0) {
return "All Zones";
}
if (selectedZones.length == 1) {
return selectedZones[0];
}
return `${selectedZones.length} Zones`;
}, [selectedZones]);
// ui
useEffect(() => {
setCurrentZones(selectedZones);
// only refresh when state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedZones]);
const trigger = (
<Button
size="sm"
variant={selectedZones?.length ? "select" : "default"}
className="flex items-center gap-2 capitalize"
>
<FaLocationDot
className={`${selectedZones?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
<div
className={`${selectedZones?.length ? "text-selected-foreground" : "text-primary"}`}
>
{buttonText}
</div>
</Button>
);
const content = (
<ZoneFilterContent
allZones={allZones}
selectedZones={selectedZones}
currentZones={currentZones}
setCurrentZones={setCurrentZones}
updateZoneFilter={updateZoneFilter}
onClose={() => setOpen(false)}
/>
);
return (
<PlatformAwareDialog
trigger={trigger}
content={content}
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentZones(selectedZones);
}
setOpen(open);
}}
/>
);
}
type ZoneFilterContentProps = {
allZones?: string[];
selectedZones?: string[];
currentZones?: string[];
updateZoneFilter?: (zones: string[] | undefined) => void;
setCurrentZones?: (zones: string[] | undefined) => void;
onClose: () => void;
};
export function ZoneFilterContent({
allZones,
selectedZones,
currentZones,
updateZoneFilter,
setCurrentZones,
onClose,
}: ZoneFilterContentProps) {
return (
<>
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
{allZones && setCurrentZones && (
<>
{isDesktop && <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
key={item}
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>
{isDesktop && <DropdownMenuSeparator />}
<div className="flex items-center justify-evenly p-2">
<Button
variant="select"
onClick={() => {
if (updateZoneFilter && selectedZones != currentZones) {
updateZoneFilter(currentZones);
}
onClose();
}}
>
Apply
</Button>
<Button
onClick={() => {
setCurrentZones?.(undefined);
updateZoneFilter?.(undefined);
}}
>
Reset
</Button>
</div>
</>
);
}
type SubFilterButtonProps = {
allSubLabels: string[];
selectedSubLabels: string[] | undefined;
updateSubLabelFilter: (labels: string[] | undefined) => void;
};
function SubFilterButton({
allSubLabels,
selectedSubLabels,
updateSubLabelFilter,
}: SubFilterButtonProps) {
const [open, setOpen] = useState(false);
const [currentSubLabels, setCurrentSubLabels] = useState<
string[] | undefined
>(selectedSubLabels);
const buttonText = useMemo(() => {
if (isMobile) {
return "Sub Labels";
}
if (!selectedSubLabels || selectedSubLabels.length == 0) {
return "All Sub Labels";
}
if (selectedSubLabels.length == 1) {
return selectedSubLabels[0];
}
return `${selectedSubLabels.length} Sub Labels`;
}, [selectedSubLabels]);
const trigger = (
<Button
size="sm"
variant={selectedSubLabels?.length ? "select" : "default"}
className="flex items-center gap-2 capitalize"
>
<SubFilterIcon
className={`${selectedSubLabels?.length || selectedSubLabels?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
<div
className={`${selectedSubLabels?.length ? "text-selected-foreground" : "text-primary"}`}
>
{buttonText}
</div>
</Button>
);
const content = (
<SubFilterContent
allSubLabels={allSubLabels}
selectedSubLabels={selectedSubLabels}
currentSubLabels={currentSubLabels}
setCurrentSubLabels={setCurrentSubLabels}
updateSubLabelFilter={updateSubLabelFilter}
onClose={() => setOpen(false)}
/>
);
return (
<PlatformAwareDialog
trigger={trigger}
content={content}
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentSubLabels(selectedSubLabels);
}
setOpen(open);
}}
/>
);
}
type SubFilterContentProps = {
allSubLabels: string[];
selectedSubLabels: string[] | undefined;
currentSubLabels: string[] | undefined;
updateSubLabelFilter: (labels: string[] | undefined) => void;
setCurrentSubLabels: (labels: string[] | undefined) => void;
onClose: () => void;
};
export function SubFilterContent({
allSubLabels,
selectedSubLabels,
currentSubLabels,
updateSubLabelFilter,
setCurrentSubLabels,
onClose,
}: SubFilterContentProps) {
return (
<>
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
<div className="mb-5 mt-2.5 flex items-center justify-between">
<Label
className="mx-2 cursor-pointer text-primary"
htmlFor="allLabels"
>
All Sub Labels
</Label>
<Switch
className="ml-1"
id="allLabels"
checked={currentSubLabels == undefined}
onCheckedChange={(isChecked) => {
if (isChecked) {
setCurrentSubLabels(undefined);
}
}}
/>
</div>
<div className="my-2.5 flex flex-col gap-2.5">
{allSubLabels.map((item) => (
<FilterSwitch
key={item}
label={item.replaceAll("_", " ")}
isChecked={currentSubLabels?.includes(item) ?? false}
onCheckedChange={(isChecked) => {
if (isChecked) {
const updatedLabels = currentSubLabels
? [...currentSubLabels]
: [];
updatedLabels.push(item);
setCurrentSubLabels(updatedLabels);
} else {
const updatedLabels = currentSubLabels
? [...currentSubLabels]
: [];
// can not deselect the last item
if (updatedLabels.length > 1) {
updatedLabels.splice(updatedLabels.indexOf(item), 1);
setCurrentSubLabels(updatedLabels);
}
}
}}
/>
))}
</div>
</div>
{isDesktop && <DropdownMenuSeparator />}
<div className="flex items-center justify-evenly p-2">
<Button
variant="select"
onClick={() => {
if (selectedSubLabels != currentSubLabels) {
updateSubLabelFilter(currentSubLabels);
}
onClose();
}}
>
Apply
</Button>
<Button
onClick={() => {
updateSubLabelFilter(undefined);
}}
>
Reset
</Button>
</div>
</>
);
}
type SearchTypeButtonProps = {
selectedSearchSources: SearchSource[] | undefined;
updateSearchSourceFilter: (sources: SearchSource[] | undefined) => void;
};
function SearchTypeButton({
selectedSearchSources,
updateSearchSourceFilter,
}: SearchTypeButtonProps) {
const [open, setOpen] = useState(false);
const buttonText = useMemo(() => {
if (isMobile) {
return "Sources";
}
if (
!selectedSearchSources ||
selectedSearchSources.length == 0 ||
selectedSearchSources.length == 2
) {
return "All Search Sources";
}
if (selectedSearchSources.length == 1) {
return selectedSearchSources[0];
}
return `${selectedSearchSources.length} Search Sources`;
}, [selectedSearchSources]);
const trigger = (
<Button
size="sm"
variant={selectedSearchSources?.length != 2 ? "select" : "default"}
className="flex items-center gap-2 capitalize"
>
<SearchSourceIcon
className={`${selectedSearchSources?.length != 2 ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
<div
className={`${selectedSearchSources?.length != 2 ? "text-selected-foreground" : "text-primary"}`}
>
{buttonText}
</div>
</Button>
);
const content = (
<SearchTypeContent
selectedSearchSources={selectedSearchSources}
updateSearchSourceFilter={updateSearchSourceFilter}
onClose={() => setOpen(false)}
/>
);
return (
<PlatformAwareDialog
trigger={trigger}
content={content}
open={open}
onOpenChange={setOpen}
/>
);
}
type SearchTypeContentProps = {
selectedSearchSources: SearchSource[] | undefined;
updateSearchSourceFilter: (sources: SearchSource[] | undefined) => void;
onClose: () => void;
};
export function SearchTypeContent({
selectedSearchSources,
updateSearchSourceFilter,
onClose,
}: SearchTypeContentProps) {
const [currentSearchSources, setCurrentSearchSources] = useState<
SearchSource[] | undefined
>(selectedSearchSources);
return (
<>
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
<div className="my-2.5 flex flex-col gap-2.5">
<FilterSwitch
label="Thumbnail Image"
isChecked={currentSearchSources?.includes("thumbnail") ?? false}
onCheckedChange={(isChecked) => {
const updatedSources = currentSearchSources
? [...currentSearchSources]
: [];
if (isChecked) {
updatedSources.push("thumbnail");
setCurrentSearchSources(updatedSources);
} else {
if (updatedSources.length > 1) {
const index = updatedSources.indexOf("thumbnail");
if (index !== -1) updatedSources.splice(index, 1);
setCurrentSearchSources(updatedSources);
}
}
}}
/>
<FilterSwitch
label="Description"
isChecked={currentSearchSources?.includes("description") ?? false}
onCheckedChange={(isChecked) => {
const updatedSources = currentSearchSources
? [...currentSearchSources]
: [];
if (isChecked) {
updatedSources.push("description");
setCurrentSearchSources(updatedSources);
} else {
if (updatedSources.length > 1) {
const index = updatedSources.indexOf("description");
if (index !== -1) updatedSources.splice(index, 1);
setCurrentSearchSources(updatedSources);
}
}
}}
/>
</div>
{isDesktop && <DropdownMenuSeparator />}
<div className="flex items-center justify-evenly p-2">
<Button
variant="select"
onClick={() => {
if (selectedSearchSources != currentSearchSources) {
updateSearchSourceFilter(currentSearchSources);
}
onClose();
}}
>
Apply
</Button>
<Button
onClick={() => {
updateSearchSourceFilter(undefined);
setCurrentSearchSources(["thumbnail", "description"]);
}}
>
Reset
</Button>
</div>
</div>
</>
);
}

View File

@@ -1,9 +1,16 @@
import {
MobilePage,
MobilePageContent,
MobilePageHeader,
MobilePageTitle,
} from "@/components/mobile/MobilePage";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { isMobile } from "react-device-detect";
type PlatformAwareDialogProps = {
@@ -42,3 +49,48 @@ export default function PlatformAwareDialog({
</Popover>
);
}
type PlatformAwareSheetProps = {
trigger: JSX.Element;
content: JSX.Element;
triggerClassName?: string;
contentClassName?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function PlatformAwareSheet({
trigger,
content,
triggerClassName = "",
contentClassName = "",
open,
onOpenChange,
}: PlatformAwareSheetProps) {
if (isMobile) {
return (
<div>
<div onClick={() => onOpenChange(true)}>{trigger}</div>
<MobilePage open={open} onOpenChange={onOpenChange}>
<MobilePageContent className="h-full overflow-hidden">
<MobilePageHeader
className="mx-2"
onClose={() => onOpenChange(false)}
>
<MobilePageTitle>More Filters</MobilePageTitle>
</MobilePageHeader>
<div className={contentClassName}>{content}</div>
</MobilePageContent>
</MobilePage>
</div>
);
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetTrigger asChild className={triggerClassName}>
{trigger}
</SheetTrigger>
<SheetContent className={contentClassName}>{content}</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,448 @@
import { FaArrowRight, FaCog } from "react-icons/fa";
import { useEffect, useMemo, useState } from "react";
import { PlatformAwareSheet } from "./PlatformAwareDialog";
import { Button } from "@/components/ui/button";
import useSWR from "swr";
import {
DEFAULT_TIME_RANGE_AFTER,
DEFAULT_TIME_RANGE_BEFORE,
SearchFilter,
SearchSource,
} from "@/types/search";
import { FrigateConfig } from "@/types/frigateConfig";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { isDesktop, isMobileOnly } from "react-device-detect";
import { useFormattedHour } from "@/hooks/use-date-utils";
import Heading from "@/components/ui/heading";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
type SearchFilterDialogProps = {
config?: FrigateConfig;
filter?: SearchFilter;
filterValues: {
cameras: string[];
labels: string[];
zones: string[];
search_type: SearchSource[];
};
onUpdateFilter: (filter: SearchFilter) => void;
};
export default function SearchFilterDialog({
config,
filter,
filterValues,
onUpdateFilter,
}: SearchFilterDialogProps) {
// data
const [currentFilter, setCurrentFilter] = useState(filter ?? {});
const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]);
// state
const [open, setOpen] = useState(false);
const trigger = (
<Button className="flex items-center gap-2" size="sm">
<FaCog className={"text-secondary-foreground"} />
More Filters
</Button>
);
const content = (
<div className="space-y-3">
<TimeRangeFilterContent
config={config}
timeRange={currentFilter.time_range}
updateTimeRange={(newRange) =>
setCurrentFilter({ ...currentFilter, time_range: newRange })
}
/>
<ZoneFilterContent
allZones={filterValues.zones}
zones={currentFilter.zones}
updateZones={(newZones) =>
setCurrentFilter({ ...currentFilter, zones: newZones })
}
/>
<SubFilterContent
allSubLabels={allSubLabels}
subLabels={currentFilter.sub_labels}
setSubLabels={(newSubLabels) =>
setCurrentFilter({ ...currentFilter, sub_labels: newSubLabels })
}
/>
<SearchTypeContent
searchSources={
currentFilter?.search_type ?? ["thumbnail", "description"]
}
setSearchSources={(newSearchSource) =>
onUpdateFilter({ ...currentFilter, search_type: newSearchSource })
}
/>
{isDesktop && <DropdownMenuSeparator />}
<div className="flex items-center justify-evenly p-2">
<Button
variant="select"
onClick={() => {
if (currentFilter != filter) {
onUpdateFilter(currentFilter);
}
setOpen(false);
}}
>
Apply
</Button>
<Button
onClick={() => {
setCurrentFilter(filter ?? {});
}}
>
Reset
</Button>
</div>
</div>
);
return (
<PlatformAwareSheet
trigger={trigger}
content={content}
contentClassName={cn(
"w-auto lg:w-[300px] scrollbar-container h-full overflow-auto px-4",
isMobileOnly && "pb-20",
)}
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentFilter(filter ?? {});
}
setOpen(open);
}}
/>
);
}
type TimeRangeFilterContentProps = {
config?: FrigateConfig;
timeRange?: string;
updateTimeRange: (range: string | undefined) => void;
};
function TimeRangeFilterContent({
config,
timeRange,
updateTimeRange,
}: TimeRangeFilterContentProps) {
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 formattedSelectedAfter = useFormattedHour(config, selectedAfterHour);
const formattedSelectedBefore = useFormattedHour(config, selectedBeforeHour);
useEffect(() => {
setSelectedAfterHour(afterHour);
setSelectedBeforeHour(beforeHour);
// only refresh when state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeRange]);
useEffect(() => {
if (
selectedAfterHour == DEFAULT_TIME_RANGE_AFTER &&
selectedBeforeHour == DEFAULT_TIME_RANGE_BEFORE
) {
updateTimeRange(undefined);
} else {
updateTimeRange(`${selectedAfterHour},${selectedBeforeHour}`);
}
// only refresh when state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedAfterHour, selectedBeforeHour]);
return (
<div className="overflow-x-hidden">
<Heading as="h4">Time Range</Heading>
<div className="my-3 flex flex-row items-center 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-row items-center justify-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>
</div>
</div>
);
}
type ZoneFilterContentProps = {
allZones?: string[];
zones?: string[];
updateZones: (zones: string[] | undefined) => void;
};
export function ZoneFilterContent({
allZones,
zones,
updateZones,
}: ZoneFilterContentProps) {
return (
<>
<div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" />
<Heading as="h4">Zones</Heading>
{allZones && (
<>
<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={zones == undefined}
onCheckedChange={(isChecked) => {
if (isChecked) {
updateZones(undefined);
}
}}
/>
</div>
<div className="my-2.5 flex flex-col gap-2.5">
{allZones.map((item) => (
<FilterSwitch
key={item}
label={item.replaceAll("_", " ")}
isChecked={zones?.includes(item) ?? false}
onCheckedChange={(isChecked) => {
if (isChecked) {
const updatedZones = zones ? [...zones] : [];
updatedZones.push(item);
updateZones(updatedZones);
} else {
const updatedZones = zones ? [...zones] : [];
// can not deselect the last item
if (updatedZones.length > 1) {
updatedZones.splice(updatedZones.indexOf(item), 1);
updateZones(updatedZones);
}
}
}}
/>
))}
</div>
</>
)}
</div>
</>
);
}
type SubFilterContentProps = {
allSubLabels: string[];
subLabels: string[] | undefined;
setSubLabels: (labels: string[] | undefined) => void;
};
export function SubFilterContent({
allSubLabels,
subLabels,
setSubLabels,
}: SubFilterContentProps) {
return (
<div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" />
<Heading as="h4">Sub Labels</Heading>
<div className="mb-5 mt-2.5 flex items-center justify-between">
<Label className="mx-2 cursor-pointer text-primary" htmlFor="allLabels">
All Sub Labels
</Label>
<Switch
className="ml-1"
id="allLabels"
checked={subLabels == undefined}
onCheckedChange={(isChecked) => {
if (isChecked) {
setSubLabels(undefined);
}
}}
/>
</div>
<div className="my-2.5 flex flex-col gap-2.5">
{allSubLabels.map((item) => (
<FilterSwitch
key={item}
label={item.replaceAll("_", " ")}
isChecked={subLabels?.includes(item) ?? false}
onCheckedChange={(isChecked) => {
if (isChecked) {
const updatedLabels = subLabels ? [...subLabels] : [];
updatedLabels.push(item);
setSubLabels(updatedLabels);
} else {
const updatedLabels = subLabels ? [...subLabels] : [];
// can not deselect the last item
if (updatedLabels.length > 1) {
updatedLabels.splice(updatedLabels.indexOf(item), 1);
setSubLabels(updatedLabels);
}
}
}}
/>
))}
</div>
</div>
);
}
type SearchTypeContentProps = {
searchSources: SearchSource[] | undefined;
setSearchSources: (sources: SearchSource[] | undefined) => void;
};
export function SearchTypeContent({
searchSources,
setSearchSources,
}: SearchTypeContentProps) {
return (
<>
<div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" />
<Heading as="h4">Search Sources</Heading>
<div className="my-2.5 flex flex-col gap-2.5">
<FilterSwitch
label="Thumbnail Image"
isChecked={searchSources?.includes("thumbnail") ?? false}
onCheckedChange={(isChecked) => {
const updatedSources = searchSources ? [...searchSources] : [];
if (isChecked) {
updatedSources.push("thumbnail");
setSearchSources(updatedSources);
} else {
if (updatedSources.length > 1) {
const index = updatedSources.indexOf("thumbnail");
if (index !== -1) updatedSources.splice(index, 1);
setSearchSources(updatedSources);
}
}
}}
/>
<FilterSwitch
label="Description"
isChecked={searchSources?.includes("description") ?? false}
onCheckedChange={(isChecked) => {
const updatedSources = searchSources ? [...searchSources] : [];
if (isChecked) {
updatedSources.push("description");
setSearchSources(updatedSources);
} else {
if (updatedSources.length > 1) {
const index = updatedSources.indexOf("description");
if (index !== -1) updatedSources.splice(index, 1);
setSearchSources(updatedSources);
}
}
}}
/>
</div>
</div>
</>
);
}