mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-30 13:48:07 +02:00
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:
parent
b75efcbca2
commit
3f1ab66899
@ -178,7 +178,7 @@ class Embeddings:
|
|||||||
embeddings = []
|
embeddings = []
|
||||||
|
|
||||||
for desc in event_descriptions.values():
|
for desc in event_descriptions.values():
|
||||||
embeddings.append(self.text_embedding([desc]))
|
embeddings.append(self.text_embedding([desc])[0])
|
||||||
|
|
||||||
ids = list(event_descriptions.keys())
|
ids = list(event_descriptions.keys())
|
||||||
|
|
||||||
|
@ -10,8 +10,6 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
@ -33,6 +31,7 @@ import { baseUrl } from "@/api/baseUrl";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { MdImageSearch } from "react-icons/md";
|
import { MdImageSearch } from "react-icons/md";
|
||||||
|
import { isMobileOnly } from "react-device-detect";
|
||||||
|
|
||||||
type SearchThumbnailProps = {
|
type SearchThumbnailProps = {
|
||||||
searchResult: SearchResult;
|
searchResult: SearchResult;
|
||||||
@ -109,7 +108,9 @@ export default function SearchThumbnailFooter({
|
|||||||
showFrigatePlus ? (searchResult as unknown as Event) : undefined
|
showFrigatePlus ? (searchResult as unknown as Event) : undefined
|
||||||
}
|
}
|
||||||
onClose={() => setShowFrigatePlus(false)}
|
onClose={() => setShowFrigatePlus(false)}
|
||||||
onEventUploaded={() => {}}
|
onEventUploaded={() => {
|
||||||
|
searchResult.plus_id = "submitted";
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col items-start text-xs">
|
<div className="flex flex-col items-start text-xs">
|
||||||
@ -122,10 +123,12 @@ export default function SearchThumbnailFooter({
|
|||||||
)}
|
)}
|
||||||
{formattedDate}
|
{formattedDate}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center justify-end gap-8 md:gap-4">
|
<div className="flex flex-row items-center justify-end gap-6 md:gap-4">
|
||||||
{config?.plus?.enabled &&
|
{!isMobileOnly &&
|
||||||
|
config?.plus?.enabled &&
|
||||||
searchResult.has_snapshot &&
|
searchResult.has_snapshot &&
|
||||||
searchResult.end_time && (
|
searchResult.end_time &&
|
||||||
|
!searchResult.plus_id && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<FrigatePlusIcon
|
<FrigatePlusIcon
|
||||||
@ -154,10 +157,6 @@ export default function SearchThumbnailFooter({
|
|||||||
<LuMoreVertical className="size-5 cursor-pointer text-primary" />
|
<LuMoreVertical className="size-5 cursor-pointer text-primary" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align={"end"}>
|
<DropdownMenuContent align={"end"}>
|
||||||
<DropdownMenuLabel className="mt-0.5">
|
|
||||||
Tracked Object Actions
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator className="mt-1" />
|
|
||||||
{searchResult.has_clip && (
|
{searchResult.has_clip && (
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<a
|
<a
|
||||||
@ -189,6 +188,20 @@ export default function SearchThumbnailFooter({
|
|||||||
<FaArrowsRotate className="mr-2 size-4" />
|
<FaArrowsRotate className="mr-2 size-4" />
|
||||||
<span>View object lifecycle</span>
|
<span>View object lifecycle</span>
|
||||||
</DropdownMenuItem>
|
</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
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => setDeleteDialogOpen(true)}
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
|
@ -69,6 +69,70 @@ export function CamerasFilterButton({
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
const content = (
|
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 && (
|
{isMobile && (
|
||||||
<>
|
<>
|
||||||
@ -158,40 +222,4 @@ export function CamerasFilterButton({
|
|||||||
</div>
|
</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,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";
|
||||||
@ -10,25 +9,19 @@ 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 { CamerasFilterButton } from "./CamerasFilterButton";
|
import { CamerasFilterButton } from "./CamerasFilterButton";
|
||||||
import {
|
import {
|
||||||
DEFAULT_SEARCH_FILTERS,
|
DEFAULT_SEARCH_FILTERS,
|
||||||
SearchFilter,
|
SearchFilter,
|
||||||
SearchFilters,
|
SearchFilters,
|
||||||
SearchSource,
|
SearchSource,
|
||||||
DEFAULT_TIME_RANGE_AFTER,
|
|
||||||
DEFAULT_TIME_RANGE_BEFORE,
|
|
||||||
} from "@/types/search";
|
} 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 { FaLocationDot } from "react-icons/fa6";
|
|
||||||
import { MdLabel } from "react-icons/md";
|
import { MdLabel } from "react-icons/md";
|
||||||
import SearchSourceIcon from "../icons/SearchSourceIcon";
|
|
||||||
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
|
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
|
||||||
import { FaArrowRight, FaClock } from "react-icons/fa";
|
import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog";
|
||||||
import { useFormattedHour } from "@/hooks/use-date-utils";
|
import { CalendarRangeFilterButton } from "./CalendarFilterButton";
|
||||||
|
|
||||||
type SearchFilterGroupProps = {
|
type SearchFilterGroupProps = {
|
||||||
className: string;
|
className: string;
|
||||||
@ -79,8 +72,6 @@ export default function SearchFilterGroup({
|
|||||||
return [...labels].sort();
|
return [...labels].sort();
|
||||||
}, [config, filterList, filter]);
|
}, [config, filterList, filter]);
|
||||||
|
|
||||||
const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]);
|
|
||||||
|
|
||||||
const allZones = useMemo<string[]>(() => {
|
const allZones = useMemo<string[]>(() => {
|
||||||
if (filterList?.zones) {
|
if (filterList?.zones) {
|
||||||
return 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") && (
|
{filters.includes("date") && (
|
||||||
<CalendarRangeFilterButton
|
<CalendarRangeFilterButton
|
||||||
range={
|
range={
|
||||||
@ -173,54 +173,12 @@ export default function SearchFilterGroup({
|
|||||||
updateSelectedRange={onUpdateSelectedRange}
|
updateSelectedRange={onUpdateSelectedRange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{filters.includes("time") && (
|
<SearchFilterDialog
|
||||||
<TimeRangeFilterButton
|
config={config}
|
||||||
config={config}
|
filter={filter}
|
||||||
timeRange={filter?.time_range}
|
filterValues={filterValues}
|
||||||
updateTimeRange={(time_range) =>
|
onUpdateFilter={onUpdateFilter}
|
||||||
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 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
@ -1,9 +1,16 @@
|
|||||||
|
import {
|
||||||
|
MobilePage,
|
||||||
|
MobilePageContent,
|
||||||
|
MobilePageHeader,
|
||||||
|
MobilePageTitle,
|
||||||
|
} from "@/components/mobile/MobilePage";
|
||||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
|
|
||||||
type PlatformAwareDialogProps = {
|
type PlatformAwareDialogProps = {
|
||||||
@ -42,3 +49,48 @@ export default function PlatformAwareDialog({
|
|||||||
</Popover>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
448
web/src/components/overlay/dialog/SearchFilterDialog.tsx
Normal file
448
web/src/components/overlay/dialog/SearchFilterDialog.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -29,16 +29,18 @@ import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
|||||||
import { PolygonType } from "@/types/canvas";
|
import { PolygonType } from "@/types/canvas";
|
||||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||||
import scrollIntoView from "scroll-into-view-if-needed";
|
import scrollIntoView from "scroll-into-view-if-needed";
|
||||||
import GeneralSettingsView from "@/views/settings/GeneralSettingsView";
|
|
||||||
import CameraSettingsView from "@/views/settings/CameraSettingsView";
|
import CameraSettingsView from "@/views/settings/CameraSettingsView";
|
||||||
import ObjectSettingsView from "@/views/settings/ObjectSettingsView";
|
import ObjectSettingsView from "@/views/settings/ObjectSettingsView";
|
||||||
import MotionTunerView from "@/views/settings/MotionTunerView";
|
import MotionTunerView from "@/views/settings/MotionTunerView";
|
||||||
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
|
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
|
||||||
import AuthenticationView from "@/views/settings/AuthenticationView";
|
import AuthenticationView from "@/views/settings/AuthenticationView";
|
||||||
import NotificationView from "@/views/settings/NotificationsSettingsView";
|
import NotificationView from "@/views/settings/NotificationsSettingsView";
|
||||||
|
import SearchSettingsView from "@/views/settings/SearchSettingsView";
|
||||||
|
import UiSettingsView from "@/views/settings/UiSettingsView";
|
||||||
|
|
||||||
const allSettingsViews = [
|
const allSettingsViews = [
|
||||||
"general",
|
"UI settings",
|
||||||
|
"search settings",
|
||||||
"camera settings",
|
"camera settings",
|
||||||
"masks / zones",
|
"masks / zones",
|
||||||
"motion tuner",
|
"motion tuner",
|
||||||
@ -49,7 +51,7 @@ const allSettingsViews = [
|
|||||||
type SettingsType = (typeof allSettingsViews)[number];
|
type SettingsType = (typeof allSettingsViews)[number];
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const [page, setPage] = useState<SettingsType>("general");
|
const [page, setPage] = useState<SettingsType>("UI settings");
|
||||||
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
||||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@ -140,7 +142,7 @@ export default function Settings() {
|
|||||||
{Object.values(settingsViews).map((item) => (
|
{Object.values(settingsViews).map((item) => (
|
||||||
<ToggleGroupItem
|
<ToggleGroupItem
|
||||||
key={item}
|
key={item}
|
||||||
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "general" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "UI settings" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||||
value={item}
|
value={item}
|
||||||
data-nav-item={item}
|
data-nav-item={item}
|
||||||
aria-label={`Select ${item}`}
|
aria-label={`Select ${item}`}
|
||||||
@ -172,7 +174,10 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24">
|
<div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24">
|
||||||
{page == "general" && <GeneralSettingsView />}
|
{page == "UI settings" && <UiSettingsView />}
|
||||||
|
{page == "search settings" && (
|
||||||
|
<SearchSettingsView setUnsavedChanges={setUnsavedChanges} />
|
||||||
|
)}
|
||||||
{page == "debug" && (
|
{page == "debug" && (
|
||||||
<ObjectSettingsView selectedCamera={selectedCamera} />
|
<ObjectSettingsView selectedCamera={selectedCamera} />
|
||||||
)}
|
)}
|
||||||
|
@ -27,6 +27,8 @@ export const ATTRIBUTE_LABELS = [
|
|||||||
"ups",
|
"ups",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export type SearchModelSize = "small" | "large";
|
||||||
|
|
||||||
export interface CameraConfig {
|
export interface CameraConfig {
|
||||||
audio: {
|
audio: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -418,7 +420,8 @@ export interface FrigateConfig {
|
|||||||
|
|
||||||
semantic_search: {
|
semantic_search: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
model_size: string;
|
reindex: boolean;
|
||||||
|
model_size: SearchModelSize;
|
||||||
};
|
};
|
||||||
|
|
||||||
snapshots: {
|
snapshots: {
|
||||||
|
@ -73,13 +73,17 @@ export default function SearchView({
|
|||||||
const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4);
|
const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4);
|
||||||
const effectiveColumnCount = useMemo(() => columnCount ?? 4, [columnCount]);
|
const effectiveColumnCount = useMemo(() => columnCount ?? 4, [columnCount]);
|
||||||
|
|
||||||
const gridClassName = cn("grid w-full gap-2 px-1 gap-2 lg:gap-4 md:mx-2", {
|
const gridClassName = cn(
|
||||||
"sm:grid-cols-2": effectiveColumnCount <= 2,
|
"grid w-full gap-2 px-1 gap-2 lg:gap-4 md:mx-2",
|
||||||
"sm:grid-cols-3": effectiveColumnCount === 3,
|
isMobileOnly && "grid-cols-2",
|
||||||
"sm:grid-cols-4": effectiveColumnCount === 4,
|
{
|
||||||
"sm:grid-cols-5": effectiveColumnCount === 5,
|
"sm:grid-cols-2": effectiveColumnCount <= 2,
|
||||||
"sm:grid-cols-6": effectiveColumnCount === 6,
|
"sm:grid-cols-3": effectiveColumnCount === 3,
|
||||||
});
|
"sm:grid-cols-4": effectiveColumnCount === 4,
|
||||||
|
"sm:grid-cols-5": effectiveColumnCount === 5,
|
||||||
|
"sm:grid-cols-6": effectiveColumnCount === 6,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// suggestions values
|
// suggestions values
|
||||||
|
|
||||||
|
288
web/src/views/settings/SearchSettingsView.tsx
Normal file
288
web/src/views/settings/SearchSettingsView.tsx
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
import Heading from "@/components/ui/heading";
|
||||||
|
import { FrigateConfig, SearchModelSize } from "@/types/frigateConfig";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import axios from "axios";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import { useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { LuExternalLink } from "react-icons/lu";
|
||||||
|
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
type SearchSettingsViewProps = {
|
||||||
|
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchSettings = {
|
||||||
|
enabled?: boolean;
|
||||||
|
reindex?: boolean;
|
||||||
|
model_size?: SearchModelSize;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SearchSettingsView({
|
||||||
|
setUnsavedChanges,
|
||||||
|
}: SearchSettingsViewProps) {
|
||||||
|
const { data: config, mutate: updateConfig } =
|
||||||
|
useSWR<FrigateConfig>("config");
|
||||||
|
const [changedValue, setChangedValue] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
|
||||||
|
|
||||||
|
const [searchSettings, setSearchSettings] = useState<SearchSettings>({
|
||||||
|
enabled: undefined,
|
||||||
|
reindex: undefined,
|
||||||
|
model_size: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [origSearchSettings, setOrigSearchSettings] = useState<SearchSettings>({
|
||||||
|
enabled: undefined,
|
||||||
|
reindex: undefined,
|
||||||
|
model_size: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config) {
|
||||||
|
if (searchSettings?.enabled == undefined) {
|
||||||
|
setSearchSettings({
|
||||||
|
enabled: config.semantic_search.enabled,
|
||||||
|
reindex: config.semantic_search.reindex,
|
||||||
|
model_size: config.semantic_search.model_size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrigSearchSettings({
|
||||||
|
enabled: config.semantic_search.enabled,
|
||||||
|
reindex: config.semantic_search.reindex,
|
||||||
|
model_size: config.semantic_search.model_size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// we know that these deps are correct
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const handleSearchConfigChange = (newConfig: Partial<SearchSettings>) => {
|
||||||
|
setSearchSettings((prevConfig) => ({ ...prevConfig, ...newConfig }));
|
||||||
|
setUnsavedChanges(true);
|
||||||
|
setChangedValue(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveToConfig = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
axios
|
||||||
|
.put(
|
||||||
|
`config/set?semantic_search.enabled=${searchSettings.enabled}&semantic_search.reindex=${searchSettings.reindex}&semantic_search.model_size=${searchSettings.model_size}`,
|
||||||
|
)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.status === 200) {
|
||||||
|
toast.success("Search settings have been saved.", {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
setChangedValue(false);
|
||||||
|
updateConfig();
|
||||||
|
} else {
|
||||||
|
toast.error(`Failed to save config changes: ${res.statusText}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(
|
||||||
|
`Failed to save config changes: ${error.response.data.message}`,
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
updateConfig,
|
||||||
|
searchSettings.enabled,
|
||||||
|
searchSettings.reindex,
|
||||||
|
searchSettings.model_size,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const onCancel = useCallback(() => {
|
||||||
|
setSearchSettings(origSearchSettings);
|
||||||
|
setChangedValue(false);
|
||||||
|
removeMessage("search_settings", "search_settings");
|
||||||
|
}, [origSearchSettings, removeMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (changedValue) {
|
||||||
|
addMessage(
|
||||||
|
"search_settings",
|
||||||
|
`Unsaved search settings changes`,
|
||||||
|
undefined,
|
||||||
|
"search_settings",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
removeMessage("search_settings", "search_settings");
|
||||||
|
}
|
||||||
|
// we know that these deps are correct
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [changedValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "Search Settings - Frigate";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return <ActivityIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex size-full flex-col md:flex-row">
|
||||||
|
<Toaster position="top-center" closeButton={true} />
|
||||||
|
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||||
|
<Heading as="h3" className="my-2">
|
||||||
|
Search Settings
|
||||||
|
</Heading>
|
||||||
|
<Separator className="my-2 flex bg-secondary" />
|
||||||
|
<Heading as="h4" className="my-2">
|
||||||
|
Semantic Search
|
||||||
|
</Heading>
|
||||||
|
<div className="max-w-6xl">
|
||||||
|
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||||
|
<p>
|
||||||
|
Semantic Search in Frigate allows you to find tracked objects
|
||||||
|
within your review items using either the image itself, a
|
||||||
|
user-defined text description, or an automatically generated one.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center text-primary">
|
||||||
|
<Link
|
||||||
|
to="https://docs.frigate.video/configuration/semantic_search"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline"
|
||||||
|
>
|
||||||
|
Read the Documentation
|
||||||
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full max-w-lg flex-col space-y-6">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<Switch
|
||||||
|
id="enabled"
|
||||||
|
className="mr-3"
|
||||||
|
disabled={searchSettings.enabled === undefined}
|
||||||
|
checked={searchSettings.enabled === true}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
handleSearchConfigChange({ enabled: isChecked });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="enabled">Enabled</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<Switch
|
||||||
|
id="reindex"
|
||||||
|
className="mr-3"
|
||||||
|
disabled={searchSettings.reindex === undefined}
|
||||||
|
checked={searchSettings.reindex === true}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
handleSearchConfigChange({ reindex: isChecked });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="reindex">Re-Index On Startup</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-sm text-muted-foreground">
|
||||||
|
Re-indexing will reprocess all thumbnails and descriptions (if
|
||||||
|
enabled) and apply the embeddings on each startup.{" "}
|
||||||
|
<em>Don't forget to disable the option after restarting!</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-col space-y-6">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="text-md">Model Size</div>
|
||||||
|
<div className="space-y-1 text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
The size of the model used for semantic search embeddings.
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-5 text-sm">
|
||||||
|
<li>
|
||||||
|
Using <em>small</em> employs a quantized version of the
|
||||||
|
model that uses less RAM and runs faster on CPU with a very
|
||||||
|
negligible difference in embedding quality.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Using <em>large</em> employs the full Jina model and will
|
||||||
|
automatically run on the GPU if applicable.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={searchSettings.model_size}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleSearchConfigChange({
|
||||||
|
model_size: value as SearchModelSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-20">
|
||||||
|
{searchSettings.model_size}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{["small", "large"].map((size) => (
|
||||||
|
<SelectItem
|
||||||
|
key={size}
|
||||||
|
className="cursor-pointer"
|
||||||
|
value={size}
|
||||||
|
>
|
||||||
|
{size}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-2 flex bg-secondary" />
|
||||||
|
|
||||||
|
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
||||||
|
<Button className="flex flex-1" onClick={onCancel}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="select"
|
||||||
|
disabled={!changedValue || isLoading}
|
||||||
|
className="flex flex-1"
|
||||||
|
onClick={saveToConfig}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<ActivityIndicator />
|
||||||
|
<span>Saving...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Save"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -22,7 +22,7 @@ import {
|
|||||||
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
|
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
|
||||||
const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
||||||
|
|
||||||
export default function GeneralSettingsView() {
|
export default function UiSettingsView() {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
const clearStoredLayouts = useCallback(() => {
|
const clearStoredLayouts = useCallback(() => {
|
Loading…
Reference in New Issue
Block a user