swr for infinite loading

This commit is contained in:
Josh Hawkins 2024-09-14 07:00:05 -05:00
parent 5aee70ac7a
commit a944e7f21a
3 changed files with 142 additions and 17 deletions

View File

@ -3,12 +3,15 @@ import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state"; import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { RecordingStartingPoint } from "@/types/record"; import { RecordingStartingPoint } from "@/types/record";
import { SearchFilter, SearchResult } from "@/types/search"; import { SearchFilter, SearchQuery, SearchResult } from "@/types/search";
import { TimeRange } from "@/types/timeline"; import { TimeRange } from "@/types/timeline";
import { RecordingView } from "@/views/recording/RecordingView"; import { RecordingView } from "@/views/recording/RecordingView";
import SearchView from "@/views/search/SearchView"; import SearchView from "@/views/search/SearchView";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
const API_LIMIT = 25;
export default function Explore() { export default function Explore() {
const { data: config } = useSWR<FrigateConfig>("config", { const { data: config } = useSWR<FrigateConfig>("config", {
@ -61,7 +64,7 @@ export default function Explore() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [search]); }, [search]);
const searchQuery = useMemo(() => { const searchQuery: SearchQuery = useMemo(() => {
if (similaritySearch) { if (similaritySearch) {
return [ return [
"events/search", "events/search",
@ -107,7 +110,7 @@ export default function Explore() {
before: searchSearchParams["before"], before: searchSearchParams["before"],
after: searchSearchParams["after"], after: searchSearchParams["after"],
search_type: searchSearchParams["search_type"], search_type: searchSearchParams["search_type"],
limit: Object.keys(searchSearchParams).length == 0 ? 20 : null, limit: Object.keys(searchSearchParams).length == 0 ? 20 : undefined,
in_progress: 0, in_progress: 0,
include_thumbnails: 0, include_thumbnails: 0,
}, },
@ -117,8 +120,66 @@ export default function Explore() {
return null; return null;
}, [searchTerm, searchSearchParams, similaritySearch]); }, [searchTerm, searchSearchParams, similaritySearch]);
const { data: searchResults, isLoading } = // paging
useSWR<SearchResult[]>(searchQuery);
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<SearchResult[]>(
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<TimeRange>(() => { const previewTimeRange = useMemo<TimeRange>(() => {
if (!searchResults) { if (!searchResults) {
@ -212,11 +273,13 @@ export default function Explore() {
searchTerm={searchTerm} searchTerm={searchTerm}
searchFilter={searchFilter} searchFilter={searchFilter}
searchResults={searchResults} searchResults={searchResults}
isLoading={isLoading} isLoading={(isLoadingInitialData || isLoadingMore) ?? true}
setSearch={setSearch} setSearch={setSearch}
setSimilaritySearch={(search) => setSearch(`similarity:${search.id}`)} setSimilaritySearch={(search) => setSearch(`similarity:${search.id}`)}
onUpdateFilter={setSearchFilter} onUpdateFilter={setSearchFilter}
onOpenSearch={onOpenSearch} onOpenSearch={onOpenSearch}
loadMore={loadMore}
hasMore={!isReachingEnd}
/> />
); );
} }

View File

@ -38,3 +38,20 @@ export type SearchFilter = {
search_type?: SearchSource[]; search_type?: SearchSource[];
event_id?: string; 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;

View File

@ -33,6 +33,8 @@ type SearchViewProps = {
setSimilaritySearch: (search: SearchResult) => void; setSimilaritySearch: (search: SearchResult) => void;
onUpdateFilter: (filter: SearchFilter) => void; onUpdateFilter: (filter: SearchFilter) => void;
onOpenSearch: (item: SearchResult) => void; onOpenSearch: (item: SearchResult) => void;
loadMore: () => void;
hasMore: boolean;
}; };
export default function SearchView({ export default function SearchView({
search, search,
@ -43,6 +45,8 @@ export default function SearchView({
setSearch, setSearch,
setSimilaritySearch, setSimilaritySearch,
onUpdateFilter, onUpdateFilter,
loadMore,
hasMore,
}: SearchViewProps) { }: SearchViewProps) {
const { data: config } = useSWR<FrigateConfig>("config", { const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false, revalidateOnFocus: false,
@ -143,7 +147,37 @@ export default function SearchView({
scrollMode: "if-needed", 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<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver | null>(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 ( return (
<div className="flex size-full flex-col pt-2 md:py-2"> <div className="flex size-full flex-col pt-2 md:py-2">
@ -199,20 +233,23 @@ export default function SearchView({
)} )}
</div> </div>
<div className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4"> <div className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto">
{searchTerm.length > 0 && searchResults?.length == 0 && ( {uniqueResults?.length == 0 && !isLoading && (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center"> <div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuSearchX className="size-16" /> <LuSearchX className="size-16" />
No Tracked Objects Found No Tracked Objects Found
</div> </div>
)} )}
{isLoading && ( {uniqueResults?.length == 0 &&
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" /> isLoading &&
)} searchFilter &&
Object.keys(searchFilter).length !== 0 && (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
)}
{uniqueResults && ( {uniqueResults && (
<div className="mt-2 grid w-full gap-2 px-1 sm:grid-cols-2 md:mx-2 md:grid-cols-4 md:gap-4 3xl:grid-cols-6"> <div className="grid w-full gap-2 px-1 sm:grid-cols-2 md:mx-2 md:grid-cols-4 md:gap-4 3xl:grid-cols-6">
{uniqueResults && {uniqueResults &&
uniqueResults.map((value, index) => { uniqueResults.map((value, index) => {
const selected = selectedIndex === index; const selected = selectedIndex === index;
@ -273,12 +310,20 @@ export default function SearchView({
})} })}
</div> </div>
)} )}
{!uniqueResults && !isLoading && ( {uniqueResults && uniqueResults.length > 0 && (
<div className="scrollbar-container flex size-full flex-col overflow-y-auto"> <>
<ExploreView onSelectSearch={onSelectSearch} /> <div ref={observerTarget} className="h-10 w-full" />
</div> <div className="flex h-12 w-full justify-center">
{hasMore && isLoading && <ActivityIndicator />}
</div>
</>
)} )}
</div> </div>
{searchFilter && Object.keys(searchFilter).length === 0 && (
<div className="scrollbar-container flex size-full flex-col overflow-y-auto">
<ExploreView onSelectSearch={onSelectSearch} />
</div>
)}
</div> </div>
); );
} }