added pagination for lists

This commit is contained in:
PhilReact 2025-03-16 22:11:02 +02:00
parent e5eff9827a
commit f613054b9a
8 changed files with 264 additions and 150 deletions

View File

@ -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<Props> = ({ 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 doesnt trigger again immediately
onLoadMore();
setTimeout(() => {
hasTriggeredRef.current = false; // Reset trigger after a short delay
}, 1000);
}
}, [inView])
}, [inView]);
return (
<div
@ -24,11 +30,11 @@ const LazyLoad: React.FC<Props> = ({ onLoadMore }) => {
style={{
display: 'flex',
justifyContent: 'center',
minHeight: '25px'
height: '50px',
overflow: 'hidden'
}}
>
</div>
><CircularProgress /></div>
)
}
export default LazyLoad
export default LazyLoad;

View File

@ -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<HTMLDivElement | null>(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
};
}, [listName, hasList, disableScrollTracker]);
return { elementRef, hasMount };
};

View File

@ -6,6 +6,7 @@ interface DynamicGridProps {
gap?: number; // Spacing between grid items
children: ReactNode
minItemWidth?: number
setColumnsPerRow: (columns: number)=> void;
}
const DynamicGrid: React.FC<DynamicGridProps> = ({
@ -13,12 +14,32 @@ const DynamicGrid: React.FC<DynamicGridProps> = ({
minItemWidth = 200, // Minimum width per item
gap = 10, // Space between items
children,
setColumnsPerRow
}) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const itemContainerRef = useRef<HTMLDivElement | null>(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 (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", width: "100%" }}>
<div
ref={containerRef}
style={{
display: "grid",
gridTemplateColumns: `repeat(auto-fill, minmax(${minItemWidth}px, 1fr))`, // ✅ Expands to fit width
@ -31,7 +52,7 @@ const DynamicGrid: React.FC<DynamicGridProps> = ({
}}
>
{items.map((component, index) => (
<div key={index} style={{ width: "100%", display: "flex", justifyContent: "center", maxWidth: '400px' }}>
<div ref={index === 0 ? itemContainerRef : null} key={index} style={{ width: "100%", display: "flex", justifyContent: "center", maxWidth: '400px' }}>
{component} {/* ✅ Renders user-provided component */}
</div>
))}

View File

@ -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<any>(null)
const lastItemRef2= useRef<any>(null)
const [columnsPerRow, setColumnsPerRow] = useState<null | number>(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 (
<div style={{ overflow: "auto", width: "100%", display: "flex", flexGrow: 1 }}>
<div style={{ overflowX: "hidden", width: "100%", display: "flex", flexGrow: 1, flexDirection: 'column' }}>
{!disablePagination && items?.length > (displayedLimit * 3) && (
<LazyLoad
onLoadMore={async () => {
await onLoadLess(displayedLimit);
lastItemRef2.current.scrollIntoView({ behavior: "auto", block: "start" });
setTimeout(() => {
window.scrollBy({ top: -50, behavior: "instant" }); // 'smooth' if needed
}, 0);
}}
/>
)}
<DynamicGrid
setColumnsPerRow={setColumnsPerRow}
minItemWidth={minItemWidth}
gap={gap}
items={displayedItems.map((item, index) => (
items={displayedItems?.map((item, index, list) => (
<React.Fragment key={`${item?.name}-${item?.service}-${item?.identifier}`}>
<div style={{
width: '100%',
display: 'flex',
justifyContent: 'center'
}} ref={index === displayedLimit ? lastItemRef2 : index === list.length -displayedLimit - 1 ? lastItemRef : null}>
<ListItemWrapper
defaultLoaderParams={defaultLoaderParams}
item={item}
index={index}
render={listItem}
renderListItemLoader={loaderItem}
/>
</div>
</React.Fragment>
))}
>
{!isLoading && displayedItems.length > 0 && (
<LazyLoad
onLoadMore={() => {
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);
}}
/>
)}
</DynamicGrid>
</div>
);
};

View File

@ -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 (
<div ref={elementRef} style={{
width: '100%',
height: '100%'
}}>
<ListLoader
noResultsMessage={
defaultLoaderParams?.listNoResultsText || "No results available"
@ -204,6 +221,7 @@ export const MemorizedComponent = ({
loaderHeight={styles?.listLoadingHeight}
>
<div
style={{
height: "100%",
display: "flex",
@ -236,72 +254,24 @@ export const MemorizedComponent = ({
)}
{disableVirtualization && direction === "HORIZONTAL" && (
<>
<DynamicGrid
minItemWidth={styles?.horizontalStyles?.minItemWidth}
gap={styles?.gap}
items={listToDisplay?.map((item, index) => {
return (
<React.Fragment
key={`${item?.name}-${item?.service}-${item?.identifier}`}
>
<ListItemWrapper
defaultLoaderParams={defaultLoaderParams}
item={item}
index={index}
render={listItem}
renderListItemLoader={loaderItem}
/>
</React.Fragment>
);
})}
>
{!isLoading && listToDisplay?.length > 0 && (
<LazyLoad onLoadMore={()=> {
getResourceMoreList()
if(onSeenLastItem){
onSeenLastItem(listToDisplay[listToDisplay?.length - 1])
}
}} />
)}
</DynamicGrid>
<HorizontalPaginatedList defaultLoaderParams={defaultLoaderParams} disablePagination={disablePagination} limit={search?.limit || 20} onLoadLess={(displayLimit)=> {
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" && (
<div style={disabledVirutalizationStyles}>
{listToDisplay?.map((item, index) => {
return (
<React.Fragment
key={`${item?.name}-${item?.service}-${item?.identifier}`}
>
<ListItemWrapper
defaultLoaderParams={defaultLoaderParams}
item={item}
index={index}
render={listItem}
renderListItemLoader={loaderItem}
/>
</React.Fragment>
);
})}
{!isLoading && listToDisplay?.length > 0 && (
<LazyLoad onLoadMore={()=> {
getResourceMoreList()
if(onSeenLastItem){
onSeenLastItem(listToDisplay[listToDisplay?.length - 1])
}
}} />
)}
<VerticalPaginatedList disablePagination={disablePagination} limit={search?.limit || 20} onLoadLess={(displayLimit)=> {
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} />
</div>
)}
</div>
</div>
</ListLoader>
</div>
);
}

View File

@ -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<any>(null)
const lastItemRef2= useRef<any>(null)
const displayedLimit = limit || 20
const displayedItems = disablePagination ? items : items.slice(- (displayedLimit * 3))
return (
<>
{!disablePagination && items?.length > (displayedLimit * 3) && (
<LazyLoad
onLoadMore={async () => {
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 (
<React.Fragment
key={`${item?.name}-${item?.service}-${item?.identifier}`}
>
<div style={{
width: '100%',
display: 'flex',
justifyContent: 'center'
}} ref={index === displayedLimit ? lastItemRef2 : index === list.length -displayedLimit - 1 ? lastItemRef : null}>
<ListItemWrapper
defaultLoaderParams={defaultLoaderParams}
item={item}
index={index}
render={listItem}
renderListItemLoader={loaderItem}
/>
</div>
</React.Fragment>
);
})}
<LazyLoad
onLoadMore={async () => {
await onLoadMore(displayedLimit);
lastItemRef.current.scrollIntoView({ behavior: "auto", block: "end" });
setTimeout(() => {
window.scrollBy({ top: 50, behavior: "instant" }); // 'smooth' if needed
}, 0);
}}
/>
</>
);
};

View File

@ -29,6 +29,7 @@ export const useResources = () => {
} = useCacheStore();
const requestControllers = new Map<string, AbortController>();
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) {

View File

@ -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<ListStore>((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) => {