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 { 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, SearchSource } from "@/types/search"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isDesktop, isMobileOnly } from "react-device-detect"; import { LuColumns, 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 { TooltipPortal } from "@radix-ui/react-tooltip"; import { Slider } from "@/components/ui/slider"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { usePersistence } from "@/hooks/use-persistence"; type SearchViewProps = { search: string; searchTerm: string; searchFilter?: SearchFilter; searchResults?: SearchResult[]; isLoading: boolean; setSearch: (search: string) => void; setSimilaritySearch: (search: SearchResult) => void; setSearchFilter: (filter: SearchFilter) => void; onUpdateFilter: (filter: SearchFilter) => void; loadMore: () => void; hasMore: boolean; }; export default function SearchView({ search, searchTerm, searchFilter, searchResults, isLoading, setSearch, setSimilaritySearch, setSearchFilter, onUpdateFilter, loadMore, hasMore, }: SearchViewProps) { const { data: config } = useSWR("config", { revalidateOnFocus: false, }); // grid const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4); const effectiveColumnCount = useMemo(() => columnCount ?? 4, [columnCount]); const gridClassName = cn("grid w-full gap-2 px-1 gap-2 lg:gap-4 md:mx-2", { "sm:grid-cols-2": effectiveColumnCount <= 2, "sm:grid-cols-3": effectiveColumnCount === 3, "sm:grid-cols-4": effectiveColumnCount === 4, "sm:grid-cols-5": effectiveColumnCount === 5, "sm:grid-cols-6": effectiveColumnCount === 6, "sm:grid-cols-7": effectiveColumnCount === 7, "sm:grid-cols-8": effectiveColumnCount >= 8, }); // 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)], }), [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(); // search interaction const [selectedIndex, setSelectedIndex] = useState(null); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const onSelectSearch = useCallback((item: SearchResult, index: number) => { setSearchDetail(item); setSelectedIndex(index); }, []); // 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]); // 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 [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; } }, [uniqueResults, inputFocused], ); useKeyboardListener( ["ArrowLeft", "ArrowRight"], 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 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 && }
{isDesktop && columnCount && (
Adjust Grid Columns
Grid Columns
setColumnCount(value)} max={8} min={2} step={1} className="flex-grow" /> {effectiveColumnCount}
)} )}
{searchFilter && Object.keys(searchFilter).length === 0 && !searchTerm && (
)}
); }