updated lists

This commit is contained in:
PhilReact 2025-03-13 17:01:55 +02:00
parent 87a4f891a0
commit bb50cfc9a2
7 changed files with 443 additions and 58 deletions

34
src/common/LazyLoad.tsx Normal file
View File

@ -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<Props> = ({ onLoadMore }) => {
const [ref, inView] = useInView({
threshold: 0.7
})
useEffect(() => {
if (inView) {
onLoadMore()
}
}, [inView])
return (
<div
ref={ref}
style={{
display: 'flex',
justifyContent: 'center',
minHeight: '25px'
}}
>
</div>
)
}
export default LazyLoad

View File

@ -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 (
<div

View File

@ -0,0 +1,64 @@
import React, { useEffect, useRef, useState, ReactNode } from "react";
interface DynamicGridProps {
items: ReactNode[]; // ✅ Accepts an array of JSX components
itemWidth?: number; // Minimum width per item
gap?: number; // Spacing between grid items
children: ReactNode
}
const DynamicGrid: React.FC<DynamicGridProps> = ({
items,
itemWidth = 300, // Default min item width
gap = 10, // Default gap between items
children
}) => {
const gridRef = useRef<HTMLDivElement>(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 (
<div style={{ display: "flex", flexDirection: 'column', alignItems: "center", width: "100%" }}>
{/* ✅ Centers the grid inside the parent */}
<div
ref={gridRef}
style={{
display: "grid",
gridTemplateColumns: `repeat(${columns}, minmax(${itemWidth}px, 1fr))`, // ✅ Dynamically calculated columns
gap: `${gap}px`,
width: "100%", // ✅ Ensures grid fills available space
maxWidth: "1200px", // ✅ Optional max width to prevent excessive stretching
margin: "auto", // ✅ Centers the grid horizontally
gridAutoFlow: "row", // ✅ Ensures rows behave correctly
}}
>
{items.map((component, index) => (
<div key={index} style={{ width: "100%", display: "flex", justifyContent: "center" }}>
{component} {/* ✅ Render user-provided component */}
</div>
))}
</div>
{children}
</div>
);
};
export default DynamicGrid;

View File

@ -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<QortalMetadata[]>([]);
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 (
<ListLoader
noResultsMessage={
@ -96,10 +147,15 @@ export const ResourceListDisplay = ({
>
<div style={{ display: "flex", flexGrow: 1 }}>
{!disableVirtualization && (
<VirtualizedList list={list} onSeenLastItem={onSeenLastItem}>
<VirtualizedList list={list} onSeenLastItem={(item)=> {
getResourceMoreList()
if(onSeenLastItem){
onSeenLastItem(item)
}
}}>
{(item: QortalMetadata, index: number) => (
<>
{styles?.gap && <Spacer height={`${styles.gap / 2}rem`} />}
{styles?.gap && <Spacer height={`${styles.gap / 2}px`} />}
<Spacer />
<ListItemWrapper
defaultLoaderParams={defaultLoaderParams}
@ -108,27 +164,20 @@ export const ResourceListDisplay = ({
render={listItem}
renderListItemLoader={loaderItem}
/>
{styles?.gap && <Spacer height={`${styles.gap / 2}rem`} />}
{styles?.gap && <Spacer height={`${styles.gap / 2}px`} />}
</>
)}
</VirtualizedList>
)}
{disableVirtualization && (
<div
style={{
position: "relative",
display: "flex",
flexDirection: "column",
width: "100%",
}}
>
{list?.map((item, index) => {
{disableVirtualization && direction === "HORIZONTAL" && (
<>
<DynamicGrid
gap={styles?.gap}
items={list?.map((item, index) => {
return (
<React.Fragment
key={`${item?.name}-${item?.service}-${item?.identifier}`}
>
{styles?.gap && <Spacer height={`${styles.gap / 2}rem`} />}
<Spacer />
<ListItemWrapper
defaultLoaderParams={defaultLoaderParams}
item={item}
@ -136,10 +185,52 @@ export const ResourceListDisplay = ({
render={listItem}
renderListItemLoader={loaderItem}
/>
{styles?.gap && <Spacer height={`${styles.gap / 2}rem`} />}
</React.Fragment>
);
})}
>
{!isLoading && list?.length > 0 && (
<LazyLoad onLoadMore={()=> {
getResourceMoreList()
if(onSeenLastItem){
onSeenLastItem(list[list?.length - 1])
}
}} />
)}
</DynamicGrid>
</>
)}
{disableVirtualization && direction === "VERTICAL" && (
<div style={disabledVirutalizationStyles}>
{list?.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 && list?.length > 0 && (
<LazyLoad onLoadMore={()=> {
getResourceMoreList()
if(onSeenLastItem){
onSeenLastItem(list[list?.length - 1])
}
}} />
)}
</div>
)}
</div>

View File

@ -9,10 +9,45 @@ export const requestQueueProductPublishesBackup = new RequestQueueWithPromise(5)
export const useResources = () => {
const { setSearchCache, getSearchCache, getResourceCache, setResourceCache } = useCacheStore();
const requestControllers = new Map<string, AbortController>();
const getArbitraryResource = async (url: string, key: string): Promise<string> => {
// ✅ 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<string> => {
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<string> => {
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<QortalMetadata[]> => {
async (params: QortalSearchParams, listName: string, cancelRequests?: boolean): Promise<QortalMetadata[]> => {
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;

View File

@ -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<CacheState>((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<CacheState>((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 {

154
src/state/lists.ts Normal file
View File

@ -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<ListStore>((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
}));