blakeblackshear.frigate/web/src/pages/Explore.tsx

204 lines
6.3 KiB
TypeScript
Raw Normal View History

import { useApiFilterArgs } from "@/hooks/use-api-filter";
import { SearchFilter, SearchQuery, SearchResult } from "@/types/search";
import SearchView from "@/views/search/SearchView";
import { useCallback, useEffect, useMemo, useState } from "react";
import { TbExclamationCircle } from "react-icons/tb";
import useSWRInfinite from "swr/infinite";
const API_LIMIT = 25;
export default function Explore() {
// search field handler
const [search, setSearch] = useState("");
const [searchFilter, setSearchFilter, searchSearchParams] =
useApiFilterArgs<SearchFilter>();
const searchTerm = useMemo(
() => searchSearchParams?.["query"] || "",
[searchSearchParams],
);
const similaritySearch = useMemo(
() => searchSearchParams["search_type"] == "similarity",
[searchSearchParams],
);
useEffect(() => {
if (!searchTerm && !search) {
return;
}
// switch back to normal search when query is entered
setSearchFilter({
...searchFilter,
search_type:
similaritySearch && search ? undefined : searchFilter?.search_type,
event_id: similaritySearch && search ? undefined : searchFilter?.event_id,
query: search.length > 0 ? search : undefined,
});
// only update when search is updated
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search]);
const searchQuery: SearchQuery = useMemo(() => {
// no search parameters
if (searchSearchParams && Object.keys(searchSearchParams).length === 0) {
return null;
}
// parameters, but no search term and not similarity
if (
searchSearchParams &&
Object.keys(searchSearchParams).length !== 0 &&
!searchTerm &&
!similaritySearch
) {
return [
"events",
{
cameras: searchSearchParams["cameras"],
labels: searchSearchParams["labels"],
sub_labels: searchSearchParams["subLabels"],
zones: searchSearchParams["zones"],
before: searchSearchParams["before"],
after: searchSearchParams["after"],
search_type: searchSearchParams["search_type"],
limit:
Object.keys(searchSearchParams).length == 0 ? API_LIMIT : undefined,
in_progress: 0,
include_thumbnails: 0,
},
];
}
// parameters and search term
if (!similaritySearch) {
setSearch(searchTerm);
}
return [
"events/search",
{
query: similaritySearch ? undefined : searchTerm,
cameras: searchSearchParams["cameras"],
labels: searchSearchParams["labels"],
sub_labels: searchSearchParams["subLabels"],
zones: searchSearchParams["zones"],
before: searchSearchParams["before"],
after: searchSearchParams["after"],
search_type: searchSearchParams["search_type"],
event_id: searchSearchParams["event_id"],
include_thumbnails: 0,
},
];
}, [searchTerm, searchSearchParams, similaritySearch]);
// paging
// usually slow only on first run while downloading models
const [isSlowLoading, setIsSlowLoading] = useState(false);
const getKey = (
pageIndex: number,
previousPageData: SearchResult[] | null,
): SearchQuery => {
if (isSlowLoading && !similaritySearch) return null;
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: true,
revalidateAll: false,
onLoadingSlow: () => {
if (!similaritySearch) {
setIsSlowLoading(true);
}
},
loadingTimeout: 10000,
},
);
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]);
return (
<>
{isSlowLoading && !similaritySearch ? (
<div className="absolute inset-0 left-1/2 top-1/2 flex h-96 w-96 -translate-x-1/2 -translate-y-1/2">
<div className="flex flex-col items-center justify-center rounded-lg bg-background/50 p-5">
<p className="my-5 text-lg">Search Unavailable</p>
<TbExclamationCircle className="mb-3 size-10" />
<p className="max-w-96 text-center">
If this is your first time using Search, be patient while Frigate
downloads the necessary embeddings models. Check Frigate logs.
</p>
</div>
</div>
) : (
<SearchView
search={search}
searchTerm={searchTerm}
searchFilter={searchFilter}
searchResults={searchResults}
isLoading={(isLoadingInitialData || isLoadingMore) ?? true}
setSearch={setSearch}
setSimilaritySearch={(search) => {
setSearchFilter({
...searchFilter,
search_type: ["similarity"],
event_id: search.id,
});
}}
setSearchFilter={setSearchFilter}
onUpdateFilter={setSearchFilter}
loadMore={loadMore}
hasMore={!isReachingEnd}
/>
)}
</>
);
}