mirror of
https://github.com/Qortal/qapp-core.git
synced 2025-06-19 03:11:20 +00:00
added list funcitons and keep scroll height before expiry
This commit is contained in:
parent
e8866f1585
commit
52f1d570a6
13
package-lock.json
generated
13
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "qapp-core",
|
"name": "qapp-core",
|
||||||
"version": "1.0.0",
|
"version": "1.0.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "qapp-core",
|
"name": "qapp-core",
|
||||||
"version": "1.0.0",
|
"version": "1.0.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
@ -14,12 +14,12 @@
|
|||||||
"@mui/icons-material": "^6.4.7",
|
"@mui/icons-material": "^6.4.7",
|
||||||
"@mui/material": "^6.4.7",
|
"@mui/material": "^6.4.7",
|
||||||
"@tanstack/react-virtual": "^3.13.2",
|
"@tanstack/react-virtual": "^3.13.2",
|
||||||
|
"@types/react": "^19.0.10",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-intersection-observer": "^9.16.0",
|
"react-intersection-observer": "^9.16.0",
|
||||||
"zustand": "^4.3.2"
|
"zustand": "^4.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.0.27",
|
|
||||||
"tsup": "^7.0.0",
|
"tsup": "^7.0.0",
|
||||||
"typescript": "^5.2.0"
|
"typescript": "^5.2.0"
|
||||||
}
|
}
|
||||||
@ -1012,11 +1012,10 @@
|
|||||||
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ=="
|
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ=="
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.18",
|
"version": "19.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz",
|
||||||
"integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==",
|
"integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "qapp-core",
|
"name": "qapp-core",
|
||||||
"version": "1.0.2",
|
"version": "1.0.3",
|
||||||
"description": "Qortal's core React library with global state, UI components, and utilities",
|
"description": "Qortal's core React library with global state, UI components, and utilities",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.mjs",
|
"module": "dist/index.mjs",
|
||||||
@ -25,12 +25,12 @@
|
|||||||
"@mui/icons-material": "^6.4.7",
|
"@mui/icons-material": "^6.4.7",
|
||||||
"@mui/material": "^6.4.7",
|
"@mui/material": "^6.4.7",
|
||||||
"@tanstack/react-virtual": "^3.13.2",
|
"@tanstack/react-virtual": "^3.13.2",
|
||||||
|
"@types/react": "^19.0.10",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-intersection-observer": "^9.16.0",
|
"react-intersection-observer": "^9.16.0",
|
||||||
"zustand": "^4.3.2"
|
"zustand": "^4.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.0.27",
|
|
||||||
"tsup": "^7.0.0",
|
"tsup": "^7.0.0",
|
||||||
"typescript": "^5.2.0"
|
"typescript": "^5.2.0"
|
||||||
},
|
},
|
||||||
|
@ -8,14 +8,18 @@ import React, {
|
|||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { useInView } from "react-intersection-observer";
|
import { useInView } from "react-intersection-observer";
|
||||||
import { QortalMetadata } from "../types/interfaces/resources";
|
import { QortalMetadata } from "../types/interfaces/resources";
|
||||||
|
import { useScrollTrackerRef } from "./useScrollTrackerRef";
|
||||||
|
import { useMergeRefs } from "../hooks/useMergeRefs";
|
||||||
|
|
||||||
interface PropsVirtualizedList {
|
interface PropsVirtualizedList {
|
||||||
list: any[];
|
list: any[];
|
||||||
children: (item: any, index: number) => React.ReactNode;
|
children: (item: any, index: number) => React.ReactNode;
|
||||||
onSeenLastItem?: (item: QortalMetadata)=> void;
|
onSeenLastItem?: (item: QortalMetadata)=> void;
|
||||||
|
listName: string
|
||||||
}
|
}
|
||||||
export const VirtualizedList = ({ list, children, onSeenLastItem }: PropsVirtualizedList) => {
|
export const VirtualizedList = ({ list, children, onSeenLastItem, listName }: PropsVirtualizedList) => {
|
||||||
const parentRef = useRef(null);
|
const parentRef = useRef(null);
|
||||||
|
useScrollTrackerRef(listName, parentRef)
|
||||||
|
|
||||||
const rowVirtualizer = useVirtualizer({
|
const rowVirtualizer = useVirtualizer({
|
||||||
count: list.length,
|
count: list.length,
|
||||||
|
26
src/common/useScrollTracker.tsx
Normal file
26
src/common/useScrollTracker.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export const useScrollTracker = (listName: string) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!listName) return;
|
||||||
|
|
||||||
|
const SCROLL_KEY = `scroll-position-${listName}`;
|
||||||
|
|
||||||
|
// 🔹 Restore saved scroll position for the given list
|
||||||
|
const savedPosition = sessionStorage.getItem(SCROLL_KEY);
|
||||||
|
if (savedPosition) {
|
||||||
|
window.scrollTo(0, parseInt(savedPosition, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
sessionStorage.setItem(SCROLL_KEY, window.scrollY.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🔹 Save scroll position on scroll
|
||||||
|
window.addEventListener("scroll", handleScroll);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", handleScroll);
|
||||||
|
};
|
||||||
|
}, [listName]); // ✅ Only runs when listName changes
|
||||||
|
};
|
30
src/common/useScrollTrackerRef.tsx
Normal file
30
src/common/useScrollTrackerRef.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export const useScrollTrackerRef = (listName: string, ref: React.RefObject<HTMLElement | null>) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!listName || !ref.current) return;
|
||||||
|
|
||||||
|
const SCROLL_KEY = `scroll-position-${listName}`;
|
||||||
|
const savedPosition = sessionStorage.getItem(SCROLL_KEY);
|
||||||
|
|
||||||
|
|
||||||
|
if (savedPosition && ref.current) {
|
||||||
|
ref.current.scrollTop = parseInt(savedPosition, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (ref.current) {
|
||||||
|
sessionStorage.setItem(SCROLL_KEY, ref.current.scrollTop.toString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ref.current.addEventListener("scroll", handleScroll);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.removeEventListener("scroll", handleScroll);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [listName, ref]);
|
||||||
|
};
|
@ -13,10 +13,11 @@ const DynamicGrid: React.FC<DynamicGridProps> = ({
|
|||||||
minItemWidth = 200, // Minimum width per item
|
minItemWidth = 200, // Minimum width per item
|
||||||
gap = 10, // Space between items
|
gap = 10, // Space between items
|
||||||
children,
|
children,
|
||||||
}) => {
|
|
||||||
|
|
||||||
|
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", width: "100%" }}>
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", width: "100%" }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
|
113
src/components/ResourceList/HorizontalPaginationList.tsx
Normal file
113
src/components/ResourceList/HorizontalPaginationList.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import React, { useRef, useEffect, useState, useCallback } from "react";
|
||||||
|
import DynamicGrid from "./DynamicGrid";
|
||||||
|
import LazyLoad from "../../common/LazyLoad";
|
||||||
|
import { ListItem } from "../../state/cache";
|
||||||
|
import { QortalMetadata } from "../../types/interfaces/resources";
|
||||||
|
import { ListItemWrapper } from "./ResourceListDisplay";
|
||||||
|
|
||||||
|
interface HorizontalPaginatedListProps {
|
||||||
|
items: QortalMetadata[];
|
||||||
|
listItem: (item: ListItem, index: number) => React.ReactNode;
|
||||||
|
loaderItem?: (status: "LOADING" | "ERROR") => React.ReactNode;
|
||||||
|
onLoadMore: () => void;
|
||||||
|
maxItems?: number;
|
||||||
|
minItemWidth?: number;
|
||||||
|
gap?: number;
|
||||||
|
isLoading?: boolean;
|
||||||
|
onSeenLastItem?: (listItem: ListItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HorizontalPaginatedList = ({
|
||||||
|
items,
|
||||||
|
listItem,
|
||||||
|
loaderItem,
|
||||||
|
onLoadMore,
|
||||||
|
maxItems = 60,
|
||||||
|
minItemWidth,
|
||||||
|
gap,
|
||||||
|
isLoading,
|
||||||
|
onSeenLastItem,
|
||||||
|
}: HorizontalPaginatedListProps) => {
|
||||||
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [displayedItems, setDisplayedItems] = useState(items);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplayedItems(items);
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const preserveScroll = useCallback((updateFunction: () => void) => {
|
||||||
|
const container = listRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const previousScrollLeft = container.scrollLeft;
|
||||||
|
const previousScrollWidth = container.scrollWidth;
|
||||||
|
|
||||||
|
updateFunction(); // Perform the update (fetch new data, remove old)
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const newScrollWidth = container.scrollWidth;
|
||||||
|
container.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 container = listRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (
|
||||||
|
container.scrollLeft + container.clientWidth >= container.scrollWidth - 10 &&
|
||||||
|
!isLoading
|
||||||
|
) {
|
||||||
|
onLoadMore();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener("scroll", handleScroll);
|
||||||
|
return () => container.removeEventListener("scroll", handleScroll);
|
||||||
|
}, [onLoadMore, isLoading]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={listRef} style={{
|
||||||
|
overflow: 'auto', width: '100%', display: 'flex', flexGrow: 1
|
||||||
|
}}>
|
||||||
|
<DynamicGrid
|
||||||
|
|
||||||
|
minItemWidth={minItemWidth}
|
||||||
|
gap={gap}
|
||||||
|
items={displayedItems.map((item, index) => (
|
||||||
|
<React.Fragment key={`${item?.name}-${item?.service}-${item?.identifier}`}>
|
||||||
|
<ListItemWrapper
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
render={listItem}
|
||||||
|
renderListItemLoader={loaderItem}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
>
|
||||||
|
{!isLoading && displayedItems.length > 0 && (
|
||||||
|
<LazyLoad
|
||||||
|
onLoadMore={() => {
|
||||||
|
onLoadMore();
|
||||||
|
if (onSeenLastItem) {
|
||||||
|
// onSeenLastItem(displayedItems[displayedItems.length - 1]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DynamicGrid>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -2,8 +2,11 @@ import React, {
|
|||||||
CSSProperties,
|
CSSProperties,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
useTransition,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
QortalMetadata,
|
QortalMetadata,
|
||||||
@ -19,6 +22,7 @@ import { Spacer } from "../../common/Spacer";
|
|||||||
import DynamicGrid from "./DynamicGrid";
|
import DynamicGrid from "./DynamicGrid";
|
||||||
import LazyLoad from "../../common/LazyLoad";
|
import LazyLoad from "../../common/LazyLoad";
|
||||||
import { useListStore } from "../../state/lists";
|
import { useListStore } from "../../state/lists";
|
||||||
|
import { useScrollTracker } from "../../common/useScrollTracker";
|
||||||
type Direction = "VERTICAL" | "HORIZONTAL";
|
type Direction = "VERTICAL" | "HORIZONTAL";
|
||||||
|
|
||||||
interface ResourceListStyles {
|
interface ResourceListStyles {
|
||||||
@ -39,7 +43,7 @@ interface DefaultLoaderParams {
|
|||||||
listItemErrorText?: string;
|
listItemErrorText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BaseProps {
|
interface BaseProps {
|
||||||
params: QortalSearchParams;
|
params: QortalSearchParams;
|
||||||
listItem: (item: ListItem, index: number) => React.ReactNode;
|
listItem: (item: ListItem, index: number) => React.ReactNode;
|
||||||
styles?: ResourceListStyles;
|
styles?: ResourceListStyles;
|
||||||
@ -48,7 +52,8 @@ interface BaseProps {
|
|||||||
loaderList?: (status: "LOADING" | "NO_RESULTS") => React.ReactNode;
|
loaderList?: (status: "LOADING" | "NO_RESULTS") => React.ReactNode;
|
||||||
disableVirtualization?: boolean;
|
disableVirtualization?: boolean;
|
||||||
onSeenLastItem?: (listItem: QortalMetadata) => void;
|
onSeenLastItem?: (listItem: QortalMetadata) => void;
|
||||||
listName: string
|
listName: string,
|
||||||
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Restrict `direction` only when `disableVirtualization = false`
|
// ✅ Restrict `direction` only when `disableVirtualization = false`
|
||||||
@ -64,7 +69,7 @@ interface NonVirtualizedProps extends BaseProps {
|
|||||||
|
|
||||||
type PropsResourceListDisplay = VirtualizedProps | NonVirtualizedProps;
|
type PropsResourceListDisplay = VirtualizedProps | NonVirtualizedProps;
|
||||||
|
|
||||||
export const ResourceListDisplay = ({
|
export const MemorizedComponent = ({
|
||||||
params,
|
params,
|
||||||
listItem,
|
listItem,
|
||||||
styles = {
|
styles = {
|
||||||
@ -76,25 +81,40 @@ export const ResourceListDisplay = ({
|
|||||||
disableVirtualization,
|
disableVirtualization,
|
||||||
direction = "VERTICAL",
|
direction = "VERTICAL",
|
||||||
onSeenLastItem,
|
onSeenLastItem,
|
||||||
listName
|
listName,
|
||||||
}: PropsResourceListDisplay) => {
|
}: PropsResourceListDisplay) => {
|
||||||
const { fetchResources } = useResources();
|
const { fetchResources } = useResources();
|
||||||
const { getTemporaryResources } = useCacheStore();
|
const { getTemporaryResources, filterOutDeletedResources } = useCacheStore();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const memoizedParams = useMemo(() => JSON.stringify(params), [params]);
|
const memoizedParams = useMemo(() => JSON.stringify(params), [params]);
|
||||||
const addList = useListStore().addList
|
const addList = useListStore().addList
|
||||||
const addItems = useListStore().addItems
|
const addItems = useListStore().addItems
|
||||||
|
const getListByName = useListStore().getListByName
|
||||||
const list = useListStore().getListByName(listName)
|
const list = useListStore().getListByName(listName)
|
||||||
|
const isListExpired = useCacheStore().isListExpired(listName)
|
||||||
|
const initialized = useRef(false)
|
||||||
|
useScrollTracker(listName);
|
||||||
|
|
||||||
const listToDisplay = useMemo(()=> {
|
const listToDisplay = useMemo(()=> {
|
||||||
return [...getTemporaryResources(listName), ...list]
|
return filterOutDeletedResources([...getTemporaryResources(listName), ...list])
|
||||||
}, [list, listName])
|
}, [list, listName, filterOutDeletedResources, getTemporaryResources])
|
||||||
|
|
||||||
|
|
||||||
const getResourceList = useCallback(async () => {
|
const getResourceList = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
await new Promise((res)=> {
|
||||||
|
setTimeout(() => {
|
||||||
|
res(null)
|
||||||
|
}, 500);
|
||||||
|
})
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const parsedParams = JSON.parse(memoizedParams);
|
const parsedParams = JSON.parse(memoizedParams);
|
||||||
const res = await fetchResources(parsedParams, listName, true); // Awaiting the async function
|
const responseData = await fetchResources(parsedParams, listName, true); // Awaiting the async function
|
||||||
addList(listName, res || [])
|
|
||||||
|
|
||||||
|
|
||||||
|
addList(listName, responseData || []);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch resources:", error);
|
console.error("Failed to fetch resources:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -108,8 +128,8 @@ export const ResourceListDisplay = ({
|
|||||||
const parsedParams = {...(JSON.parse(memoizedParams))};
|
const parsedParams = {...(JSON.parse(memoizedParams))};
|
||||||
parsedParams.before = list.length === 0 ? null : list[list.length - 1]?.created
|
parsedParams.before = list.length === 0 ? null : list[list.length - 1]?.created
|
||||||
parsedParams.offset = null
|
parsedParams.offset = null
|
||||||
const res = await fetchResources(parsedParams, listName); // Awaiting the async function
|
const responseData = await fetchResources(parsedParams, listName); // Awaiting the async function
|
||||||
addItems(listName, res || [])
|
addItems(listName, responseData || [])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch resources:", error);
|
console.error("Failed to fetch resources:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -118,8 +138,12 @@ export const ResourceListDisplay = ({
|
|||||||
}, [memoizedParams, listName, list]);
|
}, [memoizedParams, listName, list]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if(initialized.current) return
|
||||||
|
initialized.current = true
|
||||||
|
if(!isListExpired) return
|
||||||
|
sessionStorage.removeItem(`scroll-position-${listName}`);
|
||||||
getResourceList();
|
getResourceList();
|
||||||
}, [getResourceList]); // Runs when dependencies change
|
}, [getResourceList, isListExpired]); // Runs when dependencies change
|
||||||
|
|
||||||
const disabledVirutalizationStyles: CSSProperties = useMemo(() => {
|
const disabledVirutalizationStyles: CSSProperties = useMemo(() => {
|
||||||
if (styles?.disabledVirutalizationStyles?.parentContainer)
|
if (styles?.disabledVirutalizationStyles?.parentContainer)
|
||||||
@ -133,6 +157,15 @@ export const ResourceListDisplay = ({
|
|||||||
};
|
};
|
||||||
}, [styles?.disabledVirutalizationStyles, styles?.gap, direction]);
|
}, [styles?.disabledVirutalizationStyles, styles?.gap, direction]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const clearOnReload = () => {
|
||||||
|
sessionStorage.removeItem(`scroll-position-${listName}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", clearOnReload);
|
||||||
|
return () => window.removeEventListener("beforeunload", clearOnReload);
|
||||||
|
}, [listName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListLoader
|
<ListLoader
|
||||||
noResultsMessage={
|
noResultsMessage={
|
||||||
@ -155,7 +188,7 @@ export const ResourceListDisplay = ({
|
|||||||
>
|
>
|
||||||
<div style={{ display: "flex", flexGrow: 1 }}>
|
<div style={{ display: "flex", flexGrow: 1 }}>
|
||||||
{!disableVirtualization && (
|
{!disableVirtualization && (
|
||||||
<VirtualizedList list={listToDisplay} onSeenLastItem={(item)=> {
|
<VirtualizedList listName={listName} list={listToDisplay} onSeenLastItem={(item)=> {
|
||||||
getResourceMoreList()
|
getResourceMoreList()
|
||||||
if(onSeenLastItem){
|
if(onSeenLastItem){
|
||||||
onSeenLastItem(item)
|
onSeenLastItem(item)
|
||||||
@ -179,6 +212,7 @@ export const ResourceListDisplay = ({
|
|||||||
)}
|
)}
|
||||||
{disableVirtualization && direction === "HORIZONTAL" && (
|
{disableVirtualization && direction === "HORIZONTAL" && (
|
||||||
<>
|
<>
|
||||||
|
|
||||||
<DynamicGrid
|
<DynamicGrid
|
||||||
minItemWidth={styles?.horizontalStyles?.minItemWidth}
|
minItemWidth={styles?.horizontalStyles?.minItemWidth}
|
||||||
gap={styles?.gap}
|
gap={styles?.gap}
|
||||||
@ -246,7 +280,25 @@ export const ResourceListDisplay = ({
|
|||||||
</div>
|
</div>
|
||||||
</ListLoader>
|
</ListLoader>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function arePropsEqual(
|
||||||
|
prevProps: PropsResourceListDisplay,
|
||||||
|
nextProps: PropsResourceListDisplay
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
prevProps.listName === nextProps.listName &&
|
||||||
|
prevProps.disableVirtualization === nextProps.disableVirtualization &&
|
||||||
|
prevProps.direction === nextProps.direction &&
|
||||||
|
prevProps.onSeenLastItem === nextProps.onSeenLastItem &&
|
||||||
|
JSON.stringify(prevProps.params) === JSON.stringify(nextProps.params) &&
|
||||||
|
JSON.stringify(prevProps.styles) === JSON.stringify(nextProps.styles)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResourceListDisplay = React.memo(MemorizedComponent, arePropsEqual);
|
||||||
|
|
||||||
|
|
||||||
interface ListItemWrapperProps {
|
interface ListItemWrapperProps {
|
||||||
item: QortalMetadata;
|
item: QortalMetadata;
|
||||||
@ -256,7 +308,7 @@ interface ListItemWrapperProps {
|
|||||||
renderListItemLoader?: (status: "LOADING" | "ERROR") => React.ReactNode;
|
renderListItemLoader?: (status: "LOADING" | "ERROR") => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ListItemWrapper: React.FC<ListItemWrapperProps> = ({
|
export const ListItemWrapper: React.FC<ListItemWrapperProps> = ({
|
||||||
item,
|
item,
|
||||||
index,
|
index,
|
||||||
render,
|
render,
|
||||||
|
3
src/global.d.ts
vendored
3
src/global.d.ts
vendored
@ -41,7 +41,8 @@ interface QortalRequestOptions {
|
|||||||
payments?: any[]
|
payments?: any[]
|
||||||
assetId?: number,
|
assetId?: number,
|
||||||
publicKeys?: string[],
|
publicKeys?: string[],
|
||||||
recipient?: string
|
recipient?: string,
|
||||||
|
before?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
declare function qortalRequest(options: QortalRequestOptions): Promise<any>
|
declare function qortalRequest(options: QortalRequestOptions): Promise<any>
|
||||||
|
18
src/hooks/useMergeRefs.tsx
Normal file
18
src/hooks/useMergeRefs.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export function useMergeRefs<T extends HTMLElement>(...refs: (React.Ref<T> | undefined)[]) {
|
||||||
|
const mergedRef = useRef<T>(null as unknown as T); // ✅ Ensures correct type
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refs.forEach((ref) => {
|
||||||
|
if (!ref) return;
|
||||||
|
if (typeof ref === "function") {
|
||||||
|
ref(mergedRef.current);
|
||||||
|
} else if (ref && "current" in ref) {
|
||||||
|
(ref as React.MutableRefObject<T>).current = mergedRef.current;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [refs]);
|
||||||
|
|
||||||
|
return mergedRef;
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
|
|
||||||
QortalMetadata,
|
QortalMetadata,
|
||||||
QortalSearchParams,
|
QortalSearchParams,
|
||||||
} from "../types/interfaces/resources";
|
} from "../types/interfaces/resources";
|
||||||
@ -23,7 +24,8 @@ export const useResources = () => {
|
|||||||
getSearchCache,
|
getSearchCache,
|
||||||
getResourceCache,
|
getResourceCache,
|
||||||
setResourceCache,
|
setResourceCache,
|
||||||
addTemporaryResource
|
addTemporaryResource,
|
||||||
|
markResourceAsDeleted
|
||||||
} = useCacheStore();
|
} = useCacheStore();
|
||||||
const requestControllers = new Map<string, AbortController>();
|
const requestControllers = new Map<string, AbortController>();
|
||||||
|
|
||||||
@ -151,35 +153,54 @@ export const useResources = () => {
|
|||||||
): Promise<QortalMetadata[]> => {
|
): Promise<QortalMetadata[]> => {
|
||||||
if (cancelRequests) {
|
if (cancelRequests) {
|
||||||
cancelAllRequests();
|
cancelAllRequests();
|
||||||
await new Promise((res) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
res(null);
|
|
||||||
}, 250);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = generateCacheKey(params);
|
const cacheKey = generateCacheKey(params);
|
||||||
const searchCache = getSearchCache(listName, cacheKey);
|
const searchCache = getSearchCache(listName, cacheKey);
|
||||||
let responseData = [];
|
|
||||||
|
|
||||||
if (searchCache) {
|
if (searchCache) {
|
||||||
responseData = searchCache;
|
return searchCache;
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
let responseData: QortalMetadata[] = [];
|
||||||
|
let filteredResults: QortalMetadata[] = [];
|
||||||
|
let lastCreated = params.before || null;
|
||||||
|
const targetLimit = params.limit ?? 20; // Use `params.limit` if provided, else default to 20
|
||||||
|
|
||||||
|
while (filteredResults.length < targetLimit) {
|
||||||
const response = await qortalRequest({
|
const response = await qortalRequest({
|
||||||
action: "SEARCH_QDN_RESOURCES",
|
action: "SEARCH_QDN_RESOURCES",
|
||||||
mode: "ALL",
|
mode: "ALL",
|
||||||
limit: 20,
|
|
||||||
...params,
|
...params,
|
||||||
|
limit: targetLimit - filteredResults.length, // Adjust limit dynamically
|
||||||
|
before: lastCreated,
|
||||||
});
|
});
|
||||||
if (!response) throw new Error("Unable to fetch resources");
|
|
||||||
|
if (!response || response.length === 0) {
|
||||||
|
break; // No more data available
|
||||||
|
}
|
||||||
|
|
||||||
responseData = response;
|
responseData = response;
|
||||||
|
const validResults = responseData.filter(item => item.size !== 32);
|
||||||
|
filteredResults = [...filteredResults, ...validResults];
|
||||||
|
|
||||||
|
if (filteredResults.length >= targetLimit) {
|
||||||
|
filteredResults = filteredResults.slice(0, targetLimit);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastCreated = responseData[responseData.length - 1]?.created;
|
||||||
|
if (!lastCreated) break;
|
||||||
}
|
}
|
||||||
setSearchCache(listName, cacheKey, responseData);
|
|
||||||
fetchDataFromResults(responseData);
|
setSearchCache(listName, cacheKey, filteredResults);
|
||||||
|
fetchDataFromResults(filteredResults);
|
||||||
return responseData;
|
|
||||||
|
return filteredResults;
|
||||||
},
|
},
|
||||||
[getSearchCache, setSearchCache, fetchDataFromResults]
|
[getSearchCache, setSearchCache, fetchDataFromResults]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const addNewResources = useCallback(
|
const addNewResources = useCallback(
|
||||||
(listName: string, resources: TemporaryResource[]) => {
|
(listName: string, resources: TemporaryResource[]) => {
|
||||||
@ -209,11 +230,26 @@ export const useResources = () => {
|
|||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const deleteProduct = useCallback(async (qortalMetadata: QortalMetadata)=> {
|
||||||
|
if(!qortalMetadata?.service || !qortalMetadata?.identifier) throw new Error('Missing fields')
|
||||||
|
await qortalRequest({
|
||||||
|
action: "PUBLISH_QDN_RESOURCE",
|
||||||
|
service: qortalMetadata.service,
|
||||||
|
identifier: qortalMetadata.identifier,
|
||||||
|
base64: 'RA==',
|
||||||
|
});
|
||||||
|
markResourceAsDeleted(qortalMetadata)
|
||||||
|
return true
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetchResources,
|
fetchResources,
|
||||||
fetchIndividualPublish,
|
fetchIndividualPublish,
|
||||||
addNewResources,
|
addNewResources,
|
||||||
updateNewResources
|
updateNewResources,
|
||||||
|
deleteProduct
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { QortalMetadata } from "../types/interfaces/resources";
|
import { QortalMetadata } from "../types/interfaces/resources";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
|
||||||
interface SearchCache {
|
interface SearchCache {
|
||||||
@ -43,6 +44,10 @@ interface resourceCache {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DeletedResources {
|
||||||
|
[key: string]: { deleted: true; expiry: number }; // ✅ Added expiry field
|
||||||
|
}
|
||||||
|
|
||||||
interface CacheState {
|
interface CacheState {
|
||||||
resourceCache: resourceCache;
|
resourceCache: resourceCache;
|
||||||
|
|
||||||
@ -56,107 +61,157 @@ interface CacheState {
|
|||||||
getResourceCache: (id: string, ignoreExpire?: boolean) => ListItem | false | null;
|
getResourceCache: (id: string, ignoreExpire?: boolean) => ListItem | false | null;
|
||||||
addTemporaryResource: (listName: string, newResources: QortalMetadata[], customExpiry?: number)=> void;
|
addTemporaryResource: (listName: string, newResources: QortalMetadata[], customExpiry?: number)=> void;
|
||||||
getTemporaryResources:(listName: string)=> QortalMetadata[]
|
getTemporaryResources:(listName: string)=> QortalMetadata[]
|
||||||
|
deletedResources: DeletedResources;
|
||||||
|
markResourceAsDeleted: (item: QortalMetadata) => void;
|
||||||
|
filterOutDeletedResources: (items: QortalMetadata[]) => QortalMetadata[];
|
||||||
|
isListExpired: (listName: string)=> boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCacheStore = create<CacheState>((set, get) => ({
|
export const useCacheStore = create<CacheState>
|
||||||
resourceCache: {},
|
((set, get) => ({
|
||||||
searchCache: {},
|
resourceCache: {},
|
||||||
orderCache: {},
|
searchCache: {},
|
||||||
messageCache: {},
|
deletedResources: {},
|
||||||
|
|
||||||
getResourceCache: (id, ignoreExpire) => {
|
getResourceCache: (id, ignoreExpire) => {
|
||||||
const cache = get().resourceCache[id];
|
const cache = get().resourceCache[id];
|
||||||
if (cache && (cache.expiry > Date.now() || ignoreExpire)) {
|
if (cache) {
|
||||||
return cache.data; // Return cached product if not expired
|
if (cache.expiry > Date.now() || ignoreExpire) {
|
||||||
}
|
return cache.data; // ✅ Return data if not expired
|
||||||
return null; // Cache expired or doesn't exist
|
} else {
|
||||||
},
|
set((state) => {
|
||||||
setResourceCache: (id, data, customExpiry) =>
|
const updatedCache = { ...state.resourceCache };
|
||||||
set((state) => {
|
delete updatedCache[id]; // ✅ Remove expired entry
|
||||||
const expiry = Date.now() + (customExpiry || (30 * 60 * 1000)); // 30mins from now
|
return { resourceCache: updatedCache };
|
||||||
return {
|
});
|
||||||
resourceCache: {
|
}
|
||||||
...state.resourceCache,
|
|
||||||
[id]: { data, expiry },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
// Add search results to cache
|
|
||||||
setSearchCache: (listName, searchTerm, data, customExpiry) =>
|
|
||||||
set((state) => {
|
|
||||||
const expiry = Date.now() + (customExpiry || 5 * 60 * 1000); // 5mins from now
|
|
||||||
|
|
||||||
return {
|
|
||||||
searchCache: {
|
|
||||||
...state.searchCache,
|
|
||||||
[listName]: {
|
|
||||||
searches: {
|
|
||||||
...(state.searchCache[listName]?.searches || {}), // Preserve existing searches
|
|
||||||
[searchTerm]: data, // Store new search term results
|
|
||||||
},
|
|
||||||
temporaryNewResources: state.searchCache[listName]?.temporaryNewResources || [], // Preserve existing temp resources
|
|
||||||
expiry, // Expiry for the entire list
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
|
|
||||||
// Retrieve cached search results
|
|
||||||
getSearchCache: (listName, searchTerm) => {
|
|
||||||
const cache = get().searchCache[listName];
|
|
||||||
if (cache && cache.expiry > Date.now()) {
|
|
||||||
return cache.searches[searchTerm] || null; // Return specific search term results
|
|
||||||
}
|
|
||||||
return null; // Cache expired or doesn't exist
|
|
||||||
},
|
|
||||||
addTemporaryResource: (listName, newResources, customExpiry) =>
|
|
||||||
set((state) => {
|
|
||||||
const expiry = Date.now() + (customExpiry || 5 * 60 * 1000); // Reset expiry
|
|
||||||
|
|
||||||
const existingResources = state.searchCache[listName]?.temporaryNewResources || [];
|
|
||||||
|
|
||||||
// Merge and remove duplicates, keeping the latest by `created` timestamp
|
|
||||||
const uniqueResourcesMap = new Map<string, QortalMetadata>();
|
|
||||||
|
|
||||||
[...existingResources, ...newResources].forEach((item) => {
|
|
||||||
const key = `${item.service}-${item.name}-${item.identifier}`;
|
|
||||||
const existingItem = uniqueResourcesMap.get(key);
|
|
||||||
|
|
||||||
if (!existingItem || item.created > existingItem.created) {
|
|
||||||
uniqueResourcesMap.set(key, item);
|
|
||||||
}
|
}
|
||||||
});
|
return null;
|
||||||
|
},
|
||||||
return {
|
|
||||||
searchCache: {
|
setResourceCache: (id, data, customExpiry) =>
|
||||||
...state.searchCache,
|
set((state) => {
|
||||||
[listName]: {
|
const expiry = Date.now() + (customExpiry || 30 * 60 * 1000); // 30 mins
|
||||||
...state.searchCache[listName],
|
return {
|
||||||
temporaryNewResources: Array.from(uniqueResourcesMap.values()), // Store unique items
|
resourceCache: {
|
||||||
expiry, // Reset expiry
|
...state.resourceCache,
|
||||||
},
|
[id]: { data, expiry },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
setSearchCache: (listName, searchTerm, data, customExpiry) =>
|
||||||
|
set((state) => {
|
||||||
|
const expiry = Date.now() + (customExpiry || 5 * 60 * 1000); // 5 mins
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchCache: {
|
||||||
|
...state.searchCache,
|
||||||
|
[listName]: {
|
||||||
|
searches: {
|
||||||
|
...(state.searchCache[listName]?.searches || {}),
|
||||||
|
[searchTerm]: data,
|
||||||
|
},
|
||||||
|
temporaryNewResources: state.searchCache[listName]?.temporaryNewResources || [],
|
||||||
|
expiry,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
getSearchCache: (listName, searchTerm) => {
|
||||||
|
const cache = get().searchCache[listName];
|
||||||
|
if (cache) {
|
||||||
|
if (cache.expiry > Date.now()) {
|
||||||
|
return cache.searches[searchTerm] || null; // ✅ Return if valid
|
||||||
|
} else {
|
||||||
|
set((state) => {
|
||||||
|
const updatedCache = { ...state.searchCache };
|
||||||
|
delete updatedCache[listName]; // ✅ Remove expired list
|
||||||
|
return { searchCache: updatedCache };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
};
|
|
||||||
|
addTemporaryResource: (listName, newResources, customExpiry) =>
|
||||||
|
set((state) => {
|
||||||
|
const expiry = Date.now() + (customExpiry || 5 * 60 * 1000);
|
||||||
|
|
||||||
|
const existingResources = state.searchCache[listName]?.temporaryNewResources || [];
|
||||||
|
|
||||||
|
// Merge & remove duplicates, keeping the latest by `created` timestamp
|
||||||
|
const uniqueResourcesMap = new Map<string, QortalMetadata>();
|
||||||
|
|
||||||
|
[...existingResources, ...newResources].forEach((item) => {
|
||||||
|
const key = `${item.service}-${item.name}-${item.identifier}`;
|
||||||
|
const existingItem = uniqueResourcesMap.get(key);
|
||||||
|
|
||||||
|
if (!existingItem || item.created > existingItem.created) {
|
||||||
|
uniqueResourcesMap.set(key, item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchCache: {
|
||||||
|
...state.searchCache,
|
||||||
|
[listName]: {
|
||||||
|
...state.searchCache[listName],
|
||||||
|
temporaryNewResources: Array.from(uniqueResourcesMap.values()),
|
||||||
|
expiry,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
getTemporaryResources: (listName: string) => {
|
||||||
|
const cache = get().searchCache[listName];
|
||||||
|
if (cache && cache.expiry > Date.now()) {
|
||||||
|
return cache.temporaryNewResources || [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
|
||||||
|
markResourceAsDeleted: (item) =>
|
||||||
|
set((state) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const expiry = now + 5 * 60 * 1000; // ✅ Expires in 5 minutes
|
||||||
|
|
||||||
|
// ✅ Remove expired deletions before adding a new one
|
||||||
|
const validDeletedResources = Object.fromEntries(
|
||||||
|
Object.entries(state.deletedResources).filter(([_, value]) => value.expiry > now)
|
||||||
|
);
|
||||||
|
|
||||||
|
const key = `${item.service}-${item.name}-${item.identifier}`;
|
||||||
|
return {
|
||||||
|
deletedResources: {
|
||||||
|
...validDeletedResources, // ✅ Keep only non-expired ones
|
||||||
|
[key]: { deleted: true, expiry },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
filterOutDeletedResources: (items) => {
|
||||||
|
const deletedResources = get().deletedResources; // ✅ Read without modifying store
|
||||||
|
return items.filter(
|
||||||
|
(item) => !deletedResources[`${item.service}-${item.name}-${item.identifier}`]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
isListExpired: (listName: string): boolean => {
|
||||||
|
const cache = get().searchCache[listName];
|
||||||
|
return cache ? cache.expiry <= Date.now() : true; // ✅ Expired if expiry timestamp is in the past
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
clearExpiredCache: () =>
|
||||||
|
set((state) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const validSearchCache = Object.fromEntries(
|
||||||
|
Object.entries(state.searchCache).filter(([, value]) => value.expiry > now)
|
||||||
|
);
|
||||||
|
return { searchCache: validSearchCache };
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
getTemporaryResources: (listName: string) => {
|
|
||||||
const cache = get().searchCache[listName];
|
);
|
||||||
if (cache && cache.expiry > Date.now()) {
|
|
||||||
return cache.temporaryNewResources || [];
|
|
||||||
}
|
|
||||||
return []; // Return empty array if expired or doesn't exist
|
|
||||||
},
|
|
||||||
// Clear expired caches
|
|
||||||
clearExpiredCache: () =>
|
|
||||||
set((state) => {
|
|
||||||
const now = Date.now();
|
|
||||||
const validSearchCache = Object.fromEntries(
|
|
||||||
Object.entries(state.searchCache).filter(
|
|
||||||
([, value]) => value.expiry > now // Only keep unexpired lists
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
searchCache: validSearchCache,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
}));
|
|
@ -1,5 +1,6 @@
|
|||||||
import {create} from "zustand";
|
import {create} from "zustand";
|
||||||
import { QortalMetadata } from "../types/interfaces/resources";
|
import { QortalMetadata } from "../types/interfaces/resources";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
|
||||||
interface ListsState {
|
interface ListsState {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user