253 lines
6.6 KiB
TypeScript
253 lines
6.6 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useRef } from 'react';
|
|
import Spinner from './Spinner';
|
|
import SiteGrid from './SiteGrid';
|
|
import {
|
|
MoreComponentsKey,
|
|
MoreComponentsStateForKeyArgument,
|
|
useMoreComponentsState,
|
|
} from '@/state/MoreComponentsState';
|
|
|
|
const MAX_ATTEMPTS_PER_REQUEST = 5;
|
|
const MAX_TOTAL_REQUESTS = 100;
|
|
const RETRY_DELAY_IN_SECONDS = 1;
|
|
|
|
export default function MoreComponents({
|
|
stateKey,
|
|
initialOffset,
|
|
itemsPerRequest,
|
|
getNextComponent,
|
|
label = 'Load more',
|
|
triggerOnView = true,
|
|
prefetch = true,
|
|
itemsClass,
|
|
wrapMoreButtonInSiteGrid,
|
|
debug,
|
|
}: {
|
|
stateKey: MoreComponentsKey
|
|
initialOffset: number
|
|
itemsPerRequest: number
|
|
getNextComponent: (offset: number, limit: number) => Promise<{
|
|
nextComponent?: JSX.Element,
|
|
isFinished?: boolean,
|
|
didFail?: boolean,
|
|
}>
|
|
label?: string
|
|
triggerOnView?: boolean
|
|
prefetch?: boolean
|
|
itemsClass?: string
|
|
wrapMoreButtonInSiteGrid?: boolean
|
|
debug?: boolean
|
|
}) {
|
|
const { state, setStateForKey } = useMoreComponentsState();
|
|
|
|
const setState = useCallback(
|
|
(stateForKey: MoreComponentsStateForKeyArgument) =>
|
|
setStateForKey(stateKey, stateForKey),
|
|
[setStateForKey, stateKey]);
|
|
|
|
useEffect(() => {
|
|
setState({ hasMounted: true });
|
|
}, [setState]);
|
|
|
|
const {
|
|
hasMounted,
|
|
isLoading,
|
|
indexInView,
|
|
finalIndex,
|
|
didReachMaximumRequests,
|
|
components,
|
|
} = state[stateKey];
|
|
|
|
// When prefetching, always stay one request ahead of what's visible
|
|
const furthestIndexToLoad = Math.min(
|
|
prefetch ? (indexInView ?? 0) + 1 : (indexInView ?? 0),
|
|
finalIndex ?? Infinity,
|
|
);
|
|
|
|
const indexToLoad = Math.min(
|
|
components.length,
|
|
furthestIndexToLoad,
|
|
);
|
|
|
|
const attemptsPerRequest = useRef(0);
|
|
const totalRequests = useRef(0);
|
|
|
|
const hasFinalIndexBeenReached =
|
|
finalIndex !== undefined &&
|
|
finalIndex <= components.length - 1;
|
|
|
|
const areAllComponentsVisible =
|
|
(indexInView ?? 0) >= components.length - 1;
|
|
|
|
const showMoreButton =
|
|
isLoading ||
|
|
!hasFinalIndexBeenReached ||
|
|
!areAllComponentsVisible;
|
|
|
|
const currentTimeout = useRef<NodeJS.Timeout>();
|
|
|
|
const attempt = useCallback(() => {
|
|
// Consider creating temp, anonymous function
|
|
// for error handling
|
|
const attemptRetry = () => {
|
|
if (currentTimeout.current) {
|
|
clearTimeout(currentTimeout.current);
|
|
}
|
|
currentTimeout.current =
|
|
setTimeout(attempt, RETRY_DELAY_IN_SECONDS * 1000);
|
|
};
|
|
if (attemptsPerRequest.current < MAX_ATTEMPTS_PER_REQUEST) {
|
|
if (totalRequests.current < MAX_TOTAL_REQUESTS) {
|
|
attemptsPerRequest.current += 1;
|
|
totalRequests.current += 1;
|
|
setState({ isLoading: true });
|
|
if (debug) {
|
|
// eslint-disable-next-line max-len
|
|
console.log(`GETTING INDEX: #${indexToLoad}, ATTEMPT: #${attemptsPerRequest.current}`);
|
|
}
|
|
getNextComponent(
|
|
initialOffset + indexToLoad * itemsPerRequest,
|
|
itemsPerRequest,
|
|
)
|
|
.then(({ nextComponent, isFinished, didFail }) => {
|
|
if (!didFail) {
|
|
attemptsPerRequest.current = 0;
|
|
setState(state => {
|
|
const updatedComponents = [...state.components];
|
|
if (nextComponent) {
|
|
updatedComponents[indexToLoad] = nextComponent;
|
|
}
|
|
return {
|
|
...state,
|
|
...nextComponent && { components: updatedComponents},
|
|
latestIndexLoaded: indexToLoad,
|
|
isLoading: false,
|
|
didReachMaximumRequests: false,
|
|
...isFinished && {
|
|
// Special case when finished on first request
|
|
finalIndex: indexToLoad === 0 ? -1 : indexToLoad,
|
|
},
|
|
};
|
|
});
|
|
} else {
|
|
attemptRetry();
|
|
}
|
|
})
|
|
.catch(attemptRetry);
|
|
} else {
|
|
console.log(
|
|
`Max total attempts reached (${MAX_TOTAL_REQUESTS})`
|
|
);
|
|
setState({
|
|
isLoading: false,
|
|
didReachMaximumRequests: true,
|
|
});
|
|
}
|
|
} else {
|
|
console.log(
|
|
`Max attempts per request reached ${MAX_ATTEMPTS_PER_REQUEST}`
|
|
);
|
|
setState({
|
|
isLoading: false,
|
|
didReachMaximumRequests: true,
|
|
});
|
|
}
|
|
}, [
|
|
setState,
|
|
getNextComponent,
|
|
initialOffset,
|
|
indexToLoad,
|
|
itemsPerRequest,
|
|
debug,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!isLoading &&
|
|
indexToLoad >= components.length
|
|
) {
|
|
attempt();
|
|
}
|
|
}, [isLoading, indexToLoad, attempt, components.length]);
|
|
|
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
|
|
const resetRequestsAndRetry = useCallback(() => {
|
|
attemptsPerRequest.current = 0;
|
|
totalRequests.current = 0;
|
|
setState({ didReachMaximumRequests: false });
|
|
attempt();
|
|
}, [attempt, setState]);
|
|
|
|
const advance = useCallback(() => {
|
|
if (indexInView === undefined) {
|
|
setState({ indexInView: 0 });
|
|
} else if (
|
|
indexInView <= components.length - 1 && (
|
|
finalIndex === undefined ||
|
|
indexInView < finalIndex
|
|
)
|
|
) {
|
|
setState({ indexInView: indexInView + 1});
|
|
}
|
|
}, [components.length, finalIndex, indexInView, setState]);
|
|
|
|
useEffect(() => {
|
|
// Only add observer if button is rendered
|
|
if (buttonRef.current) {
|
|
const observer = new IntersectionObserver(e => {
|
|
if (triggerOnView && e[0].isIntersecting) {
|
|
advance();
|
|
}
|
|
}, {
|
|
root: null,
|
|
threshold: 0,
|
|
});
|
|
observer.observe(buttonRef.current);
|
|
return () => observer.disconnect();
|
|
}
|
|
}, [triggerOnView, advance]);
|
|
|
|
const renderMoreButton = () =>
|
|
<button
|
|
ref={buttonRef}
|
|
className="block w-full subtle"
|
|
onClick={didReachMaximumRequests ? resetRequestsAndRetry : advance}
|
|
disabled={isLoading}
|
|
>
|
|
{isLoading || !hasMounted
|
|
? <span className="relative inline-block translate-y-[3px]">
|
|
<Spinner size={16} />
|
|
</span>
|
|
: didReachMaximumRequests
|
|
? 'Try again …'
|
|
: label}
|
|
</button>;
|
|
|
|
if (debug) {
|
|
console.log({
|
|
indexInView,
|
|
componentsLength: components.length,
|
|
finalIndex,
|
|
hasFinalIndexBeenReached,
|
|
areAllComponentsVisible,
|
|
isLoading,
|
|
});
|
|
}
|
|
|
|
return <>
|
|
<div className="space-y-4">
|
|
<div className={itemsClass}>
|
|
{components.slice(0, (indexInView ?? 0) + 1)}
|
|
</div>
|
|
{showMoreButton && (
|
|
wrapMoreButtonInSiteGrid
|
|
? <SiteGrid contentMain={renderMoreButton()} />
|
|
: renderMoreButton()
|
|
)}
|
|
</div>
|
|
</>;
|
|
}
|