diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index f68bb5068..4c933171a 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -3,12 +3,15 @@ import { useCameraPreviews } from "@/hooks/use-camera-previews"; import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state"; import { FrigateConfig } from "@/types/frigateConfig"; import { RecordingStartingPoint } from "@/types/record"; -import { SearchFilter, SearchResult } from "@/types/search"; +import { SearchFilter, SearchQuery, SearchResult } from "@/types/search"; import { TimeRange } from "@/types/timeline"; import { RecordingView } from "@/views/recording/RecordingView"; import SearchView from "@/views/search/SearchView"; import { useCallback, useEffect, useMemo, useState } from "react"; import useSWR from "swr"; +import useSWRInfinite from "swr/infinite"; + +const API_LIMIT = 25; export default function Explore() { const { data: config } = useSWR("config", { @@ -61,7 +64,7 @@ export default function Explore() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [search]); - const searchQuery = useMemo(() => { + const searchQuery: SearchQuery = useMemo(() => { if (similaritySearch) { return [ "events/search", @@ -107,7 +110,7 @@ export default function Explore() { before: searchSearchParams["before"], after: searchSearchParams["after"], search_type: searchSearchParams["search_type"], - limit: Object.keys(searchSearchParams).length == 0 ? 20 : null, + limit: Object.keys(searchSearchParams).length == 0 ? 20 : undefined, in_progress: 0, include_thumbnails: 0, }, @@ -117,8 +120,66 @@ export default function Explore() { return null; }, [searchTerm, searchSearchParams, similaritySearch]); - const { data: searchResults, isLoading } = - useSWR(searchQuery); + // paging + + const getKey = ( + pageIndex: number, + previousPageData: SearchResult[] | null, + ): SearchQuery => { + if (previousPageData && !previousPageData.length) return null; // reached the end + if (!searchQuery) return null; + + const [url, params] = searchQuery; + + // If it's not the first page, use the last item's start_time as the 'before' parameter + if (pageIndex > 0 && previousPageData) { + const lastDate = previousPageData[previousPageData.length - 1].start_time; + return [ + url, + { ...params, before: lastDate.toString(), limit: API_LIMIT }, + ]; + } + + // For the first page, use the original params + return [url, { ...params, limit: API_LIMIT }]; + }; + + const { data, size, setSize, isValidating } = useSWRInfinite( + getKey, + { + revalidateFirstPage: false, + revalidateAll: false, + }, + ); + + const searchResults = useMemo( + () => (data ? ([] as SearchResult[]).concat(...data) : []), + [data], + ); + const isLoadingInitialData = !data && !isValidating; + const isLoadingMore = + isLoadingInitialData || + (size > 0 && data && typeof data[size - 1] === "undefined"); + const isEmpty = data?.[0]?.length === 0; + const isReachingEnd = + isEmpty || (data && data[data.length - 1]?.length < API_LIMIT); + + const loadMore = useCallback(() => { + if (!isReachingEnd && !isLoadingMore) { + if (searchQuery) { + const [url] = searchQuery; + + // for chroma, only load 100 results for description and similarity + if (url === "events/search" && searchResults.length >= 100) { + return; + } + } + + setSize(size + 1); + } + }, [isReachingEnd, isLoadingMore, setSize, size, searchResults, searchQuery]); + + // previews const previewTimeRange = useMemo(() => { if (!searchResults) { @@ -212,11 +273,13 @@ export default function Explore() { searchTerm={searchTerm} searchFilter={searchFilter} searchResults={searchResults} - isLoading={isLoading} + isLoading={(isLoadingInitialData || isLoadingMore) ?? true} setSearch={setSearch} setSimilaritySearch={(search) => setSearch(`similarity:${search.id}`)} onUpdateFilter={setSearchFilter} onOpenSearch={onOpenSearch} + loadMore={loadMore} + hasMore={!isReachingEnd} /> ); } diff --git a/web/src/types/search.ts b/web/src/types/search.ts index 7cc7e3e91..762da2c82 100644 --- a/web/src/types/search.ts +++ b/web/src/types/search.ts @@ -38,3 +38,20 @@ export type SearchFilter = { search_type?: SearchSource[]; event_id?: string; }; + +export type SearchQueryParams = { + cameras?: string[]; + labels?: string[]; + sub_labels?: string[]; + zones?: string[]; + before?: string; + after?: string; + search_type?: string; + limit?: number; + in_progress?: number; + include_thumbnails?: number; + query?: string; + page?: number; +}; + +export type SearchQuery = [string, SearchQueryParams] | null; \ No newline at end of file diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index 3b6fb49e3..42c8c0833 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -33,6 +33,8 @@ type SearchViewProps = { setSimilaritySearch: (search: SearchResult) => void; onUpdateFilter: (filter: SearchFilter) => void; onOpenSearch: (item: SearchResult) => void; + loadMore: () => void; + hasMore: boolean; }; export default function SearchView({ search, @@ -43,6 +45,8 @@ export default function SearchView({ setSearch, setSimilaritySearch, onUpdateFilter, + loadMore, + hasMore, }: SearchViewProps) { const { data: config } = useSWR("config", { revalidateOnFocus: false, @@ -143,7 +147,37 @@ export default function SearchView({ scrollMode: "if-needed", }); } - }, [selectedIndex, uniqueResults]); + // 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 (
@@ -199,20 +233,23 @@ export default function SearchView({ )}
-
- {searchTerm.length > 0 && searchResults?.length == 0 && ( +
+ {uniqueResults?.length == 0 && !isLoading && (
No Tracked Objects Found
)} - {isLoading && ( - - )} + {uniqueResults?.length == 0 && + isLoading && + searchFilter && + Object.keys(searchFilter).length !== 0 && ( + + )} {uniqueResults && ( -
+
{uniqueResults && uniqueResults.map((value, index) => { const selected = selectedIndex === index; @@ -273,12 +310,20 @@ export default function SearchView({ })}
)} - {!uniqueResults && !isLoading && ( -
- -
+ {uniqueResults && uniqueResults.length > 0 && ( + <> +
+
+ {hasMore && isLoading && } +
+ )}
+ {searchFilter && Object.keys(searchFilter).length === 0 && ( +
+ +
+ )}
); }