Explore pane infinite loading (#13738)

* swr for infinite loading

* search detail language change

* drawer padding

* spacing

* center calendar

* padding

* catch error

* use limit const
This commit is contained in:
Josh Hawkins 2024-09-14 08:42:56 -05:00 committed by GitHub
parent 088a0fb4a5
commit 2a66923524
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 162 additions and 36 deletions

View File

@ -90,7 +90,7 @@ export function CamerasFilterButton({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>
)} )}
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden p-4"> <div className="scrollbar-container flex h-auto max-h-[80dvh] flex-col gap-2 overflow-y-auto overflow-x-hidden p-4">
<FilterSwitch <FilterSwitch
isChecked={allCamerasSelected} isChecked={allCamerasSelected}
label="All Cameras" label="All Cameras"
@ -106,12 +106,12 @@ export function CamerasFilterButton({
/> />
{groups.length > 0 && ( {groups.length > 0 && (
<> <>
<DropdownMenuSeparator className="mt-2" /> <DropdownMenuSeparator />
{groups.map(([name, conf]) => { {groups.map(([name, conf]) => {
return ( return (
<div <div
key={name} key={name}
className="w-full cursor-pointer rounded-lg px-2 py-1.5 text-sm capitalize text-primary hover:bg-muted" className="w-full cursor-pointer rounded-lg px-2 py-0.5 text-sm capitalize text-primary hover:bg-muted"
onClick={() => setCurrentCameras([...conf.cameras])} onClick={() => setCurrentCameras([...conf.cameras])}
> >
{name} {name}
@ -120,7 +120,7 @@ export function CamerasFilterButton({
})} })}
</> </>
)} )}
<DropdownMenuSeparator className="my-2" /> <DropdownMenuSeparator />
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
{allCameras.map((item) => ( {allCameras.map((item) => (
<FilterSwitch <FilterSwitch
@ -158,7 +158,7 @@ export function CamerasFilterButton({
))} ))}
</div> </div>
</div> </div>
<DropdownMenuSeparator className="my-2" /> <DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2"> <div className="flex items-center justify-evenly p-2">
<Button <Button
variant="select" variant="select"

View File

@ -5,7 +5,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { DropdownMenuSeparator } from "../ui/dropdown-menu"; import { DropdownMenuSeparator } from "../ui/dropdown-menu";
import { getEndOfDayTimestamp } from "@/utils/dateUtil"; import { getEndOfDayTimestamp } from "@/utils/dateUtil";
import { isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
@ -309,7 +309,7 @@ function GeneralFilterButton({
}} }}
> >
<DrawerTrigger asChild>{trigger}</DrawerTrigger> <DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden"> <DrawerContent className="max-h-[75dvh] overflow-hidden p-4">
{content} {content}
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
@ -503,7 +503,7 @@ function ZoneFilterButton({
}} }}
> >
<DrawerTrigger asChild>{trigger}</DrawerTrigger> <DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden"> <DrawerContent className="max-h-[75dvh] overflow-hidden p-4">
{content} {content}
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
@ -548,7 +548,7 @@ export function ZoneFilterContent({
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden"> <div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
{allZones && setCurrentZones && ( {allZones && setCurrentZones && (
<> <>
<DropdownMenuSeparator /> {isDesktop && <DropdownMenuSeparator />}
<div className="mb-5 mt-2.5 flex items-center justify-between"> <div className="mb-5 mt-2.5 flex items-center justify-between">
<Label <Label
className="mx-2 cursor-pointer text-primary" className="mx-2 cursor-pointer text-primary"
@ -599,7 +599,7 @@ export function ZoneFilterContent({
</> </>
)} )}
</div> </div>
<DropdownMenuSeparator /> {isDesktop && <DropdownMenuSeparator />}
<div className="flex items-center justify-evenly p-2"> <div className="flex items-center justify-evenly p-2">
<Button <Button
variant="select" variant="select"
@ -697,7 +697,7 @@ function SubFilterButton({
}} }}
> >
<DrawerTrigger asChild>{trigger}</DrawerTrigger> <DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden"> <DrawerContent className="max-h-[75dvh] overflow-hidden p-4">
{content} {content}
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
@ -788,7 +788,7 @@ export function SubFilterContent({
))} ))}
</div> </div>
</div> </div>
<DropdownMenuSeparator /> {isDesktop && <DropdownMenuSeparator />}
<div className="flex items-center justify-evenly p-2"> <div className="flex items-center justify-evenly p-2">
<Button <Button
variant="select" variant="select"
@ -877,7 +877,7 @@ function SearchTypeButton({
}} }}
> >
<DrawerTrigger asChild>{trigger}</DrawerTrigger> <DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden"> <DrawerContent className="max-h-[75dvh] overflow-hidden p-4">
{content} {content}
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
@ -956,7 +956,7 @@ export function SearchTypeContent({
}} }}
/> />
</div> </div>
<DropdownMenuSeparator /> {isDesktop && <DropdownMenuSeparator />}
<div className="flex items-center justify-evenly p-2"> <div className="flex items-center justify-evenly p-2">
<Button <Button
variant="select" variant="select"

