mirror of
https://github.com/Qortal/qapp-core.git
synced 2025-06-14 17:41:20 +00:00
started on list
This commit is contained in:
parent
84218f4fa0
commit
d5f796281b
2069
package-lock.json
generated
2069
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@ -20,13 +20,18 @@
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.0.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^6.4.7",
|
||||
"@mui/material": "^6.4.7",
|
||||
"@tanstack/react-virtual": "^3.13.2",
|
||||
"react": "^19.0.0",
|
||||
"zustand": "^4.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.27",
|
||||
"tsup": "^7.0.0",
|
||||
"typescript": "^5.2.0",
|
||||
"@types/react": "^18.0.27"
|
||||
"typescript": "^5.2.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
43
src/common/ListLoader.tsx
Normal file
43
src/common/ListLoader.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { Box, Typography } from "@mui/material"
|
||||
import { BarSpinner } from "./Spinners/BarSpinner/BarSpinner"
|
||||
|
||||
interface ListLoaderProps {
|
||||
isLoading: boolean;
|
||||
loadingMessage?: string; // Optional message while loading
|
||||
resultsLength: number;
|
||||
noResultsMessage?: string; // Optional message when no results
|
||||
children: React.ReactNode; // Required, to render the list content
|
||||
}
|
||||
|
||||
|
||||
export const ListLoader = ({ isLoading, loadingMessage, resultsLength, children, noResultsMessage }: ListLoaderProps) => {
|
||||
return (
|
||||
<>
|
||||
{isLoading && (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
gap: '20px',
|
||||
alignItems: 'center',
|
||||
overflow: "auto",
|
||||
}}>
|
||||
<BarSpinner width="22px" color="green" />
|
||||
<Typography>{loadingMessage || `Fetching list`}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{!isLoading && resultsLength === 0 && (
|
||||
<Typography
|
||||
style={{
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{noResultsMessage}
|
||||
</Typography>
|
||||
)}
|
||||
{!isLoading && resultsLength > 0 && (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
10
src/common/Spinners/BarSpinner/BarSpinner.tsx
Normal file
10
src/common/Spinners/BarSpinner/BarSpinner.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import './barSpinner.css'
|
||||
export const BarSpinner = ({width = '20px', color}) => {
|
||||
return (
|
||||
<div style={{
|
||||
width,
|
||||
color: color || 'green'
|
||||
}} className="loader-bar"></div>
|
||||
)
|
||||
}
|
19
src/common/Spinners/BarSpinner/barSpinner.css
Normal file
19
src/common/Spinners/BarSpinner/barSpinner.css
Normal file
@ -0,0 +1,19 @@
|
||||
/* HTML: <div class="loader"></div> */
|
||||
.loader-bar {
|
||||
width: 45px;
|
||||
aspect-ratio: .75;
|
||||
--c:no-repeat linear-gradient(currentColor 0 0);
|
||||
background:
|
||||
var(--c) 0% 100%,
|
||||
var(--c) 50% 100%,
|
||||
var(--c) 100% 100%;
|
||||
background-size: 20% 65%;
|
||||
animation: l8 1s infinite linear;
|
||||
}
|
||||
@keyframes l8 {
|
||||
16.67% {background-position: 0% 0% ,50% 100%,100% 100%}
|
||||
33.33% {background-position: 0% 0% ,50% 0% ,100% 100%}
|
||||
50% {background-position: 0% 0% ,50% 0% ,100% 0% }
|
||||
66.67% {background-position: 0% 100%,50% 0% ,100% 0% }
|
||||
83.33% {background-position: 0% 100%,50% 100%,100% 0% }
|
||||
}
|
92
src/common/VirtualizedList.tsx
Normal file
92
src/common/VirtualizedList.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import React, { CSSProperties, useCallback, useRef } from 'react'
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
interface PropsVirtualizedList {
|
||||
list: any[]
|
||||
children: (item: any, index: number) => React.ReactNode;
|
||||
}
|
||||
export const VirtualizedList = ({list, children}: PropsVirtualizedList) => {
|
||||
const parentRef = useRef(null);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: list.length,
|
||||
getItemKey: useCallback((index: number) => (list[index]?.name && list[index]?.name) ?`${list[index].name}-${list[index].identifier}`: list[index]?.id, [list]),
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 80, // Provide an estimated height of items, adjust this as needed
|
||||
overscan: 10, // Number of items to render outside the visible area to improve smoothness
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="List"
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
overflow: "auto",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
height: "0px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: rowVirtualizer.getTotalSize(),
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const index = virtualRow.index;
|
||||
const item = list[index];
|
||||
return (
|
||||
<div
|
||||
data-index={virtualRow.index} //needed for dynamic row height measurement
|
||||
ref={rowVirtualizer.measureElement} //measure dynamic row height
|
||||
key={`${item.name}-${item.identifier}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: "50%", // Move to the center horizontally
|
||||
transform: `translateY(${virtualRow.start}px) translateX(-50%)`, // Adjust for centering
|
||||
width: "100%", // Control width (90% of the parent)
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
overscrollBehavior: "none",
|
||||
flexDirection: "column"
|
||||
}}
|
||||
>
|
||||
{typeof children === "function" ? children(item, index) : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
37
src/components/ResourceList/ItemCardWrapper.tsx
Normal file
37
src/components/ResourceList/ItemCardWrapper.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { Card, CardContent } from "@mui/material";
|
||||
import React, { ReactNode } from "react";
|
||||
|
||||
interface PropsCardWrapper {
|
||||
isInCart: boolean;
|
||||
children: ReactNode | ReactNode[];
|
||||
height?: number;
|
||||
}
|
||||
export const ItemCardWrapper = ({
|
||||
children,
|
||||
isInCart,
|
||||
height,
|
||||
}: PropsCardWrapper) => {
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: height || "auto",
|
||||
|
||||
}}
|
||||
>
|
||||
<CardContent
|
||||
sx={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
p: 1,
|
||||
"&:last-child": { pb: 1 },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
120
src/components/ResourceList/ResourceListDisplay.tsx
Normal file
120
src/components/ResourceList/ResourceListDisplay.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import React, {
|
||||
CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
QortalMetadata,
|
||||
QortalSearchParams,
|
||||
} from "../../types/interfaces/resources";
|
||||
import { useResources } from "../../hooks/useResources";
|
||||
import { VirtualizedList } from "../../common/VirtualizedList";
|
||||
import { ListLoader } from "../../common/ListLoader";
|
||||
import { ListItem, useCacheStore } from "../../state/cache";
|
||||
import { ResourceLoader } from "./ResourceLoader";
|
||||
import { ItemCardWrapper } from "./ItemCardWrapper";
|
||||
|
||||
|
||||
interface PropsResourceListDisplay {
|
||||
params: QortalSearchParams;
|
||||
listItem: (item: ListItem, index: number) => React.ReactNode; // Function type
|
||||
}
|
||||
|
||||
export const ResourceListDisplay = ({
|
||||
params,
|
||||
listItem,
|
||||
}: PropsResourceListDisplay) => {
|
||||
const [list, setList] = useState<QortalMetadata[]>([]);
|
||||
const { fetchResources } = useResources();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const memoizedParams = useMemo(() => JSON.stringify(params), [params]);
|
||||
|
||||
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`
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch resources:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [memoizedParams, fetchResources]); // Added dependencies for re-fetching
|
||||
|
||||
useEffect(() => {
|
||||
getResourceList();
|
||||
}, [getResourceList]); // Runs when dependencies change
|
||||
|
||||
return (
|
||||
<ListLoader
|
||||
noResultsMessage="No results available"
|
||||
resultsLength={list?.length}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Retrieving list. Please wait."
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", flexGrow: isLoading ? 0 : 1 }}>
|
||||
<VirtualizedList list={list}>
|
||||
{(item: QortalMetadata, index: number) => (
|
||||
<ListItemWrapper item={item} index={index} render={listItem} />
|
||||
)}
|
||||
</VirtualizedList>
|
||||
</div>
|
||||
</div>
|
||||
</ListLoader>
|
||||
);
|
||||
};
|
||||
|
||||
interface ListItemWrapperProps {
|
||||
item: QortalMetadata;
|
||||
index: number;
|
||||
render: (item: ListItem, index: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
const ListItemWrapper: React.FC<ListItemWrapperProps> = ({
|
||||
item,
|
||||
index,
|
||||
render,
|
||||
}) => {
|
||||
console.log("Rendering wrapped item:", item, index);
|
||||
const getResourceCache = useCacheStore().getResourceCache;
|
||||
|
||||
const findCachedResource = getResourceCache(
|
||||
`${item.service}-${item.name}-${item.identifier}`,
|
||||
true
|
||||
);
|
||||
if (findCachedResource === null)
|
||||
return (
|
||||
<ItemCardWrapper height={60} isInCart={false}>
|
||||
<ResourceLoader
|
||||
message="Fetching Data..."
|
||||
status="loading"
|
||||
/>
|
||||
</ItemCardWrapper>
|
||||
);
|
||||
if (findCachedResource === false)
|
||||
return (
|
||||
<ItemCardWrapper height={60} isInCart={false}>
|
||||
<ResourceLoader
|
||||
message="Product is unavailble at this moment... Try again later."
|
||||
status="error"
|
||||
/>
|
||||
</ItemCardWrapper>
|
||||
);
|
||||
|
||||
// Example transformation (Modify item if needed)
|
||||
const transformedItem = findCachedResource
|
||||
? findCachedResource
|
||||
: { qortalMetadata: item };
|
||||
|
||||
return <>{render(transformedItem, index)}</>;
|
||||
};
|
44
src/components/ResourceList/ResourceLoader.tsx
Normal file
44
src/components/ResourceList/ResourceLoader.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
|
||||
import { BarSpinner } from "../../common/Spinners/BarSpinner/BarSpinner";
|
||||
interface PropsResourceLoader {
|
||||
status?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const ResourceLoader = ({ status, message }: PropsResourceLoader) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: "20px",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{status === "loading" && (
|
||||
<>
|
||||
<BarSpinner width="22px" color="green" />
|
||||
<Typography>
|
||||
{message || `Fetching Content...`}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<>
|
||||
<ErrorOutlineIcon
|
||||
width="22px"
|
||||
style={{
|
||||
color: "green",
|
||||
}}
|
||||
/>
|
||||
<Typography>
|
||||
{message || `Content Unavailable Now... Please try again later.`}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
164
src/hooks/useResources.tsx
Normal file
164
src/hooks/useResources.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { QortalMetadata, QortalSearchParams } from '../types/interfaces/resources';
|
||||
import { useCacheStore } from '../state/cache';
|
||||
import { RequestQueueWithPromise } from '../utils/queue';
|
||||
import { base64ToUint8Array, uint8ArrayToObject } from '../utils/base64';
|
||||
|
||||
export const requestQueueProductPublishes = new RequestQueueWithPromise(20);
|
||||
export const requestQueueProductPublishesBackup = new RequestQueueWithPromise(5);
|
||||
|
||||
export const useResources = () => {
|
||||
const { setSearchCache, getSearchCache, getResourceCache, setResourceCache } = useCacheStore();
|
||||
|
||||
const fetchIndividualPublish = useCallback(
|
||||
async (item: QortalMetadata) => {
|
||||
try {
|
||||
const cachedProduct = getResourceCache(`${item?.service}-${item?.name}-${item?.identifier}`);
|
||||
if (cachedProduct) return;
|
||||
setResourceCache(`${item?.service}-${item?.name}-${item?.identifier}`, null);
|
||||
let hasFailedToDownload = false
|
||||
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,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
hasFailedToDownload = true
|
||||
}
|
||||
|
||||
if (hasFailedToDownload) {
|
||||
await new Promise((res) => {
|
||||
setTimeout(() => {
|
||||
res(null)
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
try {
|
||||
res = await requestQueueProductPublishesBackup.enqueue((): Promise<string> => {
|
||||
return qortalRequest({
|
||||
action: "FETCH_QDN_RESOURCE",
|
||||
identifier: item?.identifier,
|
||||
encoding: "base64",
|
||||
name: item?.name,
|
||||
service: item?.service,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
setResourceCache(`${item?.service}-${item?.name}-${item?.identifier}`, false);
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (res) {
|
||||
const toUint = base64ToUint8Array(res);
|
||||
const toObject = uint8ArrayToObject(toUint);
|
||||
const fullDataObject = { data: {...toObject}, qortalMetadata: item };
|
||||
setResourceCache(`${item?.service}-${item?.name}-${item?.identifier}`, fullDataObject);
|
||||
return fullDataObject
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
[getResourceCache, setResourceCache]
|
||||
);
|
||||
|
||||
const fetchDataFromResults = useCallback(
|
||||
(responseData: QortalMetadata[]): void => {
|
||||
for (const item of responseData) {
|
||||
fetchIndividualPublish(item);
|
||||
}
|
||||
},
|
||||
[fetchIndividualPublish]
|
||||
);
|
||||
|
||||
const fetchResources = useCallback(
|
||||
async (params: QortalSearchParams): Promise<QortalMetadata[]> => {
|
||||
const cacheKey = generateCacheKey(params);
|
||||
const searchCache = getSearchCache(cacheKey);
|
||||
let responseData = [];
|
||||
|
||||
if (searchCache) {
|
||||
responseData = searchCache;
|
||||
} else {
|
||||
const response = await qortalRequest({
|
||||
action: "SEARCH_QDN_RESOURCES",
|
||||
mode: 'ALL',
|
||||
limit: 20,
|
||||
...params,
|
||||
});
|
||||
if (!response) throw new Error("Unable to fetch resources");
|
||||
responseData = response
|
||||
}
|
||||
setSearchCache(cacheKey, responseData);
|
||||
fetchDataFromResults(responseData);
|
||||
|
||||
return responseData;
|
||||
},
|
||||
[getSearchCache, setSearchCache, fetchDataFromResults]
|
||||
);
|
||||
return {
|
||||
fetchResources,
|
||||
fetchIndividualPublish
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const generateCacheKey = (params: QortalSearchParams): string => {
|
||||
const {
|
||||
identifier,
|
||||
service,
|
||||
query,
|
||||
name,
|
||||
names,
|
||||
keywords,
|
||||
title,
|
||||
description,
|
||||
prefix,
|
||||
exactMatchNames,
|
||||
minLevel,
|
||||
nameListFilter,
|
||||
followedOnly,
|
||||
excludeBlocked,
|
||||
before,
|
||||
after,
|
||||
limit,
|
||||
offset,
|
||||
reverse,
|
||||
mode
|
||||
} = params;
|
||||
|
||||
const keyParts = [
|
||||
`catalog-${service}`,
|
||||
`id-${identifier}`,
|
||||
query && `q-${query}`,
|
||||
name && `n-${name}`,
|
||||
names && `ns-${names.join(",")}`,
|
||||
keywords && `kw-${keywords.join(",")}`,
|
||||
title && `t-${title}`,
|
||||
description && `desc-${description}`,
|
||||
prefix !== undefined && `p-${prefix}`,
|
||||
exactMatchNames !== undefined && `ex-${exactMatchNames}`,
|
||||
minLevel !== undefined && `ml-${minLevel}`,
|
||||
nameListFilter && `nf-${nameListFilter}`,
|
||||
followedOnly !== undefined && `fo-${followedOnly}`,
|
||||
excludeBlocked !== undefined && `eb-${excludeBlocked}`,
|
||||
before !== undefined && `b-${before}`,
|
||||
after !== undefined && `a-${after}`,
|
||||
limit !== undefined && `l-${limit}`,
|
||||
offset !== undefined && `o-${offset}`,
|
||||
reverse !== undefined && `r-${reverse}`,
|
||||
mode !== undefined && `mo-${mode}`,
|
||||
]
|
||||
.filter(Boolean) // Remove undefined or empty values
|
||||
.join("_"); // Join into a string
|
||||
|
||||
return keyParts;
|
||||
};
|
||||
|
7
src/index.css
Normal file
7
src/index.css
Normal file
@ -0,0 +1,7 @@
|
||||
|
||||
:root {
|
||||
line-height: 1.2;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
box-sizing: border-box;
|
||||
}
|
@ -1 +1,3 @@
|
||||
import './index.css'
|
||||
export { GlobalProvider, useGlobal } from "./context/GlobalProvider";
|
||||
export {ResourceListDisplay} from "./components/ResourceList/ResourceListDisplay"
|
113
src/state/cache.ts
Normal file
113
src/state/cache.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { create } from "zustand";
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export const mergeUniqueItems = (array1: QortalMetadata[], array2: QortalMetadata[]) => {
|
||||
const mergedArray = [...array1, ...array2];
|
||||
|
||||
// Use a Map to ensure uniqueness based on `identifier-name`
|
||||
const uniqueMap = new Map();
|
||||
|
||||
mergedArray.forEach(item => {
|
||||
if (item.identifier && item.name && item.service) {
|
||||
const key = `${item.service}-${item.name}-${item.identifier}`;
|
||||
uniqueMap.set(key, item);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(uniqueMap.values()); // Return the unique values
|
||||
};
|
||||
|
||||
export interface ListItem {
|
||||
data?: any
|
||||
qortalMetadata: QortalMetadata
|
||||
}
|
||||
|
||||
|
||||
interface resourceCache {
|
||||
[id: string]: {
|
||||
data: ListItem | false | null; // Cached resource data
|
||||
expiry: number; // Expiry timestamp in milliseconds
|
||||
};
|
||||
}
|
||||
|
||||
interface CacheState {
|
||||
resourceCache: resourceCache;
|
||||
|
||||
searchCache: SearchCache;
|
||||
// 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;
|
||||
clearExpiredCache: () => void;
|
||||
getResourceCache: (id: string, ignoreExpire?: boolean) => ListItem | false | null;
|
||||
}
|
||||
|
||||
export const useCacheStore = create<CacheState>((set, get) => ({
|
||||
resourceCache: {},
|
||||
searchCache: {},
|
||||
orderCache: {},
|
||||
messageCache: {},
|
||||
|
||||
getResourceCache: (id, ignoreExpire) => {
|
||||
const cache = get().resourceCache[id];
|
||||
if (cache && (cache.expiry > Date.now() || ignoreExpire)) {
|
||||
return cache.data; // Return cached product if not expired
|
||||
}
|
||||
return null; // Cache expired or doesn't exist
|
||||
},
|
||||
setResourceCache: (id, data, customExpiry) =>
|
||||
set((state) => {
|
||||
const expiry = Date.now() + (customExpiry || (30 * 60 * 1000)); // 30mins from now
|
||||
return {
|
||||
resourceCache: {
|
||||
...state.resourceCache,
|
||||
[id]: { data, expiry },
|
||||
},
|
||||
};
|
||||
}),
|
||||
// Add search results to cache
|
||||
setSearchCache: (searchTerm, data, customExpiry) =>
|
||||
set((state) => {
|
||||
const expiry = Date.now() + (customExpiry || (5 * 60 * 1000)); // 5mins from now
|
||||
return {
|
||||
searchCache: {
|
||||
...state.searchCache,
|
||||
[searchTerm]: { data, expiry },
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
// Retrieve cached search results
|
||||
getSearchCache: (searchTerm) => {
|
||||
const cache = get().searchCache[searchTerm];
|
||||
if (cache && cache.expiry > Date.now()) {
|
||||
return cache.data; // Return cached search results if not expired
|
||||
}
|
||||
return null; // Cache expired or doesn't exist
|
||||
},
|
||||
|
||||
// Clear expired caches
|
||||
clearExpiredCache: () =>
|
||||
set((state) => {
|
||||
const now = Date.now();
|
||||
// Filter expired searches
|
||||
const validSearchCache = Object.fromEntries(
|
||||
Object.entries(state.searchCache).filter(
|
||||
([, value]) => value.expiry > now
|
||||
)
|
||||
);
|
||||
return {
|
||||
searchCache: validSearchCache,
|
||||
};
|
||||
}),
|
||||
}));
|
87
src/types/interfaces/resources.ts
Normal file
87
src/types/interfaces/resources.ts
Normal file
@ -0,0 +1,87 @@
|
||||
export type Service =
|
||||
| "ARBITRARY_DATA"
|
||||
| "QCHAT_ATTACHMENT"
|
||||
| "ATTACHMENT"
|
||||
| "FILE"
|
||||
| "FILES"
|
||||
| "CHAIN_DATA"
|
||||
| "WEBSITE"
|
||||
| "IMAGE"
|
||||
| "THUMBNAIL"
|
||||
| "QCHAT_IMAGE"
|
||||
| "VIDEO"
|
||||
| "AUDIO"
|
||||
| "QCHAT_AUDIO"
|
||||
| "QCHAT_VOICE"
|
||||
| "VOICE"
|
||||
| "PODCAST"
|
||||
| "BLOG"
|
||||
| "BLOG_POST"
|
||||
| "BLOG_COMMENT"
|
||||
| "DOCUMENT"
|
||||
| "LIST"
|
||||
| "PLAYLIST"
|
||||
| "APP"
|
||||
| "METADATA"
|
||||
| "JSON"
|
||||
| "GIF_REPOSITORY"
|
||||
| "STORE"
|
||||
| "PRODUCT"
|
||||
| "OFFER"
|
||||
| "COUPON"
|
||||
| "CODE"
|
||||
| "PLUGIN"
|
||||
| "EXTENSION"
|
||||
| "GAME"
|
||||
| "ITEM"
|
||||
| "NFT"
|
||||
| "DATABASE"
|
||||
| "SNAPSHOT"
|
||||
| "COMMENT"
|
||||
| "CHAIN_COMMENT"
|
||||
| "MAIL"
|
||||
| "MESSAGE"
|
||||
// Newly added private types
|
||||
| "QCHAT_ATTACHMENT_PRIVATE"
|
||||
| "ATTACHMENT_PRIVATE"
|
||||
| "FILE_PRIVATE"
|
||||
| "IMAGE_PRIVATE"
|
||||
| "VIDEO_PRIVATE"
|
||||
| "AUDIO_PRIVATE"
|
||||
| "VOICE_PRIVATE"
|
||||
| "DOCUMENT_PRIVATE"
|
||||
| "MAIL_PRIVATE"
|
||||
| "MESSAGE_PRIVATE";
|
||||
|
||||
|
||||
export interface QortalMetadata {
|
||||
size: number
|
||||
created: number
|
||||
name: string
|
||||
identifier: string
|
||||
service: Service
|
||||
}
|
||||
|
||||
export interface QortalSearchParams {
|
||||
identifier: string;
|
||||
service: Service;
|
||||
query?: string;
|
||||
name?: string;
|
||||
names?: string[];
|
||||
keywords?: string[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
prefix?: boolean;
|
||||
exactMatchNames?: boolean;
|
||||
minLevel?: number;
|
||||
nameListFilter?: string;
|
||||
followedOnly?: boolean;
|
||||
excludeBlocked?: boolean;
|
||||
before?: number;
|
||||
after?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
reverse?: boolean;
|
||||
mode?: 'ALL' | 'LATEST'
|
||||
}
|
||||
|
121
src/utils/base64.ts
Normal file
121
src/utils/base64.ts
Normal file
@ -0,0 +1,121 @@
|
||||
class Semaphore {
|
||||
private count: number;
|
||||
private waiting: (() => void)[];
|
||||
|
||||
constructor(count: number) {
|
||||
this.count = count;
|
||||
this.waiting = [];
|
||||
}
|
||||
|
||||
acquire(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.count > 0) {
|
||||
this.count--;
|
||||
resolve();
|
||||
} else {
|
||||
this.waiting.push(resolve);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
release(): void {
|
||||
if (this.waiting.length > 0) {
|
||||
const resolve = this.waiting.shift();
|
||||
if (resolve) resolve();
|
||||
} else {
|
||||
this.count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const semaphore = new Semaphore(1)
|
||||
|
||||
export const fileToBase64 = (file : File): Promise<string> => new Promise((resolve, reject) => {
|
||||
const reader = new FileReader(); // Create a new instance
|
||||
semaphore.acquire();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result;
|
||||
semaphore.release();
|
||||
if (typeof dataUrl === 'string') {
|
||||
resolve(dataUrl.split(',')[1]);
|
||||
} else {
|
||||
reject(new Error('Invalid data URL'));
|
||||
}
|
||||
reader.onload = null; // Clear the handler
|
||||
reader.onerror = null; // Clear the handle
|
||||
};
|
||||
reader.onerror = (error) => {
|
||||
semaphore.release();
|
||||
reject(error);
|
||||
reader.onload = null; // Clear the handler
|
||||
reader.onerror = null; // Clear the handle
|
||||
};
|
||||
});
|
||||
|
||||
export function objectToBase64(obj: object): Promise<string> {
|
||||
// Step 1: Convert the object to a JSON string
|
||||
const jsonString = JSON.stringify(obj)
|
||||
// Step 2: Create a Blob from the JSON string
|
||||
const blob = new Blob([jsonString], { type: 'application/json' })
|
||||
// Step 3: Create a FileReader to read the Blob as a base64-encoded string
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
// Remove 'data:application/json;base64,' prefix
|
||||
const base64 = reader.result.replace(
|
||||
'data:application/json;base64,',
|
||||
''
|
||||
)
|
||||
resolve(base64)
|
||||
} else {
|
||||
reject(new Error('Failed to read the Blob as a base64-encoded string'))
|
||||
}
|
||||
}
|
||||
reader.onerror = () => {
|
||||
reject(reader.error)
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
export function base64ToUint8Array(base64: string) {
|
||||
const binaryString = atob(base64)
|
||||
const len = binaryString.length
|
||||
const bytes = new Uint8Array(len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function uint8ArrayToBase64(uint8Array: Uint8Array) {
|
||||
const length = uint8Array.length
|
||||
let binaryString = ''
|
||||
const chunkSize = 1024 * 1024; // Process 1MB at a time
|
||||
for (let i = 0; i < length; i += chunkSize) {
|
||||
const chunkEnd = Math.min(i + chunkSize, length)
|
||||
const chunk = uint8Array.subarray(i, chunkEnd)
|
||||
binaryString += Array.from(chunk, byte => String.fromCharCode(byte)).join('')
|
||||
}
|
||||
return btoa(binaryString)
|
||||
}
|
||||
|
||||
export function uint8ArrayToObject(uint8Array: Uint8Array) {
|
||||
// Decode the byte array using TextDecoder
|
||||
const decoder = new TextDecoder()
|
||||
const jsonString = decoder.decode(uint8Array)
|
||||
// Convert the JSON string back into an object
|
||||
return JSON.parse(jsonString)
|
||||
}
|
||||
|
||||
export function base64ToObject(base64: string){
|
||||
const toUint = base64ToUint8Array(base64);
|
||||
const toObject = uint8ArrayToObject(toUint);
|
||||
|
||||
return toObject
|
||||
}
|
86
src/utils/queue.ts
Normal file
86
src/utils/queue.ts
Normal file
@ -0,0 +1,86 @@
|
||||
type RequestFunction<T> = () => Promise<T>;
|
||||
|
||||
interface QueueItem<T> {
|
||||
request: RequestFunction<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: any) => void;
|
||||
}
|
||||
|
||||
export class RequestQueueWithPromise<T = any> {
|
||||
private queue: QueueItem<T>[] = [];
|
||||
private maxConcurrent: number;
|
||||
private currentlyProcessing: number = 0;
|
||||
private isPaused: boolean = false;
|
||||
|
||||
constructor(maxConcurrent: number = 5) {
|
||||
this.maxConcurrent = maxConcurrent;
|
||||
}
|
||||
|
||||
// Add a request to the queue and return a promise
|
||||
enqueue(request: RequestFunction<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.queue.push({ request, resolve, reject });
|
||||
this.process();
|
||||
});
|
||||
}
|
||||
|
||||
// Process requests in the queue
|
||||
private async process(): Promise<void> {
|
||||
// Process requests only if the queue is not paused
|
||||
if (this.isPaused) return;
|
||||
|
||||
while (this.queue.length > 0 && this.currentlyProcessing < this.maxConcurrent) {
|
||||
this.currentlyProcessing++;
|
||||
|
||||
const { request, resolve, reject } = this.queue.shift()!; // Non-null assertion because length > 0
|
||||
|
||||
try {
|
||||
const response = await request();
|
||||
resolve(response);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
this.currentlyProcessing--;
|
||||
await this.process(); // Continue processing the queue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pause the queue processing
|
||||
pause(): void {
|
||||
this.isPaused = true;
|
||||
}
|
||||
|
||||
// Resume the queue processing
|
||||
resume(): void {
|
||||
this.isPaused = false;
|
||||
this.process(); // Continue processing when resumed
|
||||
}
|
||||
|
||||
// Clear pending requests in the queue
|
||||
clear(): void {
|
||||
this.queue.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function retryTransaction(fn, args, throwError, retries) {
|
||||
let attempt = 0;
|
||||
while (attempt < retries) {
|
||||
try {
|
||||
return await fn(...args);
|
||||
} catch (error) {
|
||||
console.error(`Attempt ${attempt + 1} failed: ${error.message}`);
|
||||
attempt++;
|
||||
if (attempt === retries) {
|
||||
console.error("Max retries reached. Skipping transaction.");
|
||||
if(throwError){
|
||||
throw new Error(error?.message || "Unable to process transaction")
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
await new Promise(res => setTimeout(res, 10000));
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user