mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-10-13 11:16:29 +02:00
* i18n translated label fixes * Fix frame cache race bug Objects that were marked as false positives (that would later become true positives) would sometimes have their saved frame prematurely removed from the frame cache.
600 lines
17 KiB
TypeScript
600 lines
17 KiB
TypeScript
import { Button } from "../ui/button";
|
|
import useSWR from "swr";
|
|
import { FrigateConfig } from "@/types/frigateConfig";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { DropdownMenuSeparator } from "../ui/dropdown-menu";
|
|
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
|
|
import { isDesktop, isMobile } from "react-device-detect";
|
|
import { Switch } from "../ui/switch";
|
|
import { Label } from "../ui/label";
|
|
import FilterSwitch from "./FilterSwitch";
|
|
import { FilterList } from "@/types/filter";
|
|
import { CamerasFilterButton } from "./CamerasFilterButton";
|
|
import {
|
|
DEFAULT_SEARCH_FILTERS,
|
|
SearchFilter,
|
|
SearchFilters,
|
|
SearchSource,
|
|
SearchSortType,
|
|
} from "@/types/search";
|
|
import { DateRange } from "react-day-picker";
|
|
import { cn } from "@/lib/utils";
|
|
import { MdLabel, MdSort } from "react-icons/md";
|
|
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
|
|
import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog";
|
|
import { CalendarRangeFilterButton } from "./CalendarFilterButton";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
|
|
import { useTranslation } from "react-i18next";
|
|
import { getTranslatedLabel } from "@/utils/i18n";
|
|
|
|
type SearchFilterGroupProps = {
|
|
className: string;
|
|
filters?: SearchFilters[];
|
|
filter?: SearchFilter;
|
|
filterList?: FilterList;
|
|
onUpdateFilter: (filter: SearchFilter) => void;
|
|
};
|
|
export default function SearchFilterGroup({
|
|
className,
|
|
filters = DEFAULT_SEARCH_FILTERS,
|
|
filter,
|
|
filterList,
|
|
onUpdateFilter,
|
|
}: SearchFilterGroupProps) {
|
|
const { t } = useTranslation(["components/filter"]);
|
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
|
revalidateOnFocus: false,
|
|
});
|
|
|
|
const allLabels = useMemo<string[]>(() => {
|
|
if (filterList?.labels) {
|
|
return filterList.labels;
|
|
}
|
|
|
|
if (!config) {
|
|
return [];
|
|
}
|
|
|
|
const labels = new Set<string>();
|
|
const cameras = filter?.cameras || Object.keys(config.cameras);
|
|
|
|
cameras.forEach((camera) => {
|
|
if (camera == "birdseye") {
|
|
return;
|
|
}
|
|
const cameraConfig = config.cameras[camera];
|
|
|
|
if (!cameraConfig) {
|
|
return;
|
|
}
|
|
|
|
cameraConfig.objects.track.forEach((label) => {
|
|
if (!config.model.all_attributes.includes(label)) {
|
|
labels.add(label);
|
|
}
|
|
});
|
|
|
|
if (cameraConfig.type == "lpr") {
|
|
labels.add("license_plate");
|
|
}
|
|
|
|
if (cameraConfig.audio.enabled_in_config) {
|
|
cameraConfig.audio.listen.forEach((label) => {
|
|
labels.add(label);
|
|
});
|
|
}
|
|
});
|
|
|
|
return [...labels].sort();
|
|
}, [config, filterList, filter]);
|
|
|
|
const allZones = useMemo<string[]>(() => {
|
|
if (filterList?.zones) {
|
|
return filterList.zones;
|
|
}
|
|
|
|
if (!config) {
|
|
return [];
|
|
}
|
|
|
|
const zones = new Set<string>();
|
|
const cameras = filter?.cameras || Object.keys(config.cameras);
|
|
|
|
cameras.forEach((camera) => {
|
|
if (camera == "birdseye") {
|
|
return;
|
|
}
|
|
|
|
const cameraConfig = config.cameras[camera];
|
|
|
|
if (!cameraConfig) {
|
|
return;
|
|
}
|
|
|
|
Object.entries(cameraConfig.zones).map(([name, _]) => {
|
|
zones.add(name);
|
|
});
|
|
});
|
|
|
|
return [...zones].sort();
|
|
}, [config, filterList, filter]);
|
|
|
|
const filterValues = useMemo(
|
|
() => ({
|
|
cameras: Object.keys(config?.cameras || {}),
|
|
labels: Object.values(allLabels || {}),
|
|
zones: Object.values(allZones || {}),
|
|
search_type: ["thumbnail", "description"] as SearchSource[],
|
|
}),
|
|
[config, allLabels, allZones],
|
|
);
|
|
|
|
const availableSortTypes = useMemo(() => {
|
|
const sortTypes = ["date_asc", "date_desc"];
|
|
if (filter?.min_score || filter?.max_score) {
|
|
sortTypes.push("score_desc", "score_asc");
|
|
}
|
|
if (filter?.min_speed || filter?.max_speed) {
|
|
sortTypes.push("speed_desc", "speed_asc");
|
|
}
|
|
if (filter?.event_id || filter?.query) {
|
|
sortTypes.push("relevance");
|
|
}
|
|
return sortTypes as SearchSortType[];
|
|
}, [filter]);
|
|
|
|
const defaultSortType = useMemo<SearchSortType>(() => {
|
|
if (filter?.query || filter?.event_id) {
|
|
return "relevance";
|
|
} else {
|
|
return "date_desc";
|
|
}
|
|
}, [filter]);
|
|
|
|
const groups = useMemo(() => {
|
|
if (!config) {
|
|
return [];
|
|
}
|
|
|
|
return Object.entries(config.camera_groups).sort(
|
|
(a, b) => a[1].order - b[1].order,
|
|
);
|
|
}, [config]);
|
|
|
|
// handle updating filters
|
|
|
|
const onUpdateSelectedRange = useCallback(
|
|
(range?: DateRange) => {
|
|
onUpdateFilter({
|
|
...filter,
|
|
after:
|
|
range?.from == undefined ? undefined : range.from.getTime() / 1000,
|
|
before:
|
|
range?.to == undefined ? undefined : getEndOfDayTimestamp(range.to),
|
|
});
|
|
},
|
|
[filter, onUpdateFilter],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"scrollbar-container flex justify-center gap-2 overflow-x-auto",
|
|
className,
|
|
)}
|
|
>
|
|
{filters.includes("cameras") && (
|
|
<CamerasFilterButton
|
|
allCameras={filterValues.cameras}
|
|
groups={groups}
|
|
selectedCameras={filter?.cameras}
|
|
hideText={false}
|
|
updateCameraFilter={(newCameras) => {
|
|
onUpdateFilter({ ...filter, cameras: newCameras });
|
|
}}
|
|
/>
|
|
)}
|
|
{filters.includes("general") && (
|
|
<GeneralFilterButton
|
|
allLabels={filterValues.labels}
|
|
selectedLabels={filter?.labels}
|
|
updateLabelFilter={(newLabels) => {
|
|
onUpdateFilter({ ...filter, labels: newLabels });
|
|
}}
|
|
/>
|
|
)}
|
|
{filters.includes("date") && (
|
|
<CalendarRangeFilterButton
|
|
range={
|
|
filter?.after == undefined || filter?.before == undefined
|
|
? undefined
|
|
: {
|
|
from: new Date(filter.after * 1000),
|
|
to: new Date(filter.before * 1000),
|
|
}
|
|
}
|
|
defaultText={isMobile ? t("dates.all.short") : t("dates.all.title")}
|
|
updateSelectedRange={onUpdateSelectedRange}
|
|
/>
|
|
)}
|
|
<SearchFilterDialog
|
|
config={config}
|
|
filter={filter}
|
|
filterValues={filterValues}
|
|
onUpdateFilter={onUpdateFilter}
|
|
/>
|
|
{filters.includes("sort") && Object.keys(filter ?? {}).length > 0 && (
|
|
<SortTypeButton
|
|
availableSortTypes={availableSortTypes ?? []}
|
|
defaultSortType={defaultSortType}
|
|
selectedSortType={filter?.sort}
|
|
updateSortType={(newSort) => {
|
|
onUpdateFilter({ ...filter, sort: newSort });
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type GeneralFilterButtonProps = {
|
|
allLabels: string[];
|
|
selectedLabels: string[] | undefined;
|
|
updateLabelFilter: (labels: string[] | undefined) => void;
|
|
};
|
|
function GeneralFilterButton({
|
|
allLabels,
|
|
selectedLabels,
|
|
updateLabelFilter,
|
|
}: GeneralFilterButtonProps) {
|
|
const { t } = useTranslation(["components/filter"]);
|
|
const [open, setOpen] = useState(false);
|
|
const [currentLabels, setCurrentLabels] = useState<string[] | undefined>(
|
|
selectedLabels,
|
|
);
|
|
|
|
const buttonText = useMemo(() => {
|
|
if (isMobile) {
|
|
return t("labels.all.short");
|
|
}
|
|
|
|
if (!selectedLabels || selectedLabels.length == 0) {
|
|
return t("labels.all.title");
|
|
}
|
|
|
|
if (selectedLabels.length == 1) {
|
|
return getTranslatedLabel(selectedLabels[0]);
|
|
}
|
|
|
|
return t("labels.count", {
|
|
count: selectedLabels.length,
|
|
});
|
|
}, [selectedLabels, t]);
|
|
|
|
// ui
|
|
|
|
useEffect(() => {
|
|
setCurrentLabels(selectedLabels);
|
|
// only refresh when state changes
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedLabels]);
|
|
|
|
const trigger = (
|
|
<Button
|
|
size="sm"
|
|
variant={selectedLabels?.length ? "select" : "default"}
|
|
className="flex items-center gap-2 smart-capitalize"
|
|
aria-label={t("labels.label")}
|
|
>
|
|
<MdLabel
|
|
className={`${selectedLabels?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
|
/>
|
|
<div
|
|
className={`${selectedLabels?.length ? "text-selected-foreground" : "text-primary"}`}
|
|
>
|
|
{buttonText}
|
|
</div>
|
|
</Button>
|
|
);
|
|
const content = (
|
|
<GeneralFilterContent
|
|
allLabels={allLabels}
|
|
selectedLabels={selectedLabels}
|
|
currentLabels={currentLabels}
|
|
setCurrentLabels={setCurrentLabels}
|
|
updateLabelFilter={updateLabelFilter}
|
|
onClose={() => setOpen(false)}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<PlatformAwareDialog
|
|
trigger={trigger}
|
|
content={content}
|
|
contentClassName={
|
|
isDesktop
|
|
? "scrollbar-container h-auto max-h-[80dvh] overflow-y-auto"
|
|
: "max-h-[75dvh] overflow-hidden p-4"
|
|
}
|
|
open={open}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setCurrentLabels(selectedLabels);
|
|
}
|
|
|
|
setOpen(open);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
type GeneralFilterContentProps = {
|
|
allLabels: string[];
|
|
selectedLabels: string[] | undefined;
|
|
currentLabels: string[] | undefined;
|
|
updateLabelFilter: (labels: string[] | undefined) => void;
|
|
setCurrentLabels: (labels: string[] | undefined) => void;
|
|
onClose: () => void;
|
|
};
|
|
export function GeneralFilterContent({
|
|
allLabels,
|
|
selectedLabels,
|
|
currentLabels,
|
|
updateLabelFilter,
|
|
setCurrentLabels,
|
|
onClose,
|
|
}: GeneralFilterContentProps) {
|
|
const { t } = useTranslation(["components/filter"]);
|
|
return (
|
|
<>
|
|
<div className="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"
|
|
>
|
|
{t("labels.all.title")}
|
|
</Label>
|
|
<Switch
|
|
className="ml-1"
|
|
id="allLabels"
|
|
checked={currentLabels == undefined}
|
|
onCheckedChange={(isChecked) => {
|
|
if (isChecked) {
|
|
setCurrentLabels(undefined);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="my-2.5 flex flex-col gap-2.5">
|
|
{allLabels.map((item) => (
|
|
<FilterSwitch
|
|
key={item}
|
|
label={getTranslatedLabel(item)}
|
|
isChecked={currentLabels?.includes(item) ?? false}
|
|
onCheckedChange={(isChecked) => {
|
|
if (isChecked) {
|
|
const updatedLabels = currentLabels ? [...currentLabels] : [];
|
|
|
|
updatedLabels.push(item);
|
|
setCurrentLabels(updatedLabels);
|
|
} else {
|
|
const updatedLabels = currentLabels ? [...currentLabels] : [];
|
|
|
|
// can not deselect the last item
|
|
if (updatedLabels.length > 1) {
|
|
updatedLabels.splice(updatedLabels.indexOf(item), 1);
|
|
setCurrentLabels(updatedLabels);
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<DropdownMenuSeparator />
|
|
<div className="flex items-center justify-evenly p-2">
|
|
<Button
|
|
aria-label={t("button.apply", { ns: "common" })}
|
|
variant="select"
|
|
onClick={() => {
|
|
if (selectedLabels != currentLabels) {
|
|
updateLabelFilter(currentLabels);
|
|
}
|
|
|
|
onClose();
|
|
}}
|
|
>
|
|
{t("button.apply", { ns: "common" })}
|
|
</Button>
|
|
<Button
|
|
aria-label={t("button.reset", { ns: "common" })}
|
|
onClick={() => {
|
|
setCurrentLabels(undefined);
|
|
updateLabelFilter(undefined);
|
|
}}
|
|
>
|
|
{t("button.reset", { ns: "common" })}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
type SortTypeButtonProps = {
|
|
availableSortTypes: SearchSortType[];
|
|
defaultSortType: SearchSortType;
|
|
selectedSortType: SearchSortType | undefined;
|
|
updateSortType: (sortType: SearchSortType | undefined) => void;
|
|
};
|
|
function SortTypeButton({
|
|
availableSortTypes,
|
|
defaultSortType,
|
|
selectedSortType,
|
|
updateSortType,
|
|
}: SortTypeButtonProps) {
|
|
const { t } = useTranslation(["components/filter"]);
|
|
const [open, setOpen] = useState(false);
|
|
const [currentSortType, setCurrentSortType] = useState<
|
|
SearchSortType | undefined
|
|
>(selectedSortType as SearchSortType);
|
|
|
|
// ui
|
|
|
|
useEffect(() => {
|
|
setCurrentSortType(selectedSortType);
|
|
// only refresh when state changes
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedSortType]);
|
|
|
|
const trigger = (
|
|
<Button
|
|
size="sm"
|
|
variant={
|
|
selectedSortType != defaultSortType && selectedSortType != undefined
|
|
? "select"
|
|
: "default"
|
|
}
|
|
className="flex items-center gap-2 smart-capitalize"
|
|
aria-label={t("labels.label")}
|
|
>
|
|
<MdSort
|
|
className={`${selectedSortType != defaultSortType && selectedSortType != undefined ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
|
/>
|
|
<div
|
|
className={`${selectedSortType != defaultSortType && selectedSortType != undefined ? "text-selected-foreground" : "text-primary"}`}
|
|
>
|
|
{t("sort.label")}
|
|
</div>
|
|
</Button>
|
|
);
|
|
const content = (
|
|
<SortTypeContent
|
|
availableSortTypes={availableSortTypes ?? []}
|
|
defaultSortType={defaultSortType}
|
|
selectedSortType={selectedSortType}
|
|
currentSortType={currentSortType}
|
|
setCurrentSortType={setCurrentSortType}
|
|
updateSortType={updateSortType}
|
|
onClose={() => setOpen(false)}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<PlatformAwareDialog
|
|
trigger={trigger}
|
|
content={content}
|
|
contentClassName={
|
|
isDesktop
|
|
? "scrollbar-container h-auto max-h-[80dvh] overflow-y-auto"
|
|
: "max-h-[75dvh] overflow-hidden p-4"
|
|
}
|
|
open={open}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setCurrentSortType(selectedSortType);
|
|
}
|
|
|
|
setOpen(open);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
type SortTypeContentProps = {
|
|
availableSortTypes: SearchSortType[];
|
|
defaultSortType: SearchSortType;
|
|
selectedSortType: SearchSortType | undefined;
|
|
currentSortType: SearchSortType | undefined;
|
|
updateSortType: (sort_type: SearchSortType | undefined) => void;
|
|
setCurrentSortType: (sort_type: SearchSortType | undefined) => void;
|
|
onClose: () => void;
|
|
};
|
|
export function SortTypeContent({
|
|
availableSortTypes,
|
|
defaultSortType,
|
|
selectedSortType,
|
|
currentSortType,
|
|
updateSortType,
|
|
setCurrentSortType,
|
|
onClose,
|
|
}: SortTypeContentProps) {
|
|
const { t } = useTranslation(["components/filter"]);
|
|
const sortLabels = {
|
|
date_asc: t("sort.dateAsc"),
|
|
date_desc: t("sort.dateDesc"),
|
|
score_asc: t("sort.scoreAsc"),
|
|
score_desc: t("sort.scoreDesc"),
|
|
speed_asc: t("sort.speedAsc"),
|
|
speed_desc: t("sort.speedDesc"),
|
|
relevance: t("sort.relevance"),
|
|
};
|
|
return (
|
|
<>
|
|
<div className="overflow-x-hidden">
|
|
<div className="my-2.5 flex flex-col gap-2.5">
|
|
<RadioGroup
|
|
value={
|
|
Array.isArray(currentSortType)
|
|
? currentSortType?.[0]
|
|
: (currentSortType ?? defaultSortType)
|
|
}
|
|
defaultValue={defaultSortType}
|
|
onValueChange={(value) =>
|
|
setCurrentSortType(value as SearchSortType)
|
|
}
|
|
className="w-full space-y-1"
|
|
>
|
|
{availableSortTypes.map((value) => (
|
|
<div className="flex flex-row gap-2">
|
|
<RadioGroupItem
|
|
key={value}
|
|
value={value}
|
|
id={`sort-${value}`}
|
|
className={
|
|
value == (currentSortType ?? defaultSortType)
|
|
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
|
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
|
}
|
|
/>
|
|
<Label
|
|
htmlFor={`sort-${value}`}
|
|
className="flex cursor-pointer items-center space-x-2"
|
|
>
|
|
<span>{sortLabels[value]}</span>
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</RadioGroup>
|
|
</div>
|
|
</div>
|
|
<DropdownMenuSeparator />
|
|
<div className="flex items-center justify-evenly p-2">
|
|
<Button
|
|
aria-label={t("button.apply", { ns: "common" })}
|
|
variant="select"
|
|
onClick={() => {
|
|
if (selectedSortType != currentSortType) {
|
|
updateSortType(currentSortType);
|
|
}
|
|
|
|
onClose();
|
|
}}
|
|
>
|
|
{t("button.apply", { ns: "common" })}
|
|
</Button>
|
|
<Button
|
|
aria-label={t("button.reset", { ns: "common" })}
|
|
onClick={() => {
|
|
setCurrentSortType(undefined);
|
|
updateSortType(undefined);
|
|
}}
|
|
>
|
|
{t("button.reset", { ns: "common" })}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|