import SearchThumbnail from "@/components/card/SearchThumbnail"; import SearchFilterGroup from "@/components/filter/SearchFilterGroup"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import SearchDetailDialog, { SearchTab, } from "@/components/overlay/detail/SearchDetailDialog"; import { Toaster } from "@/components/ui/sonner"; import { cn } from "@/lib/utils"; import { FrigateConfig } from "@/types/frigateConfig"; import { SearchFilter, SearchResult, SearchSource } from "@/types/search"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isMobileOnly } from "react-device-detect"; import { LuImage, LuSearchX, LuText } from "react-icons/lu"; import useSWR from "swr"; import ExploreView from "../explore/ExploreView"; import useKeyboardListener, { KeyModifiers, } from "@/hooks/use-keyboard-listener"; import scrollIntoView from "scroll-into-view-if-needed"; import InputWithTags from "@/components/input/InputWithTags"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { isEqual } from "lodash"; import { formatDateToLocaleString } from "@/utils/dateUtil"; import SearchThumbnailFooter from "@/components/card/SearchThumbnailFooter"; import SearchSettings from "@/components/settings/SearchSettings"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import Chip from "@/components/indicators/Chip"; import { TooltipPortal } from "@radix-ui/react-tooltip"; type SearchViewProps = { search: string; searchTerm: string; searchFilter?: SearchFilter; searchResults?: SearchResult[]; isLoading: boolean; hasMore: boolean; columns: number; defaultView?: string; setSearch: (search: string) => void; setSimilaritySearch: (search: SearchResult) => void; setSearchFilter: (filter: SearchFilter) => void; onUpdateFilter: (filter: SearchFilter) => void; loadMore: () => void; refresh: () => void; setColumns: (columns: number) => void; setDefaultView: (name: string) => void; }; export default function SearchView({ search, searchTerm, searchFilter, searchResults, isLoading, hasMore, columns, defaultView = "summary", setSearch, setSimilaritySearch, setSearchFilter, onUpdateFilter, loadMore, refresh, setColumns, setDefaultView, }: SearchViewProps) { const contentRef = useRef(null); const { data: config } = useSWR("config", { revalidateOnFocus: false, }); // grid const gridClassName = cn( "grid w-full gap-2 px-1 gap-2 lg:gap-4 md:mx-2", isMobileOnly && "grid-cols-2", { "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, }, ); // suggestions values const allLabels = useMemo(() => { if (!config) { return []; } const labels = new Set(); const cameras = searchFilter?.cameras || Object.keys(config.cameras); cameras.forEach((camera) => { if (camera == "birdseye") { return; } const cameraConfig = config.cameras[camera]; cameraConfig.objects.track.forEach((label) => { labels.add(label); }); if (cameraConfig.audio.enabled_in_config) { cameraConfig.audio.listen.forEach((label) => { labels.add(label); }); } }); return [...labels].sort(); }, [config, searchFilter]); const { data: allSubLabels } = useSWR("sub_labels"); const allZones = useMemo(() => { if (!config) { return []; } const zones = new Set(); const cameras = searchFilter?.cameras || Object.keys(config.cameras); cameras.forEach((camera) => { if (camera == "birdseye") { return; } const cameraConfig = config.cameras[camera]; 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[], time_range: config?.ui.time_format == "24hour" ? ["00:00-23:59"] : ["12:00AM-11:59PM"], before: [formatDateToLocaleString()], after: [formatDateToLocaleString(-5)], min_score: ["50"], max_score: ["100"], }), [config, allLabels, allZones, allSubLabels], ); // remove duplicate event ids const uniqueResults = useMemo(() => { return searchResults?.filter( (value, index, self) => index === self.findIndex((v) => v.id === value.id), ); }, [searchResults]); // detail const [searchDetail, setSearchDetail] = useState(); const [page, setPage] = useState("details"); // search interaction const [selectedIndex, setSelectedIndex] = useState(null); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const onSelectSearch = useCallback( (item: SearchResult, index: number, page: SearchTab = "details") => { setPage(page); setSearchDetail(item); setSelectedIndex(index); }, [], ); useEffect(() => { setSelectedIndex(0); }, [searchTerm, searchFilter]); // 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); } }; // 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]); const hasExistingSearch = useMemo( () => searchResults != undefined || searchFilter != undefined, [searchResults, searchFilter], ); // keyboard listener const [inputFocused, setInputFocused] = useState(false); const onKeyboardShortcut = useCallback( (key: string | null, modifiers: KeyModifiers) => { if (!modifiers.down || !uniqueResults || inputFocused) { return; } switch (key) { case "ArrowLeft": setSelectedIndex((prevIndex) => { const newIndex = prevIndex === null ? uniqueResults.length - 1 : (prevIndex - 1 + uniqueResults.length) % uniqueResults.length; setSearchDetail(uniqueResults[newIndex]); return newIndex; }); break; case "ArrowRight": setSelectedIndex((prevIndex) => { const newIndex = prevIndex === null ? 0 : (prevIndex + 1) % uniqueResults.length; setSearchDetail(uniqueResults[newIndex]); return newIndex; }); break; 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; } }, [uniqueResults, inputFocused], ); useKeyboardListener( ["ArrowLeft", "ArrowRight", "PageDown", "PageUp"], onKeyboardShortcut, !inputFocused, ); // scroll into view useEffect(() => { if ( selectedIndex !== null && uniqueResults && itemRefs.current?.[selectedIndex] ) { scrollIntoView(itemRefs.current[selectedIndex], { block: "center", behavior: "smooth", scrollMode: "if-needed", }); } // we only want to scroll when the index changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedIndex]); // observer for loading more const observerTarget = useRef(null); const observerRef = useRef(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]); return (
setSimilaritySearch(searchDetail)) } />
{config?.semantic_search?.enabled && (
)} {hasExistingSearch && (
)}
{uniqueResults?.length == 0 && !isLoading && (
No Tracked Objects Found
)} {uniqueResults?.length == 0 && isLoading && (searchTerm || (searchFilter && Object.keys(searchFilter).length !== 0)) && ( )} {uniqueResults && (
{uniqueResults && uniqueResults.map((value, index) => { const selected = selectedIndex === index; return (
(itemRefs.current[index] = item)} data-start={value.start_time} className="review-item relative flex flex-col rounded-lg" >
onSelectSearch(value, index)} /> {(searchTerm || searchFilter?.search_type?.includes("similarity")) && (
{value.search_source == "thumbnail" ? ( ) : ( )} Matched {value.search_source} at{" "} {zScoreToConfidence(value.search_distance)}%
)}
{ if (config?.semantic_search.enabled) { setSimilaritySearch(value); } }} refreshResults={refresh} showObjectLifecycle={() => onSelectSearch(value, index, "object lifecycle") } />
); })}
)} {uniqueResults && uniqueResults.length > 0 && ( <>
{hasMore && isLoading && }
)}
{searchFilter && Object.keys(searchFilter).length === 0 && !searchTerm && defaultView == "summary" && (
)}
); }