mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-14 15:15:22 +03:00
swr for infinite loading
This commit is contained in:
parent
5aee70ac7a
commit
a944e7f21a
@ -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<FrigateConfig>("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<SearchResult[]>(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<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>(() => {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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<FrigateConfig>("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<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 (
|
||||
<div className="flex size-full flex-col pt-2 md:py-2">
|
||||
@ -199,20 +233,23 @@ export default function SearchView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4">
|
||||
{searchTerm.length > 0 && searchResults?.length == 0 && (
|
||||
<div className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto">
|
||||
{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">
|
||||
<LuSearchX className="size-16" />
|
||||
No Tracked Objects Found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
)}
|
||||
{uniqueResults?.length == 0 &&
|
||||
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 && (
|
||||
<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.map((value, index) => {
|
||||
const selected = selectedIndex === index;
|
||||
@ -273,12 +310,20 @@ export default function SearchView({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!uniqueResults && !isLoading && (
|
||||
<div className="scrollbar-container flex size-full flex-col overflow-y-auto">
|
||||
<ExploreView onSelectSearch={onSelectSearch} />
|
||||
</div>
|
||||
{uniqueResults && uniqueResults.length > 0 && (
|
||||
<>
|
||||
<div ref={observerTarget} className="h-10 w-full" />
|
||||
<div className="flex h-12 w-full justify-center">
|
||||
{hasMore && isLoading && <ActivityIndicator />}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user