View File

@ -193,7 +193,7 @@ export default function ObjectLifecycle({
}; };
useEffect(() => { useEffect(() => {
if (eventSequence) { if (eventSequence && eventSequence.length > 0) {
setTimeIndex(eventSequence?.[current].timestamp); setTimeIndex(eventSequence?.[current].timestamp);
handleSetBox(eventSequence?.[current].data.box ?? []); handleSetBox(eventSequence?.[current].data.box ?? []);
setLifecycleZones(eventSequence?.[current].data.zones); setLifecycleZones(eventSequence?.[current].data.zones);

View File

@ -237,7 +237,7 @@ function ObjectDetailsTab({
const [desc, setDesc] = useState(search?.data.description); const [desc, setDesc] = useState(search?.data.description);
// we have to make sure the current selected search item stays in sync // we have to make sure the current selected search item stays in sync
useEffect(() => setDesc(search?.data.description), [search]); useEffect(() => setDesc(search?.data.description ?? ""), [search]);
const formattedDate = useFormattedTimestamp( const formattedDate = useFormattedTimestamp(
search?.start_time ?? 0, search?.start_time ?? 0,
@ -351,7 +351,7 @@ function ObjectDetailsTab({
<div className="text-sm text-primary/40">Description</div> <div className="text-sm text-primary/40">Description</div>
<Textarea <Textarea
className="md:h-64" className="md:h-64"
placeholder="Description of the event" placeholder="Description of the tracked object"
value={desc} value={desc}
onChange={(e) => setDesc(e.target.value)} onChange={(e) => setDesc(e.target.value)}
/> />

View File

@ -315,7 +315,7 @@ export function DateRangePicker({
return ( return (
<div className="w-full"> <div className="w-full">
<div className="flex py-2"> <div className="flex flex-row items-start justify-center py-2">
<div className="flex"> <div className="flex">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-col items-center justify-end gap-2 px-3 pb-4 lg:flex-row lg:items-start lg:pb-0"> <div className="flex flex-col items-center justify-end gap-2 px-3 pb-4 lg:flex-row lg:items-start lg:pb-0">

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,8 @@ 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 ? API_LIMIT : undefined,
in_progress: 0, in_progress: 0,
include_thumbnails: 0, include_thumbnails: 0,
}, },
@ -117,8 +121,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 +274,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

@ -94,7 +94,7 @@ function ThumbnailRow({
}; };
return ( return (
<div className="rounded-lg bg-background_alt p-2 md:p-4"> <div className="rounded-lg bg-background_alt p-2 md:px-4">
<div className="text-lg capitalize"> <div className="text-lg capitalize">
{objectType.replaceAll("_", " ")} {objectType.replaceAll("_", " ")}
{searchResults && ( {searchResults && (

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>
); );
} }