Refactor <MoreComponents /> fetching/error handling behavior
This commit is contained in:
parent
9810514c76
commit
e034e3766b
@ -55,8 +55,6 @@ export default async function GridPage() {
|
||||
</div>}
|
||||
sideHiddenOnMobile
|
||||
/>
|
||||
: <Suspense>
|
||||
<PhotosEmptyState />
|
||||
</Suspense>
|
||||
: <PhotosEmptyState />
|
||||
);
|
||||
}
|
||||
|
||||
@ -96,8 +96,8 @@ export default function RootLayout({
|
||||
</ThemeProviderClient>
|
||||
</MoreComponentsProvider>
|
||||
</AppStateProvider>
|
||||
<Analytics />
|
||||
<SpeedInsights />
|
||||
<Analytics debug={false} />
|
||||
<SpeedInsights debug={false} />
|
||||
<PhotoEscapeHandler />
|
||||
<ToasterWithThemes />
|
||||
</body>
|
||||
|
||||
125
src/components/AnimateChildren.tsx
Normal file
125
src/components/AnimateChildren.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { Children, ReactNode, useRef } from 'react';
|
||||
import { Variant, motion } from 'framer-motion';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import usePrefersReducedMotion from '@/utility/usePrefersReducedMotion';
|
||||
|
||||
export type AnimationType = 'none' | 'scale' | 'left' | 'right' | 'bottom';
|
||||
|
||||
export interface AnimationConfig {
|
||||
type?: AnimationType
|
||||
duration?: number
|
||||
staggerDelay?: number
|
||||
scaleOffset?: number
|
||||
distanceOffset?: number
|
||||
}
|
||||
|
||||
interface Props extends AnimationConfig {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
classNameItem?: string
|
||||
animateFromAppState?: boolean
|
||||
animateOnFirstLoadOnly?: boolean
|
||||
staggerOnFirstLoadOnly?: boolean
|
||||
}
|
||||
|
||||
export default function AnimateChildren({
|
||||
children,
|
||||
className,
|
||||
classNameItem,
|
||||
type = 'scale',
|
||||
duration = 0.6,
|
||||
staggerDelay = 0.1,
|
||||
scaleOffset = 0.9,
|
||||
distanceOffset = 20,
|
||||
animateFromAppState,
|
||||
animateOnFirstLoadOnly,
|
||||
staggerOnFirstLoadOnly,
|
||||
}: Props) {
|
||||
const {
|
||||
hasLoaded,
|
||||
nextPhotoAnimation,
|
||||
clearNextPhotoAnimation,
|
||||
} = useAppState();
|
||||
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
|
||||
const hasLoadedInitial = useRef(hasLoaded);
|
||||
const nextPhotoAnimationInitial = useRef(nextPhotoAnimation);
|
||||
|
||||
const shouldAnimate = type !== 'none' &&
|
||||
!prefersReducedMotion &&
|
||||
!(animateOnFirstLoadOnly && hasLoadedInitial.current);
|
||||
const shouldStagger =
|
||||
!(staggerOnFirstLoadOnly && hasLoadedInitial.current);
|
||||
|
||||
const typeResolved = animateFromAppState
|
||||
? (nextPhotoAnimationInitial.current?.type ?? type)
|
||||
: type;
|
||||
|
||||
const durationResolved = animateFromAppState
|
||||
? (nextPhotoAnimationInitial.current?.duration ?? duration)
|
||||
: duration;
|
||||
|
||||
const getInitialVariant = (): Variant => {
|
||||
switch (typeResolved) {
|
||||
case 'left': return {
|
||||
opacity: 0,
|
||||
transform: `translateX(${distanceOffset}px)`,
|
||||
};
|
||||
case 'right': return {
|
||||
opacity: 0,
|
||||
transform: `translateX(${-distanceOffset}px)`,
|
||||
};
|
||||
case 'bottom': return {
|
||||
opacity: 0,
|
||||
transform: `translateY(${distanceOffset}px)`,
|
||||
};
|
||||
default: return {
|
||||
opacity: 0,
|
||||
transform: `translateY(${distanceOffset}px) scale(${scaleOffset})`,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
initial={shouldAnimate ? 'hidden' : false}
|
||||
animate="show"
|
||||
variants={shouldStagger
|
||||
? {
|
||||
show: {
|
||||
transition: {
|
||||
staggerChildren: staggerDelay,
|
||||
},
|
||||
},
|
||||
} : undefined}
|
||||
onAnimationComplete={() => {
|
||||
if (animateFromAppState) {
|
||||
clearNextPhotoAnimation?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Children.toArray(children).map((item, index) =>
|
||||
<motion.div
|
||||
key={index}
|
||||
className={classNameItem}
|
||||
variants={{
|
||||
hidden: getInitialVariant(),
|
||||
show: {
|
||||
opacity: 1,
|
||||
transform: 'translateX(0) translateY(0) scale(1)',
|
||||
},
|
||||
}}
|
||||
transition={{
|
||||
duration: durationResolved,
|
||||
easing: 'easeOut',
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</motion.div>)}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@ -1,10 +1,11 @@
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { ReactNode } from 'react';
|
||||
import { BiErrorAlt } from 'react-icons/bi';
|
||||
|
||||
export default function ErrorNote({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
|
||||
@ -10,8 +10,8 @@ import {
|
||||
} from '@/state/MoreComponentsState';
|
||||
|
||||
const MAX_ATTEMPTS_PER_REQUEST = 5;
|
||||
const MAX_TOTAL_REQUESTS = 500;
|
||||
const RETRY_DELAY_IN_SECONDS = 1.5;
|
||||
const MAX_TOTAL_REQUESTS = 100;
|
||||
const RETRY_DELAY_IN_SECONDS = 1;
|
||||
|
||||
export default function MoreComponents({
|
||||
stateKey,
|
||||
@ -22,6 +22,7 @@ export default function MoreComponents({
|
||||
triggerOnView = true,
|
||||
prefetch = true,
|
||||
wrapMoreButtonInSiteGrid,
|
||||
debug,
|
||||
}: {
|
||||
stateKey: MoreComponentsKey
|
||||
initialOffset: number
|
||||
@ -35,6 +36,7 @@ export default function MoreComponents({
|
||||
triggerOnView?: boolean
|
||||
prefetch?: boolean
|
||||
wrapMoreButtonInSiteGrid?: boolean
|
||||
debug?: boolean
|
||||
}) {
|
||||
const { state, setStateForKey } = useMoreComponentsState();
|
||||
|
||||
@ -44,57 +46,72 @@ export default function MoreComponents({
|
||||
[setStateForKey, stateKey]);
|
||||
|
||||
const {
|
||||
indexToView,
|
||||
indexLoaded,
|
||||
isLoading,
|
||||
lastIndexToLoad,
|
||||
indexInView,
|
||||
finalIndex,
|
||||
didReachMaximumRequests,
|
||||
components,
|
||||
} = state[stateKey];
|
||||
|
||||
// When prefetching, always stay one request ahead of what's visible
|
||||
const indexToLoad = lastIndexToLoad
|
||||
?? (prefetch ? indexToView + 1 : indexToView);
|
||||
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 showMoreButton = (
|
||||
lastIndexToLoad === undefined ||
|
||||
lastIndexToLoad > indexToView
|
||||
) && (
|
||||
attemptsPerRequest.current < MAX_ATTEMPTS_PER_REQUEST &&
|
||||
totalRequests.current < MAX_TOTAL_REQUESTS
|
||||
);
|
||||
const showMoreButton =
|
||||
isLoading ||
|
||||
finalIndex === undefined ||
|
||||
finalIndex >= components.length;
|
||||
|
||||
const currentTimeout = useRef<NodeJS.Timeout>();
|
||||
|
||||
const attempt = useCallback(() => {
|
||||
const handleError = () => {
|
||||
setTimeout(() => {
|
||||
attempt();
|
||||
}, RETRY_DELAY_IN_SECONDS * 1000);
|
||||
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 - 1) * itemsPerRequest,
|
||||
initialOffset + indexToLoad * itemsPerRequest,
|
||||
itemsPerRequest,
|
||||
)
|
||||
.then(({ nextComponent, isFinished, didFail }) => {
|
||||
if (!didFail && nextComponent) {
|
||||
if (!didFail) {
|
||||
attemptsPerRequest.current = 0;
|
||||
setState(state => {
|
||||
const updatedComponents = [...state.components];
|
||||
updatedComponents[indexToLoad] = nextComponent;
|
||||
if (nextComponent) {
|
||||
updatedComponents[indexToLoad] = nextComponent;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
components: updatedComponents,
|
||||
indexLoaded: indexToLoad,
|
||||
...nextComponent && { components: updatedComponents},
|
||||
latestIndexLoaded: indexToLoad,
|
||||
isLoading: false,
|
||||
...isFinished && { lastIndexToLoad: indexToLoad },
|
||||
didReachMaximumRequests: false,
|
||||
...isFinished && { finalIndex: indexToLoad },
|
||||
};
|
||||
});
|
||||
attemptsPerRequest.current = 0;
|
||||
} else {
|
||||
handleError();
|
||||
}
|
||||
@ -104,7 +121,10 @@ export default function MoreComponents({
|
||||
console.log(
|
||||
`Max total attempts reached (${MAX_TOTAL_REQUESTS})`
|
||||
);
|
||||
setState({ isLoading: false });
|
||||
setState({
|
||||
isLoading: false,
|
||||
didReachMaximumRequests: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
@ -112,7 +132,7 @@ export default function MoreComponents({
|
||||
);
|
||||
setState({
|
||||
isLoading: false,
|
||||
haveAttemptsPerRequestBeenExceeded: true,
|
||||
didReachMaximumRequests: true,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
@ -121,32 +141,37 @@ export default function MoreComponents({
|
||||
initialOffset,
|
||||
indexToLoad,
|
||||
itemsPerRequest,
|
||||
debug,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isLoading &&
|
||||
indexToLoad >= indexToView &&
|
||||
indexToLoad > indexLoaded
|
||||
indexToLoad >= components.length
|
||||
) {
|
||||
console.log('Attempting', { isLoading });
|
||||
attempt();
|
||||
}
|
||||
}, [
|
||||
isLoading,
|
||||
indexToLoad,
|
||||
indexToView,
|
||||
indexLoaded,
|
||||
attempt,
|
||||
]);
|
||||
}, [isLoading, indexToLoad, indexInView, 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 (indexToView <= indexLoaded) {
|
||||
setState({ indexToView: indexToView + 1 });
|
||||
if (indexInView === undefined) {
|
||||
setState({ indexInView: 0 });
|
||||
} else if (
|
||||
(indexInView <= components.length) &&
|
||||
(finalIndex === undefined || indexInView < finalIndex)
|
||||
) {
|
||||
setState({ indexInView: indexInView + 1});
|
||||
}
|
||||
}, [setState, indexToView, indexLoaded]);
|
||||
}, [components.length, finalIndex, indexInView, setState]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only add observer if button is rendered
|
||||
@ -168,22 +193,26 @@ export default function MoreComponents({
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className="block w-full subtle"
|
||||
onClick={!triggerOnView ? advance : undefined}
|
||||
disabled={triggerOnView || isLoading}
|
||||
onClick={didReachMaximumRequests ? resetRequestsAndRetry : advance}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading || triggerOnView
|
||||
{isLoading
|
||||
? <span className="relative inline-block translate-y-[3px]">
|
||||
<Spinner size={16} />
|
||||
</span>
|
||||
: label}
|
||||
: didReachMaximumRequests
|
||||
? 'Try again …'
|
||||
: label}
|
||||
</button>;
|
||||
|
||||
return <>
|
||||
<div className="space-y-4">
|
||||
<div>{components.slice(0, indexToView + 1)}</div>
|
||||
{(showMoreButton || true) && wrapMoreButtonInSiteGrid
|
||||
? <SiteGrid contentMain={renderMoreButton()} />
|
||||
: renderMoreButton()}
|
||||
<div>{components.slice(0, (indexInView ?? 0) + 1)}</div>
|
||||
{showMoreButton && (
|
||||
wrapMoreButtonInSiteGrid
|
||||
? <SiteGrid contentMain={renderMoreButton()} />
|
||||
: renderMoreButton()
|
||||
)}
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import MoreComponents from '@/components/MoreComponents';
|
||||
import { getPhotosCached } from '@/photo/cache';
|
||||
import PhotoGrid from './PhotoGrid';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export function MorePhotosGrid({
|
||||
initialOffset,
|
||||
@ -11,32 +12,38 @@ export function MorePhotosGrid({
|
||||
itemsPerRequest: number
|
||||
totalPhotosCount: number
|
||||
}) {
|
||||
const getNextComponent = useCallback(async (
|
||||
offset: number,
|
||||
limit: number,
|
||||
) => {
|
||||
'use server';
|
||||
if (
|
||||
process.env.NODE_ENV === 'development' &&
|
||||
Math.random() < 0.5
|
||||
) {
|
||||
return { didFail: true };
|
||||
}
|
||||
const photos = await getPhotosCached({ limit: offset + limit })
|
||||
.catch(() => undefined);
|
||||
if (!photos) {
|
||||
return { didFail: true };
|
||||
} else {
|
||||
const nextPhotos = photos.slice(offset);
|
||||
return {
|
||||
...nextPhotos.length > 0 && {
|
||||
nextComponent: <PhotoGrid photos={nextPhotos} />,
|
||||
},
|
||||
isFinished: offset + limit >= totalPhotosCount,
|
||||
};
|
||||
}
|
||||
}, [totalPhotosCount]);
|
||||
return (
|
||||
<MoreComponents
|
||||
stateKey="PhotosGrid"
|
||||
label="More photos"
|
||||
initialOffset={initialOffset}
|
||||
itemsPerRequest={itemsPerRequest}
|
||||
getNextComponent={async (offset, limit) => {
|
||||
'use server';
|
||||
if (
|
||||
process.env.NODE_ENV === 'development' &&
|
||||
Math.random() < 0.95
|
||||
) {
|
||||
return { didFail: true };
|
||||
}
|
||||
const photos = await getPhotosCached({ limit: offset + limit })
|
||||
.catch(() => undefined);
|
||||
if (!photos) {
|
||||
return { didFail: true };
|
||||
} else {
|
||||
const nextPhotos = photos.slice(offset);
|
||||
return {
|
||||
nextComponent: <PhotoGrid photos={nextPhotos} />,
|
||||
isFinished: offset + limit >= totalPhotosCount,
|
||||
};
|
||||
}
|
||||
}}
|
||||
getNextComponent={getNextComponent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import MoreComponents from '@/components/MoreComponents';
|
||||
import PhotosLarge from './PhotosLarge';
|
||||
import { getPhotosCached } from '@/photo/cache';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export function MorePhotosRoot({
|
||||
initialOffset,
|
||||
@ -11,32 +12,36 @@ export function MorePhotosRoot({
|
||||
itemsPerRequest: number
|
||||
totalPhotosCount: number
|
||||
}) {
|
||||
const getNextComponent = useCallback(async (
|
||||
offset: number,
|
||||
limit: number,
|
||||
) => {
|
||||
'use server';
|
||||
if (
|
||||
process.env.NODE_ENV === 'development' &&
|
||||
Math.random() < 0.5
|
||||
) {
|
||||
return { didFail: true };
|
||||
}
|
||||
const photos = await getPhotosCached({ limit: offset + limit })
|
||||
.catch(() => undefined);
|
||||
if (!photos) {
|
||||
return { didFail: true };
|
||||
} else {
|
||||
const nextPhotos = photos.slice(offset);
|
||||
return {
|
||||
nextComponent: <PhotosLarge photos={nextPhotos} />,
|
||||
isFinished: offset + limit >= totalPhotosCount,
|
||||
};
|
||||
}
|
||||
}, [totalPhotosCount]);
|
||||
return (
|
||||
<MoreComponents
|
||||
stateKey="PhotosRoot"
|
||||
label="More photos"
|
||||
initialOffset={initialOffset}
|
||||
itemsPerRequest={itemsPerRequest}
|
||||
getNextComponent={async (offset, limit) => {
|
||||
'use server';
|
||||
if (
|
||||
process.env.NODE_ENV === 'development' &&
|
||||
Math.random() < 0.95
|
||||
) {
|
||||
return { didFail: true };
|
||||
}
|
||||
const photos = await getPhotosCached({ limit: offset + limit })
|
||||
.catch(() => undefined);
|
||||
if (!photos) {
|
||||
return { didFail: true };
|
||||
} else {
|
||||
const nextPhotos = photos.slice(offset);
|
||||
return {
|
||||
nextComponent: <PhotosLarge photos={nextPhotos} />,
|
||||
isFinished: offset + limit >= totalPhotosCount,
|
||||
};
|
||||
}
|
||||
}}
|
||||
getNextComponent={getNextComponent}
|
||||
wrapMoreButtonInSiteGrid
|
||||
/>
|
||||
);
|
||||
|
||||
@ -17,7 +17,7 @@ export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
|
||||
export const INFINITE_SCROLL_MULTIPLE_ROOT =
|
||||
process.env.NODE_ENV === 'development' ? 2 : 12;
|
||||
export const INFINITE_SCROLL_MULTIPLE_GRID = HIGH_DENSITY_GRID
|
||||
? process.env.NODE_ENV === 'development' ? 5 : 20
|
||||
? process.env.NODE_ENV === 'development' ? 4 : 20
|
||||
: process.env.NODE_ENV === 'development' ? 4 : 24;
|
||||
|
||||
export const GRID_THUMBNAILS_TO_SHOW_MAX = 12;
|
||||
|
||||
@ -5,20 +5,17 @@ export type MoreComponentsKey =
|
||||
'PhotosGrid';
|
||||
|
||||
export interface MoreComponentsStateForKey {
|
||||
indexToView: number
|
||||
indexLoaded: number
|
||||
isLoading: boolean
|
||||
lastIndexToLoad?: number
|
||||
haveAttemptsPerRequestBeenExceeded: boolean
|
||||
indexInView?: number
|
||||
finalIndex?: number
|
||||
didReachMaximumRequests: boolean
|
||||
components: JSX.Element[]
|
||||
}
|
||||
|
||||
export const createInitialStateForKey =
|
||||
(): MoreComponentsStateForKey => ({
|
||||
indexToView: 0,
|
||||
indexLoaded: 0,
|
||||
isLoading: false,
|
||||
haveAttemptsPerRequestBeenExceeded: false,
|
||||
didReachMaximumRequests: false,
|
||||
components: [],
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user