From e61607b5d4ee3cc505eb57f7804fb369e49b0a34 Mon Sep 17 00:00:00 2001 From: NlightN22 Date: Sat, 1 Feb 2025 17:07:16 +0700 Subject: [PATCH] main page - add infinite page scroll --- src/pages/MainPage.tsx | 88 +++++++++++++++-------- src/services/frigate.proxy/frigate.api.ts | 2 +- src/shared/stores/main.store.ts | 4 +- 3 files changed, 61 insertions(+), 33 deletions(-) diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index 33b30aa..30b6eeb 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -1,9 +1,10 @@ import { Flex, Grid } from '@mantine/core'; import { IconSearch } from '@tabler/icons-react'; -import { useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery } from '@tanstack/react-query'; import { observer } from 'mobx-react-lite'; -import { ChangeEvent, useContext, useEffect, useMemo, useState } from 'react'; +import { ChangeEvent, useContext, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useInView } from 'react-intersection-observer'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { Context } from '..'; import { useDebounce } from '../hooks/useDebounce'; @@ -11,13 +12,13 @@ import { useRealmUser } from '../hooks/useRealmUser'; import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api'; import { GetCameraWHostWConfig } from '../services/frigate.proxy/frigate.schema'; import ClearableTextInput from '../shared/components/inputs/ClearableTextInput'; -import CenterLoader from '../shared/components/loaders/CenterLoader'; +import CogwheelLoader from '../shared/components/loaders/CogwheelLoader'; import { isProduction } from '../shared/env.const'; +import CameraCard from '../widgets/card/CameraCard'; import MainFiltersRightSide from '../widgets/sidebars/MainFiltersRightSide'; import { SideBarContext } from '../widgets/sidebars/SideBarContext'; import RetryErrorPage from './RetryErrorPage'; -import CameraCard from '../widgets/card/CameraCard'; -import { useInView } from 'react-intersection-observer'; +import { useIntersection } from '@mantine/hooks'; export const mainPageParams = { hostId: 'hostId', @@ -33,37 +34,64 @@ const MainPage = () => { const { setRightChildren } = useContext(SideBarContext) const { hostId: selectedHostId, selectedTags, searchQuery } = mainStore.filters - const [filteredCameras, setFilteredCameras] = useState([]) const { ref, inView } = useInView({ threshold: 0.5 }) - const [visibleCameras, setVisibleCameras] = useState([]) const realmUser = useRealmUser() if (!isProduction) console.log('Realmuser:', realmUser) + const loadTriggered = useRef(false); - const { data: cameras, isPending, isError, refetch } = useQuery({ + const pageSize = 20; + + const { + data, + isLoading, + isError, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + refetch, + } = useInfiniteQuery({ queryKey: [frigateQueryKeys.getCamerasWHost, selectedHostId, searchQuery, selectedTags], - queryFn: () => + queryFn: ({ pageParam = 0 }) => + // Pass pagination parameters to the backend frigateApi.getCamerasWHost({ - name: searchQuery, // filter by camera name - frigateHostId: selectedHostId, // filter by host id - tagIds: selectedTags, // filter by tag id(s) - // offset and limit can be added later for pagination + name: searchQuery, + frigateHostId: selectedHostId, + tagIds: selectedTags, + offset: pageParam, + limit: pageSize, }), - }) + getNextPageParam: (lastPage, pages) => { + // If last page size is less than pageSize, no more pages + if (lastPage.length < pageSize) return undefined; + // Next page offset is pages.length * pageSize + return pages.length * pageSize; + }, + initialPageParam: 0, + }); + + const cameras: GetCameraWHostWConfig[] = data?.pages.flat() || []; + // const cameras: GetCameraWHostWConfig[] = []; + + const [visibleCount, setVisibleCount] = useState(pageSize) useEffect(() => { - setFilteredCameras(cameras || []); - setVisibleCameras([]); // reset visible cameras for pagination - }, [cameras]); - - useEffect(() => { - const pageSize = 20; - if (inView && filteredCameras.length > visibleCameras.length) { - const nextBatch = filteredCameras.slice(visibleCameras.length, visibleCameras.length + pageSize); - setVisibleCameras(prev => [...prev, ...nextBatch]); + if (inView && !isFetching) { + if (visibleCount < cameras.length) { + setVisibleCount(prev => Math.min(prev + pageSize, cameras.length)); + } else if (hasNextPage && !isFetchingNextPage) { + loadTriggered.current = true; + fetchNextPage().then(() => { + // Add a small delay before resetting the flag + setTimeout(() => { + loadTriggered.current = false; + }, 300); // delay in milliseconds; adjust as needed + }); + } } - }, [inView, filteredCameras, visibleCameras]); + }, [inView, cameras, visibleCount, hasNextPage, isFetchingNextPage, isFetching, fetchNextPage]) useEffect(() => { const hostId = searchParams.get(mainPageParams.hostId) || '' @@ -95,10 +123,8 @@ const MainPage = () => { debouncedHandleSearchQuery(event.currentTarget.value) } - if (isPending) return - + if (isLoading) return ; if (isError) return - if (!isProduction) console.log('MainPage rendered') return ( @@ -113,16 +139,18 @@ const MainPage = () => { placeholder={t('search')} icon={} value={searchQuery || undefined} - onChange={onInputChange} + // onChange={onInputChange} /> - {visibleCameras.map(camera => ( + {cameras.slice(0, visibleCount).map(camera => ( ))} -
{/* trigger point */} + { isFetching && !isFetchingNextPage ? : null} + {/* trigger point. Rerender twice when enabled */} +
); diff --git a/src/services/frigate.proxy/frigate.api.ts b/src/services/frigate.proxy/frigate.api.ts index c845999..311ed4f 100644 --- a/src/services/frigate.proxy/frigate.api.ts +++ b/src/services/frigate.proxy/frigate.api.ts @@ -50,7 +50,7 @@ export const frigateApi = { name?: string | null | undefined; frigateHostId?: string | null | undefined; tagIds?: string | string[]; - offset?: number; + offset?: any; limit?: number; } = {}) => instanceApi diff --git a/src/shared/stores/main.store.ts b/src/shared/stores/main.store.ts index 0546d73..2da2177 100644 --- a/src/shared/stores/main.store.ts +++ b/src/shared/stores/main.store.ts @@ -7,11 +7,11 @@ import { isProduction } from "../env.const"; interface Filters { hostId?: string | null searchQuery?: string | null - selectedTags?: string[] + selectedTags: string[] } export class MainStore { - filters: Filters = {} + filters: Filters = { selectedTags: [] } constructor() { makeAutoObservable(this)