From bb50cfc9a27f346fd333702fe4e7fafe537611be Mon Sep 17 00:00:00 2001 From: PhilReact Date: Thu, 13 Mar 2025 17:01:55 +0200 Subject: [PATCH] updated lists --- src/common/LazyLoad.tsx | 34 ++++ src/common/VirtualizedList.tsx | 4 +- src/components/ResourceList/DynamicGrid.tsx | 64 ++++++++ .../ResourceList/ResourceListDisplay.tsx | 143 +++++++++++++--- src/hooks/useResources.tsx | 66 ++++++-- src/state/cache.ts | 36 ++-- src/state/lists.ts | 154 ++++++++++++++++++ 7 files changed, 443 insertions(+), 58 deletions(-) create mode 100644 src/common/LazyLoad.tsx create mode 100644 src/components/ResourceList/DynamicGrid.tsx create mode 100644 src/state/lists.ts diff --git a/src/common/LazyLoad.tsx b/src/common/LazyLoad.tsx new file mode 100644 index 0000000..34a6cf9 --- /dev/null +++ b/src/common/LazyLoad.tsx @@ -0,0 +1,34 @@ +import React, { useState, 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 [ref, inView] = useInView({ + threshold: 0.7 + }) + + useEffect(() => { + if (inView) { + onLoadMore() + } + }, [inView]) + + return ( +
+
+ ) +} + +export default LazyLoad diff --git a/src/common/VirtualizedList.tsx b/src/common/VirtualizedList.tsx index c66d63b..e691ba5 100644 --- a/src/common/VirtualizedList.tsx +++ b/src/common/VirtualizedList.tsx @@ -31,12 +31,12 @@ export const VirtualizedList = ({ list, children, onSeenLastItem }: PropsVirtual overscan: 10, // Number of items to render outside the visible area to improve smoothness }); - const onSeenLastItemFunc = useCallback((lastItem: QortalMetadata) => { + const onSeenLastItemFunc = (lastItem: QortalMetadata) => { if(onSeenLastItem){ onSeenLastItem(lastItem) } - }, []); + }; return (
= ({ + items, + itemWidth = 300, // Default min item width + gap = 10, // Default gap between items + children +}) => { + const gridRef = useRef(null); + const [columns, setColumns] = useState(1); + + useEffect(() => { + const updateColumns = () => { + if (gridRef.current) { + const containerWidth = gridRef.current.offsetWidth; + const newColumns = Math.floor(containerWidth / (itemWidth + gap)); + setColumns(newColumns > 0 ? newColumns : 1); // Ensure at least 1 column + } + }; + + updateColumns(); // Initial column calculation + + const resizeObserver = new ResizeObserver(updateColumns); + if (gridRef.current) { + resizeObserver.observe(gridRef.current); + } + + return () => resizeObserver.disconnect(); // Cleanup observer + }, [itemWidth, gap]); + + return ( +
+ {/* ✅ Centers the grid inside the parent */} +
+ {items.map((component, index) => ( +
+ {component} {/* ✅ Render user-provided component */} +
+ ))} +
+ {children} +
+ ); +}; + +export default DynamicGrid; diff --git a/src/components/ResourceList/ResourceListDisplay.tsx b/src/components/ResourceList/ResourceListDisplay.tsx index 9dc9bcd..b80aca7 100644 --- a/src/components/ResourceList/ResourceListDisplay.tsx +++ b/src/components/ResourceList/ResourceListDisplay.tsx @@ -10,16 +10,23 @@ import { QortalSearchParams, } from "../../types/interfaces/resources"; import { useResources } from "../../hooks/useResources"; -import { VirtualizedList } from "../../common/VirtualizedList"; +import { MessageWrapper, 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"; +type Direction = "VERTICAL" | "HORIZONTAL"; interface ResourceListStyles { gap?: number; listLoadingHeight?: CSSProperties; + disabledVirutalizationStyles?: { + parentContainer?: CSSProperties; + }; } interface DefaultLoaderParams { @@ -29,17 +36,31 @@ interface DefaultLoaderParams { listItemErrorText?: string; } -interface PropsResourceListDisplay { +interface BaseProps { params: QortalSearchParams; - listItem: (item: ListItem, index: number) => React.ReactNode; // Function type + listItem: (item: ListItem, index: number) => React.ReactNode; styles?: ResourceListStyles; - loaderItem?: (status: "LOADING" | "ERROR") => React.ReactNode; // Function type + loaderItem?: (status: "LOADING" | "ERROR") => React.ReactNode; defaultLoaderParams?: DefaultLoaderParams; - loaderList?: (status: "LOADING" | "NO_RESULTS") => React.ReactNode; // Function type + loaderList?: (status: "LOADING" | "NO_RESULTS") => React.ReactNode; disableVirtualization?: boolean; - onSeenLastItem?: (listItem: QortalMetadata)=> void; + onSeenLastItem?: (listItem: QortalMetadata) => void; + listName: string } +// ✅ Restrict `direction` only when `disableVirtualization = false` +interface VirtualizedProps extends BaseProps { + disableVirtualization?: false; + direction?: "VERTICAL"; // Only allow "VERTICAL" when virtualization is enabled +} + +interface NonVirtualizedProps extends BaseProps { + disableVirtualization: true; + direction?: Direction; // Allow both "VERTICAL" & "HORIZONTAL" when virtualization is disabled +} + +type PropsResourceListDisplay = VirtualizedProps | NonVirtualizedProps; + export const ResourceListDisplay = ({ params, listItem, @@ -50,19 +71,23 @@ export const ResourceListDisplay = ({ loaderItem, loaderList, disableVirtualization, - onSeenLastItem + direction = "VERTICAL", + onSeenLastItem, + listName }: PropsResourceListDisplay) => { - const [list, setList] = useState([]); const { fetchResources } = useResources(); const [isLoading, setIsLoading] = useState(false); const memoizedParams = useMemo(() => JSON.stringify(params), [params]); + const addList = useListStore().addList + const addItems = useListStore().addItems + const list = useListStore().getListByName(listName) const getResourceList = useCallback(async () => { try { setIsLoading(true); const parsedParams = JSON.parse(memoizedParams); - const res = await fetchResources(parsedParams); // Awaiting the async function - setList(res || []); // Ensure it's an array, avoid setting `undefined` + const res = await fetchResources(parsedParams, listName, true); // Awaiting the async function + addList(listName, res || []) } catch (error) { console.error("Failed to fetch resources:", error); } finally { @@ -70,10 +95,36 @@ export const ResourceListDisplay = ({ } }, [memoizedParams, fetchResources]); // Added dependencies for re-fetching + const getResourceMoreList = useCallback(async () => { + try { + // setIsLoading(true); + const parsedParams = {...(JSON.parse(memoizedParams))}; + parsedParams.offset = (parsedParams?.offset || 0) + list.length + const res = await fetchResources(parsedParams, listName); // Awaiting the async function + addItems(listName, res || []) + } catch (error) { + console.error("Failed to fetch resources:", error); + } finally { + setIsLoading(false); + } + }, [memoizedParams, listName, list?.length]); + useEffect(() => { getResourceList(); }, [getResourceList]); // Runs when dependencies change + const disabledVirutalizationStyles: CSSProperties = useMemo(() => { + if (styles?.disabledVirutalizationStyles?.parentContainer) + return styles?.disabledVirutalizationStyles.parentContainer; + return { + position: "relative", + display: "flex", + flexDirection: "column", + gap: `${styles.gap}px` || 0, + width: "100%", + }; + }, [styles?.disabledVirutalizationStyles, styles?.gap, direction]); + return (
{!disableVirtualization && ( - + { + getResourceMoreList() + if(onSeenLastItem){ + onSeenLastItem(item) + } + }}> {(item: QortalMetadata, index: number) => ( <> - {styles?.gap && } + {styles?.gap && } - {styles?.gap && } + {styles?.gap && } )} )} - {disableVirtualization && ( -
- {list?.map((item, index) => { + {disableVirtualization && direction === "HORIZONTAL" && ( + <> + { return ( - {styles?.gap && } - - {styles?.gap && } ); })} + > + + {!isLoading && list?.length > 0 && ( + { + getResourceMoreList() + if(onSeenLastItem){ + + onSeenLastItem(list[list?.length - 1]) + } + }} /> + )} + + + + )} + {disableVirtualization && direction === "VERTICAL" && ( +
+ {list?.map((item, index) => { + return ( + + + + + + ); + })} + {!isLoading && list?.length > 0 && ( + { + getResourceMoreList() + if(onSeenLastItem){ + onSeenLastItem(list[list?.length - 1]) + } + }} /> + )} +
)}
diff --git a/src/hooks/useResources.tsx b/src/hooks/useResources.tsx index 43bfbcc..8003e75 100644 --- a/src/hooks/useResources.tsx +++ b/src/hooks/useResources.tsx @@ -9,10 +9,45 @@ export const requestQueueProductPublishesBackup = new RequestQueueWithPromise(5) export const useResources = () => { const { setSearchCache, getSearchCache, getResourceCache, setResourceCache } = useCacheStore(); + const requestControllers = new Map(); + + const getArbitraryResource = async (url: string, key: string): Promise => { + // ✅ Create or reuse an existing controller + let controller = requestControllers.get(key); + if (!controller) { + controller = new AbortController(); + requestControllers.set(key, controller); + } + + try { + const res = await fetch(url, { signal: controller.signal }); + return await res.text(); + } catch (error: any) { + if (error?.name === "AbortError") { + console.warn(`Request cancelled: ${key}`); + return "canceled"; // Return empty response on cancel + } else { + console.error(`Fetch error: ${key}`, error); + } + throw error; + } finally { + requestControllers.delete(key); // ✅ Cleanup controller after request + } + }; + + const cancelAllRequests = () => { + requestControllers.forEach((controller, key) => { + controller.abort(); + }); + requestControllers.clear(); + }; + const fetchIndividualPublish = useCallback( async (item: QortalMetadata) => { try { + const key = `${item?.service}-${item?.name}-${item?.identifier}`; + const cachedProduct = getResourceCache(`${item?.service}-${item?.name}-${item?.identifier}`); if (cachedProduct) return; setResourceCache(`${item?.service}-${item?.name}-${item?.identifier}`, null); @@ -20,17 +55,12 @@ export const useResources = () => { let res: string | undefined = undefined try { res = await requestQueueProductPublishes.enqueue((): Promise => { - return qortalRequest({ - action: "FETCH_QDN_RESOURCE", - identifier: item?.identifier, - encoding: "base64", - name: item?.name, - service: item?.service, - }); + return getArbitraryResource(`/arbitrary/${item?.service}/${item?.name}/${item?.identifier}?encoding=base64`, key) }); } catch (error) { hasFailedToDownload = true } + if(res === 'canceled') return false if (hasFailedToDownload) { await new Promise((res) => { @@ -41,13 +71,7 @@ export const useResources = () => { try { res = await requestQueueProductPublishesBackup.enqueue((): Promise => { - return qortalRequest({ - action: "FETCH_QDN_RESOURCE", - identifier: item?.identifier, - encoding: "base64", - name: item?.name, - service: item?.service, - }); + return getArbitraryResource(`/arbitrary/${item?.service}/${item?.name}/${item?.identifier}?encoding=base64`, key) }); } catch (error) { setResourceCache(`${item?.service}-${item?.name}-${item?.identifier}`, false); @@ -79,9 +103,17 @@ export const useResources = () => { ); const fetchResources = useCallback( - async (params: QortalSearchParams): Promise => { + async (params: QortalSearchParams, listName: string, cancelRequests?: boolean): Promise => { + if(cancelRequests){ + cancelAllRequests() + await new Promise((res)=> { + setTimeout(() => { + res(null) + }, 250); + }) + } const cacheKey = generateCacheKey(params); - const searchCache = getSearchCache(cacheKey); + const searchCache = getSearchCache(listName, cacheKey); let responseData = []; if (searchCache) { @@ -96,7 +128,7 @@ export const useResources = () => { if (!response) throw new Error("Unable to fetch resources"); responseData = response } - setSearchCache(cacheKey, responseData); + setSearchCache(listName, cacheKey, responseData); fetchDataFromResults(responseData); return responseData; diff --git a/src/state/cache.ts b/src/state/cache.ts index f3518cc..8e9a06b 100644 --- a/src/state/cache.ts +++ b/src/state/cache.ts @@ -3,13 +3,16 @@ import { QortalMetadata } from "../types/interfaces/resources"; interface SearchCache { - [searchTerm: string]: { - data: QortalMetadata[]; // List of products for the search term - expiry: number; // Expiry timestamp in milliseconds + [listName: string]: { + searches: { + [searchTerm: string]: QortalMetadata[]; // List of products for each search term + }; + expiry: number; // Expiry timestamp for the whole list }; } + export const mergeUniqueItems = (array1: QortalMetadata[], array2: QortalMetadata[]) => { const mergedArray = [...array1, ...array2]; @@ -46,8 +49,8 @@ interface CacheState { // Search cache actions setResourceCache: (id: string, data: ListItem | false | null, customExpiry?: number) => void; - setSearchCache: (searchTerm: string, data: QortalMetadata[], customExpiry?: number) => void; - getSearchCache: (searchTerm: string) => QortalMetadata[] | null; + setSearchCache: (listName: string, searchTerm: string, data: QortalMetadata[], customExpiry?: number) => void; + getSearchCache: (listName: string, searchTerm: string) => QortalMetadata[] | null; clearExpiredCache: () => void; getResourceCache: (id: string, ignoreExpire?: boolean) => ListItem | false | null; } @@ -76,22 +79,30 @@ export const useCacheStore = create((set, get) => ({ }; }), // Add search results to cache - setSearchCache: (searchTerm, data, customExpiry) => + setSearchCache: (listName, searchTerm, data, customExpiry) => set((state) => { - const expiry = Date.now() + (customExpiry || (5 * 60 * 1000)); // 5mins from now + const expiry = Date.now() + (customExpiry || 5 * 60 * 1000); // 5mins from now + return { searchCache: { ...state.searchCache, - [searchTerm]: { data, expiry }, + [listName]: { + searches: { + ...(state.searchCache[listName]?.searches || {}), // Preserve existing searches + [searchTerm]: data, // Store new search term results + }, + expiry, // Expiry for the entire list + }, }, }; }), + // Retrieve cached search results - getSearchCache: (searchTerm) => { - const cache = get().searchCache[searchTerm]; + getSearchCache: (listName, searchTerm) => { + const cache = get().searchCache[listName]; if (cache && cache.expiry > Date.now()) { - return cache.data; // Return cached search results if not expired + return cache.searches[searchTerm] || null; // Return specific search term results } return null; // Cache expired or doesn't exist }, @@ -100,10 +111,9 @@ export const useCacheStore = create((set, get) => ({ clearExpiredCache: () => set((state) => { const now = Date.now(); - // Filter expired searches const validSearchCache = Object.fromEntries( Object.entries(state.searchCache).filter( - ([, value]) => value.expiry > now + ([, value]) => value.expiry > now // Only keep unexpired lists ) ); return { diff --git a/src/state/lists.ts b/src/state/lists.ts new file mode 100644 index 0000000..6ac58df --- /dev/null +++ b/src/state/lists.ts @@ -0,0 +1,154 @@ +import {create} from "zustand"; +import { QortalMetadata } from "../types/interfaces/resources"; + + +interface ListsState { + [listName: string]: { + name: string; + items: QortalMetadata[]; // ✅ Items are stored as an array + }; +} + +interface ListStore { + lists: ListsState; + + // CRUD Operations + addList: (name: string, items: QortalMetadata[]) => void; + addItem: (listName: string, item: QortalMetadata) => void; + addItems: (listName: string, items: QortalMetadata[]) => void; + updateItem: (listName: string, item: QortalMetadata) => void; + deleteItem: (listName: string, itemKey: string) => void; + deleteList: (listName: string) => void; + + // Getter function + getListByName: (listName: string) => QortalMetadata[] +} + +export const useListStore = create((set, get) => ({ + lists: {}, + + addList: (name, items) => + set((state) => ({ + lists: { + ...state.lists, + [name]: { name, items }, // ✅ Store items as an array + }, + })), + + addItem: (listName, item) => + set((state) => { + if (!state.lists[listName]) return state; // Exit if list doesn't exist + + const itemKey = `${item.name}-${item.service}-${item.identifier}`; + const existingItem = state.lists[listName].items.find( + (existing) => `${existing.name}-${existing.service}-${existing.identifier}` === itemKey + ); + + if (existingItem) return state; // Avoid duplicates + + return { + lists: { + ...state.lists, + [listName]: { + ...state.lists[listName], + items: [...state.lists[listName].items, item], // ✅ Add to array + }, + }, + }; + }), + addItems: (listName, items) => + set((state) => { + + if (!state.lists[listName]) { + console.warn(`List "${listName}" does not exist. Creating a new list.`); + return { + lists: { + ...state.lists, + [listName]: { name: listName, items: [...items] }, // ✅ Create new list if missing + }, + }; + } + + // ✅ Generate existing keys correctly + const existingKeys = new Set( + state.lists[listName].items.map( + (item) => `${item.name}-${item.service}-${item.identifier}` + ) + ); + + + // ✅ Ensure we correctly compare identifiers + const newItems = items.filter((item) => { + const itemKey = `${item.name}-${item.service}-${item.identifier}`; + const isDuplicate = existingKeys.has(itemKey); + + return !isDuplicate; // ✅ Only keep items that are NOT in the existing list + }); + + + if (newItems.length === 0) { + console.warn("No new items were added because they were all considered duplicates."); + return state; // ✅ Prevent unnecessary re-renders if no changes + } + + return { + lists: { + ...state.lists, + [listName]: { + ...state.lists[listName], + items: [...state.lists[listName].items, ...newItems], // ✅ Append only new items + }, + }, + }; + }), + + updateItem: (listName, item) => + set((state) => { + if (!state.lists[listName]) return state; + + const itemKey = `${item.name}-${item.service}-${item.identifier}`; + + return { + lists: { + ...state.lists, + [listName]: { + ...state.lists[listName], + items: state.lists[listName].items.map((existing) => + `${existing.name}-${existing.service}-${existing.identifier}` === itemKey + ? item // ✅ Update item + : existing + ), + }, + }, + }; + }), + + deleteItem: (listName, itemKey) => + set((state) => { + if (!state.lists[listName]) return state; + + return { + lists: { + ...state.lists, + [listName]: { + ...state.lists[listName], + items: state.lists[listName].items.filter( + (item) => `${item.name}-${item.service}-${item.identifier}` !== itemKey + ), // ✅ Remove from array + }, + }, + }; + }), + + deleteList: (listName) => + set((state) => { + if (!state.lists[listName]) return state; + + const updatedLists = { ...state.lists }; + delete updatedLists[listName]; + + return { lists: updatedLists }; + }), + + getListByName: (listName) => get().lists[listName]?.items || [], // ✅ Get a list by name +}));