import SearchThumbnail from "@/components/card/SearchThumbnail"; import SearchFilterGroup from "@/components/filter/SearchFilterGroup"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import Chip from "@/components/indicators/Chip"; import SearchDetailDialog from "@/components/overlay/detail/SearchDetailDialog"; import { Input } from "@/components/ui/input"; import { Toaster } from "@/components/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { FrigateConfig } from "@/types/frigateConfig"; import { SearchFilter, SearchResult } from "@/types/search"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isMobileOnly } from "react-device-detect"; import { LuImage, LuSearchX, LuText, LuXCircle } 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"; type SearchViewProps = { search: string; searchTerm: string; searchFilter?: SearchFilter; searchResults?: SearchResult[]; isLoading: boolean; setSearch: (search: string) => void; setSimilaritySearch: (search: SearchResult) => void; onUpdateFilter: (filter: SearchFilter) => void; onOpenSearch: (item: SearchResult) => void; loadMore: () => void; hasMore: boolean; }; export default function SearchView({ search, searchTerm, searchFilter, searchResults, isLoading, setSearch, setSimilaritySearch, onUpdateFilter, loadMore, hasMore, }: SearchViewProps) { const { data: config } = useSWR("config", { revalidateOnFocus: false, }); // 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(); // search interaction const [selectedIndex, setSelectedIndex] = useState(null); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const onSelectSearch = useCallback((item: SearchResult, index: number) => { setSearchDetail(item); setSelectedIndex(index); }, []); // confidence score - probably needs tweaking const zScoreToConfidence = (score: number, source: string) => { let midpoint, scale; if (source === "thumbnail") { midpoint = 2; scale = 0.5; } else { midpoint = 0.5; scale = 1.5; } // Sigmoid function: 1 / (1 + e^x) const confidence = 1 / (1 + Math.exp((score - midpoint) * scale)); return Math.round(confidence * 100); }; const hasExistingSearch = useMemo( () => searchResults != undefined || searchFilter != undefined, [searchResults, searchFilter], ); // keyboard listener const onKeyboardShortcut = useCallback( (key: string | null, modifiers: KeyModifiers) => { if (!modifiers.down || !uniqueResults) { 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; } }, [uniqueResults], ); useKeyboardListener(["ArrowLeft", "ArrowRight"], onKeyboardShortcut); // 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 && (
setSearch(e.target.value)} /> {search && ( setSearch("")} /> )}
)} {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 rounded-lg" >
setSimilaritySearch(value)} onClick={() => onSelectSearch(value, index)} /> {searchTerm && (
{value.search_source == "thumbnail" ? ( ) : ( )} {zScoreToConfidence( value.search_distance, value.search_source, )} % Matched {value.search_source} at{" "} {zScoreToConfidence( value.search_distance, value.search_source, )} %
)}
); })}
)} {uniqueResults && uniqueResults.length > 0 && ( <>
{hasMore && isLoading && }
)}
{searchFilter && Object.keys(searchFilter).length === 0 && !searchTerm && (
)}
); }