2024-09-11 17:41:16 +03:00
|
|
|
import SearchThumbnail from "@/components/card/SearchThumbnail";
|
2024-06-23 23:58:00 +03:00
|
|
|
import SearchFilterGroup from "@/components/filter/SearchFilterGroup";
|
|
|
|
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
2024-10-15 16:24:47 +03:00
|
|
|
import SearchDetailDialog, {
|
|
|
|
|
SearchTab,
|
|
|
|
|
} from "@/components/overlay/detail/SearchDetailDialog";
|
2024-06-23 23:58:00 +03:00
|
|
|
import { Toaster } from "@/components/ui/sonner";
|
2024-07-11 01:01:31 +03:00
|
|
|
import { cn } from "@/lib/utils";
|
2024-09-10 19:23:20 +03:00
|
|
|
import { FrigateConfig } from "@/types/frigateConfig";
|
2024-09-26 23:30:56 +03:00
|
|
|
import { SearchFilter, SearchResult, SearchSource } from "@/types/search";
|
2024-09-13 06:07:35 +03:00
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
2024-10-16 19:54:01 +03:00
|
|
|
import { isMobileOnly } from "react-device-detect";
|
2024-10-17 18:21:20 +03:00
|
|
|
import { LuImage, LuSearchX, LuText } from "react-icons/lu";
|
2024-09-10 19:23:20 +03:00
|
|
|
import useSWR from "swr";
|
2024-09-11 17:41:16 +03:00
|
|
|
import ExploreView from "../explore/ExploreView";
|
2024-09-13 06:07:35 +03:00
|
|
|
import useKeyboardListener, {
|
|
|
|
|
KeyModifiers,
|
|
|
|
|
} from "@/hooks/use-keyboard-listener";
|
|
|
|
|
import scrollIntoView from "scroll-into-view-if-needed";
|
2024-09-18 21:18:16 +03:00
|
|
|
import InputWithTags from "@/components/input/InputWithTags";
|
2024-09-23 15:39:20 +03:00
|
|
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
2024-09-24 17:14:51 +03:00
|
|
|
import { isEqual } from "lodash";
|
2024-09-25 19:05:40 +03:00
|
|
|
import { formatDateToLocaleString } from "@/utils/dateUtil";
|
2024-10-15 16:24:47 +03:00
|
|
|
import SearchThumbnailFooter from "@/components/card/SearchThumbnailFooter";
|
2025-03-16 18:36:20 +03:00
|
|
|
import ExploreSettings from "@/components/settings/SearchSettings";
|
2024-10-17 18:21:20 +03:00
|
|
|
import {
|
|
|
|
|
Tooltip,
|
|
|
|
|
TooltipContent,
|
|
|
|
|
TooltipTrigger,
|
|
|
|
|
} from "@/components/ui/tooltip";
|
|
|
|
|
import Chip from "@/components/indicators/Chip";
|
|
|
|
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
2024-12-02 21:12:55 +03:00
|
|
|
import SearchActionGroup from "@/components/filter/SearchActionGroup";
|
2025-05-06 05:42:24 +03:00
|
|
|
import { Trans, useTranslation } from "react-i18next";
|
2025-07-07 17:03:57 +03:00
|
|
|
import { useNavigate } from "react-router-dom";
|
2024-06-23 23:58:00 +03:00
|
|
|
|
|
|
|
|
type SearchViewProps = {
|
|
|
|
|
search: string;
|
|
|
|
|
searchTerm: string;
|
|
|
|
|
searchFilter?: SearchFilter;
|
|
|
|
|
searchResults?: SearchResult[];
|
|
|
|
|
isLoading: boolean;
|
2024-11-02 04:52:00 +03:00
|
|
|
isValidating: boolean;
|
2024-10-15 16:24:47 +03:00
|
|
|
hasMore: boolean;
|
2024-10-16 19:54:01 +03:00
|
|
|
columns: number;
|
|
|
|
|
defaultView?: string;
|
2024-06-23 23:58:00 +03:00
|
|
|
setSearch: (search: string) => void;
|
|
|
|
|
setSimilaritySearch: (search: SearchResult) => void;
|
2024-09-18 21:18:16 +03:00
|
|
|
setSearchFilter: (filter: SearchFilter) => void;
|
2024-06-23 23:58:00 +03:00
|
|
|
onUpdateFilter: (filter: SearchFilter) => void;
|
2024-09-14 16:42:56 +03:00
|
|
|
loadMore: () => void;
|
2024-10-15 16:24:47 +03:00
|
|
|
refresh: () => void;
|
2024-10-16 19:54:01 +03:00
|
|
|
setColumns: (columns: number) => void;
|
|
|
|
|
setDefaultView: (name: string) => void;
|
2024-06-23 23:58:00 +03:00
|
|
|
};
|
|
|
|
|
export default function SearchView({
|
|
|
|
|
search,
|
|
|
|
|
searchTerm,
|
|
|
|
|
searchFilter,
|
|
|
|
|
searchResults,
|
|
|
|
|
isLoading,
|
2024-11-02 04:52:00 +03:00
|
|
|
isValidating,
|
2024-10-15 16:24:47 +03:00
|
|
|
hasMore,
|
2024-10-16 19:54:01 +03:00
|
|
|
columns,
|
|
|
|
|
defaultView = "summary",
|
2024-06-23 23:58:00 +03:00
|
|
|
setSearch,
|
|
|
|
|
setSimilaritySearch,
|
2024-09-18 21:18:16 +03:00
|
|
|
setSearchFilter,
|
2024-06-23 23:58:00 +03:00
|
|
|
onUpdateFilter,
|
2024-09-14 16:42:56 +03:00
|
|
|
loadMore,
|
2024-10-15 16:24:47 +03:00
|
|
|
refresh,
|
2024-10-16 19:54:01 +03:00
|
|
|
setColumns,
|
|
|
|
|
setDefaultView,
|
2024-06-23 23:58:00 +03:00
|
|
|
}: SearchViewProps) {
|
2025-03-16 18:36:20 +03:00
|
|
|
const { t } = useTranslation(["views/explore"]);
|
2024-10-02 17:32:12 +03:00
|
|
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
2024-09-10 19:23:20 +03:00
|
|
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
|
|
|
|
revalidateOnFocus: false,
|
|
|
|
|
});
|
2025-07-07 17:03:57 +03:00
|
|
|
const navigate = useNavigate();
|
2024-09-10 19:23:20 +03:00
|
|
|
|
2024-10-02 15:59:53 +03:00
|
|
|
// grid
|
|
|
|
|
|
2024-10-16 03:25:59 +03:00
|
|
|
const gridClassName = cn(
|
|
|
|
|
"grid w-full gap-2 px-1 gap-2 lg:gap-4 md:mx-2",
|
|
|
|
|
isMobileOnly && "grid-cols-2",
|
|
|
|
|
{
|
2024-10-16 19:54:01 +03:00
|
|
|
"sm:grid-cols-2": columns <= 2,
|
|
|
|
|
"sm:grid-cols-3": columns === 3,
|
|
|
|
|
"sm:grid-cols-4": columns === 4,
|
|
|
|
|
"sm:grid-cols-5": columns === 5,
|
|
|
|
|
"sm:grid-cols-6": columns === 6,
|
2024-10-30 14:54:06 +03:00
|
|
|
"sm:grid-cols-7": columns === 7,
|
|
|
|
|
"sm:grid-cols-8": columns === 8,
|
2024-10-16 03:25:59 +03:00
|
|
|
},
|
|
|
|
|
);
|
2024-10-02 15:59:53 +03:00
|
|
|
|
2024-09-18 21:18:16 +03:00
|
|
|
// suggestions values
|
|
|
|
|
|
|
|
|
|
const allLabels = useMemo<string[]>(() => {
|
|
|
|
|
if (!config) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const labels = new Set<string>();
|
|
|
|
|
const cameras = searchFilter?.cameras || Object.keys(config.cameras);
|
|
|
|
|
|
|
|
|
|
cameras.forEach((camera) => {
|
|
|
|
|
if (camera == "birdseye") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-04-17 22:48:09 +03:00
|
|
|
|
2024-09-18 21:18:16 +03:00
|
|
|
const cameraConfig = config.cameras[camera];
|
2025-04-17 22:48:09 +03:00
|
|
|
|
|
|
|
|
if (!cameraConfig) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-18 21:18:16 +03:00
|
|
|
cameraConfig.objects.track.forEach((label) => {
|
|
|
|
|
labels.add(label);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (cameraConfig.audio.enabled_in_config) {
|
|
|
|
|
cameraConfig.audio.listen.forEach((label) => {
|
|
|
|
|
labels.add(label);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return [...labels].sort();
|
|
|
|
|
}, [config, searchFilter]);
|
|
|
|
|
|
|
|
|
|
const { data: allSubLabels } = useSWR("sub_labels");
|
2025-03-13 02:45:16 +03:00
|
|
|
const { data: allRecognizedLicensePlates } = useSWR(
|
|
|
|
|
"recognized_license_plates",
|
|
|
|
|
);
|
2024-09-18 21:18:16 +03:00
|
|
|
|
|
|
|
|
const allZones = useMemo<string[]>(() => {
|
|
|
|
|
if (!config) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const zones = new Set<string>();
|
|
|
|
|
const cameras = searchFilter?.cameras || Object.keys(config.cameras);
|
|
|
|
|
|
|
|
|
|
cameras.forEach((camera) => {
|
|
|
|
|
if (camera == "birdseye") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-04-17 22:48:09 +03:00
|
|
|
|
2024-09-18 21:18:16 +03:00
|
|
|
const cameraConfig = config.cameras[camera];
|
2025-04-17 22:48:09 +03:00
|
|
|
|
|
|
|
|
if (!cameraConfig) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-18 21:18:16 +03:00
|
|
|
Object.entries(cameraConfig.zones).map(([name, _]) => {
|
|
|
|
|
zones.add(name);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return [...zones].sort();
|
|
|
|
|
}, [config, searchFilter]);
|
|
|
|
|
|
|
|
|
|
const suggestionsValues = useMemo(
|
|
|
|
|
() => ({
|
|
|
|
|
cameras: Object.keys(config?.cameras || {}),
|
|
|
|
|
labels: Object.values(allLabels || {}),
|
|
|
|
|
zones: Object.values(allZones || {}),
|
|
|
|
|
sub_labels: allSubLabels,
|
|
|
|
|
search_type: ["thumbnail", "description"] as SearchSource[],
|
2024-09-25 20:15:08 +03:00
|
|
|
time_range:
|
|
|
|
|
config?.ui.time_format == "24hour"
|
|
|
|
|
? ["00:00-23:59"]
|
|
|
|
|
: ["12:00AM-11:59PM"],
|
2024-09-25 19:05:40 +03:00
|
|
|
before: [formatDateToLocaleString()],
|
|
|
|
|
after: [formatDateToLocaleString(-5)],
|
2024-10-17 14:30:52 +03:00
|
|
|
min_score: ["50"],
|
|
|
|
|
max_score: ["100"],
|
2025-02-10 23:23:42 +03:00
|
|
|
min_speed: ["1"],
|
|
|
|
|
max_speed: ["150"],
|
2025-03-13 02:45:16 +03:00
|
|
|
recognized_license_plate: allRecognizedLicensePlates,
|
2024-10-19 00:16:43 +03:00
|
|
|
has_clip: ["yes", "no"],
|
|
|
|
|
has_snapshot: ["yes", "no"],
|
2024-11-11 01:57:11 +03:00
|
|
|
...(config?.plus?.enabled &&
|
|
|
|
|
searchFilter?.has_snapshot && { is_submitted: ["yes", "no"] }),
|
2024-09-18 21:18:16 +03:00
|
|
|
}),
|
2025-03-13 02:45:16 +03:00
|
|
|
[
|
|
|
|
|
config,
|
|
|
|
|
allLabels,
|
|
|
|
|
allZones,
|
|
|
|
|
allSubLabels,
|
|
|
|
|
allRecognizedLicensePlates,
|
|
|
|
|
searchFilter,
|
|
|
|
|
],
|
2024-09-18 21:18:16 +03:00
|
|
|
);
|
|
|
|
|
|
2024-09-09 17:33:38 +03:00
|
|
|
// remove duplicate event ids
|
|
|
|
|
|
|
|
|
|
const uniqueResults = useMemo(() => {
|
|
|
|
|
return searchResults?.filter(
|
|
|
|
|
(value, index, self) =>
|
|
|
|
|
index === self.findIndex((v) => v.id === value.id),
|
|
|
|
|
);
|
|
|
|
|
}, [searchResults]);
|
|
|
|
|
|
2024-06-23 23:58:00 +03:00
|
|
|
// detail
|
|
|
|
|
|
|
|
|
|
const [searchDetail, setSearchDetail] = useState<SearchResult>();
|
2024-10-15 16:24:47 +03:00
|
|
|
const [page, setPage] = useState<SearchTab>("details");
|
2024-06-23 23:58:00 +03:00
|
|
|
|
|
|
|
|
// search interaction
|
|
|
|
|
|
2024-12-02 21:12:55 +03:00
|
|
|
const [selectedObjects, setSelectedObjects] = useState<string[]>([]);
|
2024-09-13 06:07:35 +03:00
|
|
|
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
|
|
|
|
2024-10-15 16:24:47 +03:00
|
|
|
const onSelectSearch = useCallback(
|
2024-12-02 21:12:55 +03:00
|
|
|
(item: SearchResult, ctrl: boolean, page: SearchTab = "details") => {
|
|
|
|
|
if (selectedObjects.length > 1 || ctrl) {
|
|
|
|
|
const index = selectedObjects.indexOf(item.id);
|
|
|
|
|
|
|
|
|
|
if (index != -1) {
|
|
|
|
|
if (selectedObjects.length == 1) {
|
|
|
|
|
setSelectedObjects([]);
|
|
|
|
|
} else {
|
|
|
|
|
const copy = [
|
|
|
|
|
...selectedObjects.slice(0, index),
|
|
|
|
|
...selectedObjects.slice(index + 1),
|
|
|
|
|
];
|
|
|
|
|
setSelectedObjects(copy);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const copy = [...selectedObjects];
|
|
|
|
|
copy.push(item.id);
|
|
|
|
|
setSelectedObjects(copy);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
setPage(page);
|
|
|
|
|
setSearchDetail(item);
|
|
|
|
|
}
|
2024-10-15 16:24:47 +03:00
|
|
|
},
|
2024-12-02 21:12:55 +03:00
|
|
|
[selectedObjects],
|
2024-10-15 16:24:47 +03:00
|
|
|
);
|
|
|
|
|
|
2024-12-02 21:12:55 +03:00
|
|
|
const onSelectAllObjects = useCallback(() => {
|
|
|
|
|
if (!uniqueResults || uniqueResults.length == 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (selectedObjects.length < uniqueResults.length) {
|
|
|
|
|
setSelectedObjects(uniqueResults.map((value) => value.id));
|
|
|
|
|
} else {
|
|
|
|
|
setSelectedObjects([]);
|
|
|
|
|
}
|
|
|
|
|
}, [uniqueResults, selectedObjects]);
|
|
|
|
|
|
2024-10-15 16:24:47 +03:00
|
|
|
useEffect(() => {
|
2024-12-02 21:12:55 +03:00
|
|
|
setSelectedObjects([]);
|
|
|
|
|
// unselect items when search term or filter changes
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2024-10-15 16:24:47 +03:00
|
|
|
}, [searchTerm, searchFilter]);
|
2024-06-23 23:58:00 +03:00
|
|
|
|
2024-10-17 18:21:20 +03:00
|
|
|
// confidence score
|
|
|
|
|
|
|
|
|
|
const zScoreToConfidence = (score: number) => {
|
|
|
|
|
// Normalizing is not needed for similarity searches
|
|
|
|
|
// Sigmoid function for normalized: 1 / (1 + e^x)
|
|
|
|
|
// Cosine for similarity
|
|
|
|
|
if (searchFilter) {
|
|
|
|
|
const notNormalized = searchFilter?.search_type?.includes("similarity");
|
|
|
|
|
|
|
|
|
|
const confidence = notNormalized ? 1 - score : 1 / (1 + Math.exp(score));
|
|
|
|
|
|
|
|
|
|
return Math.round(confidence * 100);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2024-09-24 17:14:51 +03:00
|
|
|
// update search detail when results change
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (searchDetail && searchResults) {
|
|
|
|
|
const flattenedResults = searchResults.flat();
|
|
|
|
|
const updatedSearchDetail = flattenedResults.find(
|
|
|
|
|
(result) => result.id === searchDetail.id,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (updatedSearchDetail && !isEqual(updatedSearchDetail, searchDetail)) {
|
|
|
|
|
setSearchDetail(updatedSearchDetail);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [searchResults, searchDetail]);
|
|
|
|
|
|
2024-09-10 19:23:20 +03:00
|
|
|
const hasExistingSearch = useMemo(
|
|
|
|
|
() => searchResults != undefined || searchFilter != undefined,
|
|
|
|
|
[searchResults, searchFilter],
|
|
|
|
|
);
|
|
|
|
|
|
2024-09-13 06:07:35 +03:00
|
|
|
// keyboard listener
|
|
|
|
|
|
2024-09-25 21:45:42 +03:00
|
|
|
const [inputFocused, setInputFocused] = useState(false);
|
|
|
|
|
|
2024-09-13 06:07:35 +03:00
|
|
|
const onKeyboardShortcut = useCallback(
|
|
|
|
|
(key: string | null, modifiers: KeyModifiers) => {
|
2024-09-25 21:45:42 +03:00
|
|
|
if (!modifiers.down || !uniqueResults || inputFocused) {
|
2024-09-13 06:07:35 +03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (key) {
|
2024-12-02 21:12:55 +03:00
|
|
|
case "a":
|
|
|
|
|
if (modifiers.ctrl) {
|
|
|
|
|
onSelectAllObjects();
|
|
|
|
|
}
|
|
|
|
|
break;
|
2024-09-13 06:07:35 +03:00
|
|
|
case "ArrowLeft":
|
2024-12-02 21:12:55 +03:00
|
|
|
if (uniqueResults.length > 0) {
|
|
|
|
|
const currentIndex = searchDetail
|
|
|
|
|
? uniqueResults.findIndex(
|
|
|
|
|
(result) => result.id === searchDetail.id,
|
|
|
|
|
)
|
|
|
|
|
: -1;
|
|
|
|
|
|
2024-09-13 06:07:35 +03:00
|
|
|
const newIndex =
|
2024-12-02 21:12:55 +03:00
|
|
|
currentIndex === -1
|
2024-09-13 06:07:35 +03:00
|
|
|
? uniqueResults.length - 1
|
2024-12-02 21:12:55 +03:00
|
|
|
: (currentIndex - 1 + uniqueResults.length) %
|
|
|
|
|
uniqueResults.length;
|
|
|
|
|
|
2024-09-13 06:07:35 +03:00
|
|
|
setSearchDetail(uniqueResults[newIndex]);
|
2024-12-02 21:12:55 +03:00
|
|
|
}
|
2024-09-13 06:07:35 +03:00
|
|
|
break;
|
2024-12-02 21:12:55 +03:00
|
|
|
|
2024-09-13 06:07:35 +03:00
|
|
|
case "ArrowRight":
|
2024-12-02 21:12:55 +03:00
|
|
|
if (uniqueResults.length > 0) {
|
|
|
|
|
const currentIndex = searchDetail
|
|
|
|
|
? uniqueResults.findIndex(
|
|
|
|
|
(result) => result.id === searchDetail.id,
|
|
|
|
|
)
|
|
|
|
|
: -1;
|
|
|
|
|
|
2024-09-13 06:07:35 +03:00
|
|
|
const newIndex =
|
2024-12-02 21:12:55 +03:00
|
|
|
currentIndex === -1
|
|
|
|
|
? 0
|
|
|
|
|
: (currentIndex + 1) % uniqueResults.length;
|
|
|
|
|
|
2024-09-13 06:07:35 +03:00
|
|
|
setSearchDetail(uniqueResults[newIndex]);
|
2024-12-02 21:12:55 +03:00
|
|
|
}
|
2024-09-13 06:07:35 +03:00
|
|
|
break;
|
2024-10-02 17:32:12 +03:00
|
|
|
case "PageDown":
|
|
|
|
|
contentRef.current?.scrollBy({
|
|
|
|
|
top: contentRef.current.clientHeight / 2,
|
|
|
|
|
behavior: "smooth",
|
|
|
|
|
});
|
|
|
|
|
break;
|
|
|
|
|
case "PageUp":
|
|
|
|
|
contentRef.current?.scrollBy({
|
|
|
|
|
top: -contentRef.current.clientHeight / 2,
|
|
|
|
|
behavior: "smooth",
|
|
|
|
|
});
|
|
|
|
|
break;
|
2024-09-13 06:07:35 +03:00
|
|
|
}
|
|
|
|
|
},
|
2024-12-02 21:12:55 +03:00
|
|
|
[uniqueResults, inputFocused, onSelectAllObjects, searchDetail],
|
2024-09-13 06:07:35 +03:00
|
|
|
);
|
|
|
|
|
|
2024-09-25 21:45:42 +03:00
|
|
|
useKeyboardListener(
|
2024-12-02 21:12:55 +03:00
|
|
|
["a", "ArrowLeft", "ArrowRight", "PageDown", "PageUp"],
|
2024-09-25 21:45:42 +03:00
|
|
|
onKeyboardShortcut,
|
|
|
|
|
!inputFocused,
|
|
|
|
|
);
|
2024-09-13 06:07:35 +03:00
|
|
|
|
|
|
|
|
// scroll into view
|
|
|
|
|
|
2024-12-02 21:12:55 +03:00
|
|
|
const [prevSearchDetail, setPrevSearchDetail] = useState<
|
|
|
|
|
SearchResult | undefined
|
|
|
|
|
>();
|
|
|
|
|
|
|
|
|
|
// keep track of previous ref to outline thumbnail when dialog closes
|
|
|
|
|
const prevSearchDetailRef = useRef<SearchResult | undefined>();
|
|
|
|
|
|
2024-09-13 06:07:35 +03:00
|
|
|
useEffect(() => {
|
2024-12-02 21:12:55 +03:00
|
|
|
if (searchDetail === undefined && prevSearchDetailRef.current) {
|
|
|
|
|
setPrevSearchDetail(prevSearchDetailRef.current);
|
2024-09-13 06:07:35 +03:00
|
|
|
}
|
2024-12-02 21:12:55 +03:00
|
|
|
prevSearchDetailRef.current = searchDetail;
|
|
|
|
|
}, [searchDetail]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (uniqueResults && itemRefs.current && prevSearchDetail) {
|
|
|
|
|
const selectedIndex = uniqueResults.findIndex(
|
|
|
|
|
(result) => result.id === prevSearchDetail.id,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const parent = itemRefs.current[selectedIndex];
|
|
|
|
|
|
|
|
|
|
if (selectedIndex !== -1 && parent) {
|
|
|
|
|
const target = parent.querySelector(".review-item-ring");
|
|
|
|
|
if (target) {
|
|
|
|
|
scrollIntoView(target, {
|
|
|
|
|
block: "center",
|
|
|
|
|
behavior: "smooth",
|
|
|
|
|
scrollMode: "if-needed",
|
|
|
|
|
});
|
|
|
|
|
target.classList.add(`outline-selected`);
|
|
|
|
|
target.classList.remove("outline-transparent");
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
target.classList.remove(`outline-selected`);
|
|
|
|
|
target.classList.add("outline-transparent");
|
|
|
|
|
}, 3000);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// we only want to scroll when the dialog closes
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [prevSearchDetail]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (uniqueResults && itemRefs.current && searchDetail) {
|
|
|
|
|
const selectedIndex = uniqueResults.findIndex(
|
|
|
|
|
(result) => result.id === searchDetail.id,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const parent = itemRefs.current[selectedIndex];
|
|
|
|
|
|
|
|
|
|
if (selectedIndex !== -1 && parent) {
|
|
|
|
|
scrollIntoView(parent, {
|
|
|
|
|
block: "center",
|
|
|
|
|
behavior: "smooth",
|
|
|
|
|
scrollMode: "if-needed",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// we only want to scroll when changing the detail pane
|
2024-09-14 16:42:56 +03:00
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2024-12-02 21:12:55 +03:00
|
|
|
}, [searchDetail]);
|
2024-09-14 16:42:56 +03:00
|
|
|
|
|
|
|
|
// observer for loading more
|
|
|
|
|
|
|
|
|
|
const observerTarget = useRef<HTMLDivElement>(null);
|
|
|
|
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const observer = new IntersectionObserver(
|
|
|
|
|
(entries) => {
|
|
|
|
|
if (entries[0].isIntersecting && hasMore && !isLoading) {
|
|
|
|
|
loadMore();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{ threshold: 1.0 },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (observerTarget.current) {
|
|
|
|
|
observer.observe(observerTarget.current);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
observerRef.current = observer;
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
if (observerRef.current) {
|
|
|
|
|
observerRef.current.disconnect();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, [hasMore, isLoading, loadMore]);
|
2024-09-13 06:07:35 +03:00
|
|
|
|
2024-06-23 23:58:00 +03:00
|
|
|
return (
|
|
|
|
|
<div className="flex size-full flex-col pt-2 md:py-2">
|
|
|
|
|
<Toaster closeButton={true} />
|
|
|
|
|
<SearchDetailDialog
|
|
|
|
|
search={searchDetail}
|
2024-10-15 16:24:47 +03:00
|
|
|
page={page}
|
2024-06-23 23:58:00 +03:00
|
|
|
setSearch={setSearchDetail}
|
2024-10-15 16:24:47 +03:00
|
|
|
setSearchPage={setPage}
|
2024-06-23 23:58:00 +03:00
|
|
|
setSimilarity={
|
|
|
|
|
searchDetail && (() => setSimilaritySearch(searchDetail))
|
|
|
|
|
}
|
2025-01-20 16:23:22 +03:00
|
|
|
setInputFocused={setInputFocused}
|
2024-06-23 23:58:00 +03:00
|
|
|
/>
|
|
|
|
|
|
2024-09-10 19:23:20 +03:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
2024-09-18 21:18:16 +03:00
|
|
|
"flex flex-col items-start space-y-2 pl-2 pr-2 md:mb-2 md:pl-3 lg:relative lg:h-10 lg:flex-row lg:items-center lg:space-y-0",
|
2024-09-10 19:23:20 +03:00
|
|
|
config?.semantic_search?.enabled
|
|
|
|
|
? "justify-between"
|
|
|
|
|
: "justify-center",
|
2024-09-11 20:32:45 +03:00
|
|
|
isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0",
|
2024-09-10 19:23:20 +03:00
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{config?.semantic_search?.enabled && (
|
2024-09-18 21:18:16 +03:00
|
|
|
<div className={cn("z-[41] w-full lg:absolute lg:top-0 lg:w-1/3")}>
|
|
|
|
|
<InputWithTags
|
2024-09-25 21:45:42 +03:00
|
|
|
inputFocused={inputFocused}
|
|
|
|
|
setInputFocused={setInputFocused}
|
2024-09-18 21:18:16 +03:00
|
|
|
filters={searchFilter ?? {}}
|
|
|
|
|
setFilters={setSearchFilter}
|
|
|
|
|
search={search}
|
|
|
|
|
setSearch={setSearch}
|
|
|
|
|
allSuggestions={suggestionsValues}
|
2024-09-06 22:26:32 +03:00
|
|
|
/>
|
2024-09-10 19:23:20 +03:00
|
|
|
</div>
|
|
|
|
|
)}
|
2024-06-23 23:58:00 +03:00
|
|
|
|
2024-09-10 19:23:20 +03:00
|
|
|
{hasExistingSearch && (
|
2024-09-23 15:39:20 +03:00
|
|
|
<ScrollArea className="w-full whitespace-nowrap lg:ml-[35%]">
|
2024-10-16 19:54:01 +03:00
|
|
|
<div className="flex flex-row gap-2">
|
2024-12-02 21:12:55 +03:00
|
|
|
{selectedObjects.length == 0 ? (
|
|
|
|
|
<>
|
|
|
|
|
<SearchFilterGroup
|
|
|
|
|
className={cn(
|
|
|
|
|
"w-full justify-between md:justify-start lg:justify-end",
|
|
|
|
|
)}
|
|
|
|
|
filter={searchFilter}
|
|
|
|
|
onUpdateFilter={onUpdateFilter}
|
|
|
|
|
/>
|
2025-03-16 18:36:20 +03:00
|
|
|
<ExploreSettings
|
2024-12-02 21:12:55 +03:00
|
|
|
columns={columns}
|
|
|
|
|
setColumns={setColumns}
|
|
|
|
|
defaultView={defaultView}
|
|
|
|
|
setDefaultView={setDefaultView}
|
|
|
|
|
filter={searchFilter}
|
|
|
|
|
onUpdateFilter={onUpdateFilter}
|
|
|
|
|
/>
|
|
|
|
|
<ScrollBar orientation="horizontal" className="h-0" />
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"scrollbar-container flex justify-center gap-2 overflow-x-auto",
|
|
|
|
|
"h-10 w-full justify-between md:justify-start lg:justify-end",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<SearchActionGroup
|
|
|
|
|
selectedObjects={selectedObjects}
|
|
|
|
|
setSelectedObjects={setSelectedObjects}
|
|
|
|
|
pullLatestData={refresh}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2024-09-23 15:39:20 +03:00
|
|
|
</div>
|
|
|
|
|
</ScrollArea>
|
2024-09-10 19:23:20 +03:00
|
|
|
)}
|
2024-06-23 23:58:00 +03:00
|
|
|
</div>
|
|
|
|
|
|
2024-10-02 17:32:12 +03:00
|
|
|
<div
|
|
|
|
|
ref={contentRef}
|
|
|
|
|
className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto"
|
|
|
|
|
>
|
2024-09-14 16:42:56 +03:00
|
|
|
{uniqueResults?.length == 0 && !isLoading && (
|
2024-06-23 23:58:00 +03:00
|
|
|
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
|
|
|
|
|
<LuSearchX className="size-16" />
|
2025-03-16 18:36:20 +03:00
|
|
|
{t("noTrackedObjects")}
|
2024-06-23 23:58:00 +03:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2024-11-02 04:52:00 +03:00
|
|
|
{((isLoading && uniqueResults?.length == 0) || // show on initial load
|
|
|
|
|
(isValidating && !isLoading)) && // or revalidation
|
|
|
|
|
(searchTerm || // or change of filter/search term
|
2024-09-14 23:08:46 +03:00
|
|
|
(searchFilter && Object.keys(searchFilter).length !== 0)) && (
|
2024-11-01 15:37:52 +03:00
|
|
|
<ActivityIndicator className="absolute left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-background/80 p-3 dark:bg-background/50" />
|
2024-09-14 16:42:56 +03:00
|
|
|
)}
|
2024-06-23 23:58:00 +03:00
|
|
|
|
2024-09-11 17:41:16 +03:00
|
|
|
{uniqueResults && (
|
2024-10-02 15:59:53 +03:00
|
|
|
<div className={gridClassName}>
|
2024-09-11 17:41:16 +03:00
|
|
|
{uniqueResults &&
|
2024-09-13 06:07:35 +03:00
|
|
|
uniqueResults.map((value, index) => {
|
2024-12-02 21:12:55 +03:00
|
|
|
const selected = selectedObjects.includes(value.id);
|
2024-09-11 17:41:16 +03:00
|
|
|
|
|
|
|
|
return (
|
2024-07-11 01:01:31 +03:00
|
|
|
<div
|
2024-09-11 17:41:16 +03:00
|
|
|
key={value.id}
|
2024-09-13 06:07:35 +03:00
|
|
|
ref={(item) => (itemRefs.current[index] = item)}
|
2024-09-11 17:41:16 +03:00
|
|
|
data-start={value.start_time}
|
2024-12-02 21:12:55 +03:00
|
|
|
className="relative flex flex-col rounded-lg"
|
2024-07-11 01:01:31 +03:00
|
|
|
>
|
2024-09-11 17:41:16 +03:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
2024-10-15 16:24:47 +03:00
|
|
|
"aspect-square w-full overflow-hidden rounded-t-lg border",
|
2024-09-11 17:41:16 +03:00
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<SearchThumbnail
|
2024-10-15 16:24:47 +03:00
|
|
|
searchResult={value}
|
2024-12-02 21:12:55 +03:00
|
|
|
onClick={(
|
|
|
|
|
value: SearchResult,
|
|
|
|
|
ctrl: boolean,
|
|
|
|
|
detail: boolean,
|
|
|
|
|
) => {
|
|
|
|
|
if (detail && selectedObjects.length == 0) {
|
|
|
|
|
setSearchDetail(value);
|
|
|
|
|
} else {
|
|
|
|
|
onSelectSearch(
|
|
|
|
|
value,
|
|
|
|
|
ctrl || selectedObjects.length > 0,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}}
|
2024-10-15 16:24:47 +03:00
|
|
|
/>
|
2024-10-17 18:21:20 +03:00
|
|
|
{(searchTerm ||
|
|
|
|
|
searchFilter?.search_type?.includes("similarity")) && (
|
|
|
|
|
<div className={cn("absolute right-2 top-2 z-40")}>
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger>
|
|
|
|
|
<Chip
|
2025-04-23 01:21:09 +03:00
|
|
|
className={`flex select-none items-center justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs text-white smart-capitalize`}
|
2024-10-17 18:21:20 +03:00
|
|
|
>
|
|
|
|
|
{value.search_source == "thumbnail" ? (
|
|
|
|
|
<LuImage className="size-3" />
|
|
|
|
|
) : (
|
|
|
|
|
<LuText className="size-3" />
|
|
|
|
|
)}
|
|
|
|
|
</Chip>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipPortal>
|
|
|
|
|
<TooltipContent>
|
2025-05-06 05:42:24 +03:00
|
|
|
<Trans
|
|
|
|
|
ns="views/explore"
|
|
|
|
|
values={{
|
|
|
|
|
type: t(
|
|
|
|
|
"filter.searchType." +
|
|
|
|
|
value.search_source,
|
|
|
|
|
{ ns: "views/search" },
|
|
|
|
|
),
|
|
|
|
|
confidence: zScoreToConfidence(
|
|
|
|
|
value.search_distance,
|
|
|
|
|
),
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
searchResult.tooltip
|
|
|
|
|
</Trans>
|
2024-10-17 18:21:20 +03:00
|
|
|
</TooltipContent>
|
|
|
|
|
</TooltipPortal>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2024-10-15 16:24:47 +03:00
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
className={`review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${selected ? `shadow-selected outline-selected` : "outline-transparent duration-500"}`}
|
|
|
|
|
/>
|
2024-10-17 14:30:52 +03:00
|
|
|
<div className="flex w-full grow items-center justify-between rounded-b-lg border border-t-0 bg-card p-3 text-card-foreground">
|
2024-10-15 16:24:47 +03:00
|
|
|
<SearchThumbnailFooter
|
2024-09-11 17:41:16 +03:00
|
|
|
searchResult={value}
|
2024-10-17 14:30:52 +03:00
|
|
|
columns={columns}
|
2024-10-14 04:36:49 +03:00
|
|
|
findSimilar={() => {
|
|
|
|
|
if (config?.semantic_search.enabled) {
|
|
|
|
|
setSimilaritySearch(value);
|
|
|
|
|
}
|
|
|
|
|
}}
|
2024-10-15 16:24:47 +03:00
|
|
|
refreshResults={refresh}
|
|
|
|
|
showObjectLifecycle={() =>
|
2025-03-16 18:36:20 +03:00
|
|
|
onSelectSearch(value, false, "object_lifecycle")
|
2024-10-15 16:24:47 +03:00
|
|
|
}
|
2024-11-12 15:37:25 +03:00
|
|
|
showSnapshot={() =>
|
2024-12-02 21:12:55 +03:00
|
|
|
onSelectSearch(value, false, "snapshot")
|
2024-11-12 15:37:25 +03:00
|
|
|
}
|
2025-07-07 17:03:57 +03:00
|
|
|
addTrigger={() => {
|
|
|
|
|
if (
|
|
|
|
|
config?.semantic_search.enabled &&
|
|
|
|
|
value.data.type == "object"
|
|
|
|
|
) {
|
|
|
|
|
navigate(
|
|
|
|
|
`/settings?page=triggers&camera=${value.camera}&event_id=${value.id}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}}
|
2024-09-11 17:41:16 +03:00
|
|
|
/>
|
|
|
|
|
</div>
|
2024-06-23 23:58:00 +03:00
|
|
|
</div>
|
2024-09-11 17:41:16 +03:00
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2024-09-14 16:42:56 +03:00
|
|
|
{uniqueResults && uniqueResults.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<div ref={observerTarget} className="h-10 w-full" />
|
|
|
|
|
<div className="flex h-12 w-full justify-center">
|
|
|
|
|
{hasMore && isLoading && <ActivityIndicator />}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
2024-09-11 17:41:16 +03:00
|
|
|
)}
|
2024-06-23 23:58:00 +03:00
|
|
|
</div>
|
2024-09-14 23:08:46 +03:00
|
|
|
{searchFilter &&
|
|
|
|
|
Object.keys(searchFilter).length === 0 &&
|
2024-10-16 19:54:01 +03:00
|
|
|
!searchTerm &&
|
|
|
|
|
defaultView == "summary" && (
|
2024-09-14 23:08:46 +03:00
|
|
|
<div className="scrollbar-container flex size-full flex-col overflow-y-auto">
|
2024-09-24 21:34:29 +03:00
|
|
|
<ExploreView
|
|
|
|
|
searchDetail={searchDetail}
|
|
|
|
|
setSearchDetail={setSearchDetail}
|
2024-10-22 17:01:01 +03:00
|
|
|
setSimilaritySearch={setSimilaritySearch}
|
|
|
|
|
onSelectSearch={onSelectSearch}
|
2024-09-24 21:34:29 +03:00
|
|
|
/>
|
2024-09-14 23:08:46 +03:00
|
|
|
</div>
|
|
|
|
|
)}
|
2024-06-23 23:58:00 +03:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|