diff --git a/src/common/LazyLoad.tsx b/src/common/LazyLoad.tsx index 34a6cf9..4696dc7 100644 --- a/src/common/LazyLoad.tsx +++ b/src/common/LazyLoad.tsx @@ -1,22 +1,28 @@ -import React, { useState, useEffect, useRef } from 'react' +import { CircularProgress } from '@mui/material'; +import React, { useEffect, useRef } from 'react' import { useInView } from 'react-intersection-observer' -import CircularProgress from '@mui/material/CircularProgress' interface Props { onLoadMore: () => void } const LazyLoad: React.FC = ({ onLoadMore }) => { + const hasTriggeredRef = useRef(false); // Prevents multiple auto-triggers const [ref, inView] = useInView({ - threshold: 0.7 - }) + threshold: 0.7, + triggerOnce: false, // Allows multiple triggers, but we control when + }); useEffect(() => { - if (inView) { - onLoadMore() + if (inView && !hasTriggeredRef.current) { + hasTriggeredRef.current = true; // Set flag so it doesn’t trigger again immediately + onLoadMore(); + setTimeout(() => { + hasTriggeredRef.current = false; // Reset trigger after a short delay + }, 1000); } - }, [inView]) + }, [inView]); return (
= ({ onLoadMore }) => { style={{ display: 'flex', justifyContent: 'center', - minHeight: '25px' + height: '50px', + overflow: 'hidden' }} - > -
+ > ) } -export default LazyLoad +export default LazyLoad; diff --git a/src/common/useScrollTracker.tsx b/src/common/useScrollTracker.tsx index 0f7c4d2..04ae71a 100644 --- a/src/common/useScrollTracker.tsx +++ b/src/common/useScrollTracker.tsx @@ -1,26 +1,37 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; + +export const useScrollTracker = (listName: string, hasList: boolean, disableScrollTracker?: boolean) => { + const elementRef = useRef(null); + const [hasMount, setHasMount] = useState(false); + const scrollPositionRef = useRef(0); // Store the last known scroll position -export const useScrollTracker = (listName: string) => { useEffect(() => { - if (!listName) return; - + if(disableScrollTracker) return + if (!listName || !hasList) return; + const SCROLL_KEY = `scroll-position-${listName}`; - // πŸ”Ή Restore saved scroll position for the given list + // πŸ”Ή Restore scroll when the component mounts const savedPosition = sessionStorage.getItem(SCROLL_KEY); if (savedPosition) { window.scrollTo(0, parseInt(savedPosition, 10)); + setTimeout(() => { + setHasMount(true); + }, 200); } + // πŸ”Ή Capture scroll position before unmount const handleScroll = () => { - sessionStorage.setItem(SCROLL_KEY, window.scrollY.toString()); + scrollPositionRef.current = window.scrollY; // Store the last known scroll position }; - // πŸ”Ή Save scroll position on scroll window.addEventListener("scroll", handleScroll); return () => { + sessionStorage.setItem(SCROLL_KEY, scrollPositionRef.current.toString()); window.removeEventListener("scroll", handleScroll); }; - }, [listName]); // βœ… Only runs when listName changes -}; \ No newline at end of file + }, [listName, hasList, disableScrollTracker]); + + return { elementRef, hasMount }; +}; diff --git a/src/components/ResourceList/DynamicGrid.tsx b/src/components/ResourceList/DynamicGrid.tsx index 7883292..a44b7c3 100644 --- a/src/components/ResourceList/DynamicGrid.tsx +++ b/src/components/ResourceList/DynamicGrid.tsx @@ -6,6 +6,7 @@ interface DynamicGridProps { gap?: number; // Spacing between grid items children: ReactNode minItemWidth?: number + setColumnsPerRow: (columns: number)=> void; } const DynamicGrid: React.FC = ({ @@ -13,12 +14,32 @@ const DynamicGrid: React.FC = ({ minItemWidth = 200, // Minimum width per item gap = 10, // Space between items children, - + setColumnsPerRow }) => { + const containerRef = useRef(null); + const itemContainerRef = useRef(null); + + const updateColumns = () => { + if (containerRef.current && itemContainerRef.current) { + const containerWidth = containerRef.current.clientWidth; + const itemWidth = itemContainerRef.current.clientWidth + const calculatedColumns = Math.floor(containerWidth / itemWidth); + setColumnsPerRow(calculatedColumns); + } + }; + + useEffect(() => { + updateColumns(); // Run on mount + window.addEventListener("resize", updateColumns); + return () => window.removeEventListener("resize", updateColumns); + }, []); + return (
= ({ }} > {items.map((component, index) => ( -
+
{component} {/* βœ… Renders user-provided component */}
))} diff --git a/src/components/ResourceList/HorizontalPaginationList.tsx b/src/components/ResourceList/HorizontalPaginationList.tsx index c9429cd..efefbd2 100644 --- a/src/components/ResourceList/HorizontalPaginationList.tsx +++ b/src/components/ResourceList/HorizontalPaginationList.tsx @@ -1,21 +1,24 @@ -import React, { useEffect, useState, useCallback } from "react"; +import React, { useMemo, useRef, useState } from "react"; import DynamicGrid from "./DynamicGrid"; import LazyLoad from "../../common/LazyLoad"; -import { ListItem, useCacheStore } from "../../state/cache"; +import { ListItem } from "../../state/cache"; import { QortalMetadata } from "../../types/interfaces/resources"; -import { ListItemWrapper } from "./ResourceListDisplay"; +import { DefaultLoaderParams, ListItemWrapper } from "./ResourceListDisplay"; interface HorizontalPaginatedListProps { items: QortalMetadata[]; listItem: (item: ListItem, index: number) => React.ReactNode; loaderItem?: (status: "LOADING" | "ERROR") => React.ReactNode; - onLoadMore: () => void; - maxItems?: number; + onLoadMore: (limit: number) => void; + onLoadLess: (limit: number)=> void; minItemWidth?: number; gap?: number; isLoading?: boolean; onSeenLastItem?: (listItem: ListItem) => void; - + isLoadingMore: boolean; + limit: number, + disablePagination?: boolean + defaultLoaderParams?: DefaultLoaderParams; } export const HorizontalPaginatedList = ({ @@ -23,84 +26,78 @@ export const HorizontalPaginatedList = ({ listItem, loaderItem, onLoadMore, - maxItems = 60, + onLoadLess, minItemWidth, gap, isLoading, onSeenLastItem, - + isLoadingMore, + limit, + disablePagination, + defaultLoaderParams }: HorizontalPaginatedListProps) => { - const [displayedItems, setDisplayedItems] = useState(items); - +const lastItemRef= useRef(null) +const lastItemRef2= useRef(null) +const [columnsPerRow, setColumnsPerRow] = useState(null) - useEffect(() => { - setDisplayedItems(items); - }, [items]); +const displayedLimit = useMemo(()=> { + if(disablePagination) return limit || 20 + return Math.floor((limit || 20) / (columnsPerRow || 3)) * (columnsPerRow || 3); +}, [columnsPerRow, disablePagination]) - const preserveScroll = useCallback((updateFunction: () => void) => { - const previousScrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft; - const previousScrollWidth = document.documentElement.scrollWidth || document.body.scrollWidth; - - updateFunction(); // Perform the update (fetch new data, remove old) - - requestAnimationFrame(() => { - const newScrollWidth = document.documentElement.scrollWidth || document.body.scrollWidth; - document.documentElement.scrollLeft = document.body.scrollLeft = - previousScrollLeft - (previousScrollWidth - newScrollWidth); - }); - }, []); - - useEffect(() => { - if (displayedItems.length > maxItems) { - preserveScroll(() => { - const excess = displayedItems.length - maxItems; - setDisplayedItems((prev) => prev.slice(excess)); // Trim from the start - }); - } - }, [displayedItems, maxItems, preserveScroll]); - - useEffect(() => { - const handleScroll = () => { - const scrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft; - const clientWidth = document.documentElement.clientWidth || document.body.clientWidth; - const scrollWidth = document.documentElement.scrollWidth || document.body.scrollWidth; - - if (scrollLeft + clientWidth >= scrollWidth - 10 && !isLoading) { - onLoadMore(); - } - }; - - window.addEventListener("scroll", handleScroll); - return () => window.removeEventListener("scroll", handleScroll); - }, [onLoadMore, isLoading]); +const displayedItems = disablePagination ? items : items?.length < (displayedLimit * 3) ? items?.slice(0, displayedLimit * 3) : items.slice(- (displayedLimit * 3)) return ( -
+
+ {!disablePagination && items?.length > (displayedLimit * 3) && ( + { + + await onLoadLess(displayedLimit); + lastItemRef2.current.scrollIntoView({ behavior: "auto", block: "start" }); + setTimeout(() => { + window.scrollBy({ top: -50, behavior: "instant" }); // 'smooth' if needed + }, 0); + }} + /> + )} ( + items={displayedItems?.map((item, index, list) => ( +
+
))} > - {!isLoading && displayedItems.length > 0 && ( + { - onLoadMore(); - if (onSeenLastItem) { - // onSeenLastItem(displayedItems[displayedItems.length - 1]); - } + onLoadMore={async () => { + await onLoadMore(displayedLimit); + lastItemRef.current.scrollIntoView({ behavior: "auto", block: "end" }); + setTimeout(() => { + window.scrollBy({ top: 50, behavior: "instant" }); // 'smooth' if needed + }, 0); + }} /> - )} +
+ +
); }; diff --git a/src/components/ResourceList/ResourceListDisplay.tsx b/src/components/ResourceList/ResourceListDisplay.tsx index dcd1c19..0e671a5 100644 --- a/src/components/ResourceList/ResourceListDisplay.tsx +++ b/src/components/ResourceList/ResourceListDisplay.tsx @@ -2,28 +2,25 @@ import React, { CSSProperties, useCallback, useEffect, - useLayoutEffect, useMemo, useRef, useState, - useTransition, } from "react"; import { QortalMetadata, QortalSearchParams, } from "../../types/interfaces/resources"; import { useResources } from "../../hooks/useResources"; -import { MessageWrapper, VirtualizedList } from "../../common/VirtualizedList"; +import { VirtualizedList } from "../../common/VirtualizedList"; import { ListLoader } from "../../common/ListLoader"; import { ListItem, useCacheStore } from "../../state/cache"; import { ResourceLoader } from "./ResourceLoader"; import { ItemCardWrapper } from "./ItemCardWrapper"; import { Spacer } from "../../common/Spacer"; -import DynamicGrid from "./DynamicGrid"; -import LazyLoad from "../../common/LazyLoad"; import { useListStore } from "../../state/lists"; import { useScrollTracker } from "../../common/useScrollTracker"; import { HorizontalPaginatedList } from "./HorizontalPaginationList"; +import { VerticalPaginatedList } from "./VerticalPaginationList"; type Direction = "VERTICAL" | "HORIZONTAL"; interface ResourceListStyles { @@ -37,7 +34,7 @@ interface ResourceListStyles { } } -interface DefaultLoaderParams { +export interface DefaultLoaderParams { listLoadingText?: string; listNoResultsText?: string; listItemLoadingText?: string; @@ -57,6 +54,8 @@ interface BaseProps { children?: React.ReactNode; searchCacheDuration?: number resourceCacheDuration?: number + disablePagination?: boolean + disableScrollTracker?: boolean } // βœ… Restrict `direction` only when `disableVirtualization = false` @@ -86,17 +85,22 @@ export const MemorizedComponent = ({ onSeenLastItem, listName, searchCacheDuration, - resourceCacheDuration + resourceCacheDuration, + disablePagination, + disableScrollTracker }: PropsResourceListDisplay) => { const { fetchResources } = useResources(); const { getTemporaryResources, filterOutDeletedResources } = useCacheStore(); - const [isLoading, setIsLoading] = useState(false); const memoizedParams = useMemo(() => JSON.stringify(search), [search]); const addList = useListStore().addList + const removeFromList = useListStore().removeFromList + const addItems = useListStore().addItems - const getListByName = useListStore().getListByName const list = useListStore().getListByName(listName) + const [isLoading, setIsLoading] = useState(list?.length > 0 ? false : true); + const isListExpired = useCacheStore().isListExpired(listName) + const [isLoadingMore, setIsLoadingMore] = useState(false) const initialized = useRef(false) const getResourceList = useCallback(async () => { @@ -124,13 +128,16 @@ export const MemorizedComponent = ({ useEffect(() => { if(initialized.current) return initialized.current = true - if(!isListExpired) return + if(!isListExpired) { + setIsLoading(false) + return + } sessionStorage.removeItem(`scroll-position-${listName}`); getResourceList(); }, [getResourceList, isListExpired]); // Runs when dependencies change - useScrollTracker(listName); + const {elementRef} = useScrollTracker(listName, list?.length > 0, disableScrollTracker); const setSearchCacheExpiryDuration = useCacheStore().setSearchCacheExpiryDuration const setResourceCacheExpiryDuration = useCacheStore().setResourceCacheExpiryDuration @@ -152,18 +159,24 @@ export const MemorizedComponent = ({ - const getResourceMoreList = useCallback(async () => { + const getResourceMoreList = useCallback(async (displayLimit?: number) => { try { - // setIsLoading(true); + setIsLoadingMore(true) const parsedParams = {...(JSON.parse(memoizedParams))}; parsedParams.before = list.length === 0 ? null : list[list.length - 1]?.created parsedParams.offset = null + if(displayLimit){ + parsedParams.limit = displayLimit + } const responseData = await fetchResources(parsedParams, listName); // Awaiting the async function addItems(listName, responseData || []) } catch (error) { console.error("Failed to fetch resources:", error); } finally { - setIsLoading(false); + setTimeout(() => { + setIsLoadingMore(false); + + }, 1000); } }, [memoizedParams, listName, list]); @@ -191,6 +204,10 @@ export const MemorizedComponent = ({ }, [listName]); return ( +
- { - return ( - - - - ); - })} - > - - {!isLoading && listToDisplay?.length > 0 && ( - { - getResourceMoreList() - if(onSeenLastItem){ - - onSeenLastItem(listToDisplay[listToDisplay?.length - 1]) - } - }} /> - )} - + { + removeFromList(listName, displayLimit) + }} isLoadingMore={isLoadingMore} items={listToDisplay} listItem={listItem} onLoadMore={(displayLimit)=> getResourceMoreList(displayLimit)} gap={styles?.gap} isLoading={isLoading} minItemWidth={styles?.horizontalStyles?.minItemWidth} loaderItem={loaderItem} /> )} {disableVirtualization && direction === "VERTICAL" && (
- {listToDisplay?.map((item, index) => { - return ( - - - - - - ); - })} - {!isLoading && listToDisplay?.length > 0 && ( - { - getResourceMoreList() - if(onSeenLastItem){ - onSeenLastItem(listToDisplay[listToDisplay?.length - 1]) - } - }} /> - )} - + { + + removeFromList(listName, displayLimit) + }} defaultLoaderParams={defaultLoaderParams} isLoadingMore={isLoadingMore} items={listToDisplay} listItem={listItem} onLoadMore={(displayLimit)=> getResourceMoreList(displayLimit)} gap={styles?.gap} isLoading={isLoading} minItemWidth={styles?.horizontalStyles?.minItemWidth} loaderItem={loaderItem} />
)}
+
); } diff --git a/src/components/ResourceList/VerticalPaginationList.tsx b/src/components/ResourceList/VerticalPaginationList.tsx new file mode 100644 index 0000000..458990e --- /dev/null +++ b/src/components/ResourceList/VerticalPaginationList.tsx @@ -0,0 +1,97 @@ +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import DynamicGrid from "./DynamicGrid"; +import LazyLoad from "../../common/LazyLoad"; +import { ListItem } from "../../state/cache"; +import { QortalMetadata } from "../../types/interfaces/resources"; +import { DefaultLoaderParams, ListItemWrapper } from "./ResourceListDisplay"; +import { Button } from "@mui/material"; + +interface VerticalPaginatedListProps { + items: QortalMetadata[]; + listItem: (item: ListItem, index: number) => React.ReactNode; + loaderItem?: (status: "LOADING" | "ERROR") => React.ReactNode; + onLoadMore: (limit: number) => void; + onLoadLess: (limit: number)=> void; + minItemWidth?: number; + gap?: number; + isLoading?: boolean; + onSeenLastItem?: (listItem: ListItem) => void; + isLoadingMore: boolean; + limit: number, + disablePagination?: boolean + defaultLoaderParams?: DefaultLoaderParams; +} + +export const VerticalPaginatedList = ({ + items, + listItem, + loaderItem, + onLoadMore, + onLoadLess, + minItemWidth, + gap, + isLoading, + onSeenLastItem, + isLoadingMore, + limit, + disablePagination, + defaultLoaderParams +}: VerticalPaginatedListProps) => { + +const lastItemRef= useRef(null) +const lastItemRef2= useRef(null) + +const displayedLimit = limit || 20 + +const displayedItems = disablePagination ? items : items.slice(- (displayedLimit * 3)) + + return ( + <> + {!disablePagination && items?.length > (displayedLimit * 3) && ( + { + + await onLoadLess(displayedLimit); + lastItemRef2.current.scrollIntoView({ behavior: "auto", block: "start" }); + setTimeout(() => { + window.scrollBy({ top: -50, behavior: "instant" }); // 'smooth' if needed + }, 0); + }} + /> + )} + + {displayedItems?.map((item, index, list) => { + return ( + +
+ +
+
+ ); + })} + + { + await onLoadMore(displayedLimit); + lastItemRef.current.scrollIntoView({ behavior: "auto", block: "end" }); + setTimeout(() => { + window.scrollBy({ top: 50, behavior: "instant" }); // 'smooth' if needed + }, 0); + + }} + /> + + ); +}; diff --git a/src/hooks/useResources.tsx b/src/hooks/useResources.tsx index f86863d..6768bba 100644 --- a/src/hooks/useResources.tsx +++ b/src/hooks/useResources.tsx @@ -29,6 +29,7 @@ export const useResources = () => { } = useCacheStore(); const requestControllers = new Map(); + const getArbitraryResource = async ( url: string, key: string @@ -42,6 +43,7 @@ export const useResources = () => { try { const res = await fetch(url, { signal: controller.signal }); + if(!res?.ok) throw new Error('Error in downloading') return await res.text(); } catch (error: any) { if (error?.name === "AbortError") { @@ -90,6 +92,8 @@ export const useResources = () => { } catch (error) { hasFailedToDownload = true; } + + if (res === "canceled") return false; if (hasFailedToDownload) { diff --git a/src/state/lists.ts b/src/state/lists.ts index 254076f..258baed 100644 --- a/src/state/lists.ts +++ b/src/state/lists.ts @@ -15,6 +15,7 @@ interface ListStore { // CRUD Operations addList: (name: string, items: QortalMetadata[]) => void; + removeFromList: (name: string, length: number)=> void; addItem: (listName: string, item: QortalMetadata) => void; addItems: (listName: string, items: QortalMetadata[]) => void; updateItem: (listName: string, item: QortalMetadata) => void; @@ -35,6 +36,13 @@ export const useListStore = create((set, get) => ({ [name]: { name, items }, // βœ… Store items as an array }, })), + removeFromList: (name, length) => + set((state) => ({ + lists: { + ...state.lists, + [name]: { name, items: state.lists[name].items.slice(0, state.lists[name].items.length - length) }, // βœ… Store items as an array + }, + })), addItem: (listName, item) => set((state) => {