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"; import SearchActionGroup from "@/components/filter/SearchActionGroup"; type SearchViewProps = { search: string; searchTerm: string; searchFilter?: SearchFilter; searchResults?: SearchResult[]; isLoading: boolean; isValidating: 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, isValidating, 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, "sm:grid-cols-7": columns === 7, "sm:grid-cols-8": columns === 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 { data: allIdentifiers } = useSWR("identifiers"); 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"], min_speed: ["1"], max_speed: ["150"], identifier: allIdentifiers, has_clip: ["yes", "no"], has_snapshot: ["yes", "no"], ...(config?.plus?.enabled && searchFilter?.has_snapshot && { is_submitted: ["yes", "no"] }), }), [config, allLabels, allZones, allSubLabels, allIdentifiers, searchFilter], ); // 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 [selectedObjects, setSelectedObjects] = useState([]); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const onSelectSearch = useCallback( (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); } }, [selectedObjects], ); const onSelectAllObjects = useCallback(() => { if (!uniqueResults || uniqueResults.length == 0) { return; } if (selectedObjects.length < uniqueResults.length) { setSelectedObjects(uniqueResults.map((value) => value.id)); } else { setSelectedObjects([]); } }, [uniqueResults, selectedObjects]); useEffect(() => { setSelectedObjects([]); // unselect items when search term or filter changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [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 "a": if (modifiers.ctrl) { onSelectAllObjects(); } break; case "ArrowLeft": if (uniqueResults.length > 0) { const currentIndex = searchDetail ? uniqueResults.findIndex( (result) => result.id === searchDetail.id, ) : -1; const newIndex = currentIndex === -1 ? uniqueResults.length - 1 : (currentIndex - 1 + uniqueResults.length) % uniqueResults.length; setSearchDetail(uniqueResults[newIndex]); } break; case "ArrowRight": if (uniqueResults.length > 0) { const currentIndex = searchDetail ? uniqueResults.findIndex( (result) => result.id === searchDetail.id, ) : -1; const newIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % uniqueResults.length; setSearchDetail(uniqueResults[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, onSelectAllObjects, searchDetail], ); useKeyboardListener( ["a", "ArrowLeft", "ArrowRight", "PageDown", "PageUp"], onKeyboardShortcut, !inputFocused, ); // scroll into view const [prevSearchDetail, setPrevSearchDetail] = useState< SearchResult | undefined >(); // keep track of previous ref to outline thumbnail when dialog closes const prevSearchDetailRef = useRef(); useEffect(() => { if (searchDetail === undefined && prevSearchDetailRef.current) { setPrevSearchDetail(prevSearchDetailRef.current); } 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 // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchDetail]); // 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)) } setInputFocused={setInputFocused} />
{config?.semantic_search?.enabled && (
)} {hasExistingSearch && (
{selectedObjects.length == 0 ? ( <> ) : (
)}
)}
{uniqueResults?.length == 0 && !isLoading && (
No Tracked Objects Found
)} {((isLoading && uniqueResults?.length == 0) || // show on initial load (isValidating && !isLoading)) && // or revalidation (searchTerm || // or change of filter/search term (searchFilter && Object.keys(searchFilter).length !== 0)) && ( )} {uniqueResults && (
{uniqueResults && uniqueResults.map((value, index) => { const selected = selectedObjects.includes(value.id); return (
(itemRefs.current[index] = item)} data-start={value.start_time} className="relative flex flex-col rounded-lg" >
{ if (detail && selectedObjects.length == 0) { setSearchDetail(value); } else { onSelectSearch( value, ctrl || selectedObjects.length > 0, ); } }} /> {(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, false, "object lifecycle") } showSnapshot={() => onSelectSearch(value, false, "snapshot") } />
); })}
)} {uniqueResults && uniqueResults.length > 0 && ( <>
{hasMore && isLoading && }
)}
{searchFilter && Object.keys(searchFilter).length === 0 && !searchTerm && defaultView == "summary" && (
)}
); }