Use global state in MoreComponents for better forward/back UX
This commit is contained in:
parent
d2f1e6a38c
commit
0d892aad12
@ -3,7 +3,7 @@ import { SpeedInsights } from '@vercel/speed-insights/react';
|
|||||||
import { clsx } from 'clsx/lite';
|
import { clsx } from 'clsx/lite';
|
||||||
import { IBM_Plex_Mono } from 'next/font/google';
|
import { IBM_Plex_Mono } from 'next/font/google';
|
||||||
import { BASE_URL, SITE_DESCRIPTION, SITE_TITLE } from '@/site/config';
|
import { BASE_URL, SITE_DESCRIPTION, SITE_TITLE } from '@/site/config';
|
||||||
import StateProvider from '@/state/AppStateProvider';
|
import AppStateProvider from '@/state/AppStateProvider';
|
||||||
import ThemeProviderClient from '@/site/ThemeProviderClient';
|
import ThemeProviderClient from '@/site/ThemeProviderClient';
|
||||||
import Nav from '@/site/Nav';
|
import Nav from '@/site/Nav';
|
||||||
import ToasterWithThemes from '@/toast/ToasterWithThemes';
|
import ToasterWithThemes from '@/toast/ToasterWithThemes';
|
||||||
@ -13,6 +13,7 @@ import { Suspense } from 'react';
|
|||||||
import FooterClient from '@/site/FooterClient';
|
import FooterClient from '@/site/FooterClient';
|
||||||
import NavClient from '@/site/NavClient';
|
import NavClient from '@/site/NavClient';
|
||||||
import { Metadata } from 'next/types';
|
import { Metadata } from 'next/types';
|
||||||
|
import MoreComponentsProvider from '@/state/MoreComponentsProvider';
|
||||||
|
|
||||||
import '../site/globals.css';
|
import '../site/globals.css';
|
||||||
|
|
||||||
@ -72,27 +73,29 @@ export default function RootLayout({
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className={ibmPlexMono.variable}>
|
<body className={ibmPlexMono.variable}>
|
||||||
<StateProvider>
|
<AppStateProvider>
|
||||||
<ThemeProviderClient>
|
<MoreComponentsProvider>
|
||||||
<main className={clsx(
|
<ThemeProviderClient>
|
||||||
'mx-3 mb-3',
|
<main className={clsx(
|
||||||
'lg:mx-6 lg:mb-6',
|
'mx-3 mb-3',
|
||||||
)}>
|
'lg:mx-6 lg:mb-6',
|
||||||
<Suspense fallback={<NavClient />}>
|
|
||||||
<Nav />
|
|
||||||
</Suspense>
|
|
||||||
<div className={clsx(
|
|
||||||
'min-h-[16rem] sm:min-h-[30rem]',
|
|
||||||
'mb-12',
|
|
||||||
)}>
|
)}>
|
||||||
{children}
|
<Suspense fallback={<NavClient />}>
|
||||||
</div>
|
<Nav />
|
||||||
<Suspense fallback={<FooterClient />}>
|
</Suspense>
|
||||||
<Footer />
|
<div className={clsx(
|
||||||
</Suspense>
|
'min-h-[16rem] sm:min-h-[30rem]',
|
||||||
</main>
|
'mb-12',
|
||||||
</ThemeProviderClient>
|
)}>
|
||||||
</StateProvider>
|
{children}
|
||||||
|
</div>
|
||||||
|
<Suspense fallback={<FooterClient />}>
|
||||||
|
<Footer />
|
||||||
|
</Suspense>
|
||||||
|
</main>
|
||||||
|
</ThemeProviderClient>
|
||||||
|
</MoreComponentsProvider>
|
||||||
|
</AppStateProvider>
|
||||||
<Analytics />
|
<Analytics />
|
||||||
<SpeedInsights />
|
<SpeedInsights />
|
||||||
<PhotoEscapeHandler />
|
<PhotoEscapeHandler />
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { ReactNode, useRef } from 'react';
|
import { ReactNode, useRef } from 'react';
|
||||||
import { Variant, motion } from 'framer-motion';
|
import { Variant, motion } from 'framer-motion';
|
||||||
import { useAppState } from '@/state';
|
import { useAppState } from '@/state/AppState';
|
||||||
import usePrefersReducedMotion from '@/utility/usePrefersReducedMotion';
|
import usePrefersReducedMotion from '@/utility/usePrefersReducedMotion';
|
||||||
|
|
||||||
export type AnimationType = 'none' | 'scale' | 'left' | 'right' | 'bottom';
|
export type AnimationType = 'none' | 'scale' | 'left' | 'right' | 'bottom';
|
||||||
|
|||||||
@ -1,14 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import Spinner from './Spinner';
|
import Spinner from './Spinner';
|
||||||
import SiteGrid from './SiteGrid';
|
import SiteGrid from './SiteGrid';
|
||||||
|
import {
|
||||||
|
MoreComponentsKey,
|
||||||
|
MoreComponentsStateForKeyArgument,
|
||||||
|
useMoreComponentsState,
|
||||||
|
} from '@/state/MoreComponentsState';
|
||||||
|
|
||||||
const MAX_ATTEMPTS_PER_REQUEST = 5;
|
const MAX_ATTEMPTS_PER_REQUEST = 5;
|
||||||
const MAX_TOTAL_REQUESTS = 500;
|
const MAX_TOTAL_REQUESTS = 500;
|
||||||
const RETRY_DELAY_IN_SECONDS = 1.5;
|
const RETRY_DELAY_IN_SECONDS = 1.5;
|
||||||
|
|
||||||
export default function MoreComponents({
|
export default function MoreComponents({
|
||||||
|
stateKey,
|
||||||
initialOffset,
|
initialOffset,
|
||||||
itemsPerRequest,
|
itemsPerRequest,
|
||||||
getNextComponent,
|
getNextComponent,
|
||||||
@ -16,6 +22,7 @@ export default function MoreComponents({
|
|||||||
triggerOnView = true,
|
triggerOnView = true,
|
||||||
prefetch = true,
|
prefetch = true,
|
||||||
}: {
|
}: {
|
||||||
|
stateKey: MoreComponentsKey
|
||||||
initialOffset: number
|
initialOffset: number
|
||||||
itemsPerRequest: number
|
itemsPerRequest: number
|
||||||
getNextComponent: (offset: number, limit: number) => Promise<{
|
getNextComponent: (offset: number, limit: number) => Promise<{
|
||||||
@ -27,11 +34,20 @@ export default function MoreComponents({
|
|||||||
triggerOnView?: boolean
|
triggerOnView?: boolean
|
||||||
prefetch?: boolean
|
prefetch?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [indexToView, setIndexToView] = useState(0);
|
const { state, setStateForKey } = useMoreComponentsState();
|
||||||
const [indexLoaded, setIndexLoaded] = useState(0);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const setState = useCallback(
|
||||||
const [lastIndexToLoad, setLastIndexToLoad] = useState<number>();
|
(stateForKey: MoreComponentsStateForKeyArgument) =>
|
||||||
const [components, setComponents] = useState<JSX.Element[]>([]);
|
setStateForKey(stateKey, stateForKey),
|
||||||
|
[setStateForKey, stateKey]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
indexToView,
|
||||||
|
indexLoaded,
|
||||||
|
isLoading,
|
||||||
|
lastIndexToLoad,
|
||||||
|
components,
|
||||||
|
} = state[stateKey];
|
||||||
|
|
||||||
// When prefetching, always stay one request ahead of what's visible
|
// When prefetching, always stay one request ahead of what's visible
|
||||||
const indexToLoad = lastIndexToLoad
|
const indexToLoad = lastIndexToLoad
|
||||||
@ -53,7 +69,7 @@ export default function MoreComponents({
|
|||||||
if (totalRequests.current < MAX_TOTAL_REQUESTS) {
|
if (totalRequests.current < MAX_TOTAL_REQUESTS) {
|
||||||
attemptsPerRequest.current += 1;
|
attemptsPerRequest.current += 1;
|
||||||
totalRequests.current += 1;
|
totalRequests.current += 1;
|
||||||
setIsLoading(true);
|
setState({ isLoading: true });
|
||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
attempt();
|
attempt();
|
||||||
@ -65,14 +81,17 @@ export default function MoreComponents({
|
|||||||
)
|
)
|
||||||
.then(({ nextComponent, isFinished, didFail }) => {
|
.then(({ nextComponent, isFinished, didFail }) => {
|
||||||
if (!didFail && nextComponent) {
|
if (!didFail && nextComponent) {
|
||||||
setComponents(current => {
|
setState(state => {
|
||||||
const updatedComponents = [...current];
|
const updatedComponents = [...state.components];
|
||||||
updatedComponents[indexToLoad] = nextComponent;
|
updatedComponents[indexToLoad] = nextComponent;
|
||||||
return updatedComponents;
|
return {
|
||||||
|
...state,
|
||||||
|
components: updatedComponents,
|
||||||
|
indexLoaded: indexToLoad,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
setIndexLoaded(indexToLoad);
|
|
||||||
if (isFinished) {
|
if (isFinished) {
|
||||||
setLastIndexToLoad(indexToLoad);
|
setState({ lastIndexToLoad: indexToLoad });
|
||||||
}
|
}
|
||||||
attemptsPerRequest.current = 0;
|
attemptsPerRequest.current = 0;
|
||||||
} else {
|
} else {
|
||||||
@ -80,7 +99,7 @@ export default function MoreComponents({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(handleError)
|
.catch(handleError)
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setState({ isLoading: false }));
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error(
|
||||||
`Max total attempts reached (${MAX_TOTAL_REQUESTS})`
|
`Max total attempts reached (${MAX_TOTAL_REQUESTS})`
|
||||||
@ -92,9 +111,10 @@ export default function MoreComponents({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
setState,
|
||||||
getNextComponent,
|
getNextComponent,
|
||||||
indexToLoad,
|
|
||||||
initialOffset,
|
initialOffset,
|
||||||
|
indexToLoad,
|
||||||
itemsPerRequest,
|
itemsPerRequest,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -118,9 +138,9 @@ export default function MoreComponents({
|
|||||||
|
|
||||||
const advance = useCallback(() => {
|
const advance = useCallback(() => {
|
||||||
if (indexToView <= indexLoaded) {
|
if (indexToView <= indexLoaded) {
|
||||||
setIndexToView(i => i + 1);
|
setState({ indexToView: indexToView + 1 });
|
||||||
}
|
}
|
||||||
}, [indexToView, indexLoaded]);
|
}, [setState, indexToView, indexLoaded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only add observer if button is rendered
|
// Only add observer if button is rendered
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export function MorePhotosLarge({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<MoreComponents
|
<MoreComponents
|
||||||
|
stateKey="PhotosLarge"
|
||||||
label="More photos"
|
label="More photos"
|
||||||
initialOffset={initialOffset}
|
initialOffset={initialOffset}
|
||||||
itemsPerRequest={itemsPerRequest}
|
itemsPerRequest={itemsPerRequest}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { ReactNode } from 'react';
|
|||||||
import { Photo } from '@/photo';
|
import { Photo } from '@/photo';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { AnimationConfig } from '../components/AnimateItems';
|
import { AnimationConfig } from '../components/AnimateItems';
|
||||||
import { useAppState } from '@/state';
|
import { useAppState } from '@/state/AppState';
|
||||||
import { pathForPhoto } from '@/site/paths';
|
import { pathForPhoto } from '@/site/paths';
|
||||||
import { Camera } from '@/camera';
|
import { Camera } from '@/camera';
|
||||||
import { FilmSimulation } from '@/simulation';
|
import { FilmSimulation } from '@/simulation';
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { Photo, getNextPhoto, getPreviousPhoto } from '@/photo';
|
|||||||
import PhotoLink from './PhotoLink';
|
import PhotoLink from './PhotoLink';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { pathForPhoto } from '@/site/paths';
|
import { pathForPhoto } from '@/site/paths';
|
||||||
import { useAppState } from '@/state';
|
import { useAppState } from '@/state/AppState';
|
||||||
import { AnimationConfig } from '@/components/AnimateItems';
|
import { AnimationConfig } from '@/components/AnimateItems';
|
||||||
import { Camera } from '@/camera';
|
import { Camera } from '@/camera';
|
||||||
import { FilmSimulation } from '@/simulation';
|
import { FilmSimulation } from '@/simulation';
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, ReactNode } from 'react';
|
import { useState, useEffect, ReactNode } from 'react';
|
||||||
import { AppStateContext } from '.';
|
import { AppStateContext } from './AppState';
|
||||||
import { AnimationConfig } from '@/components/AnimateItems';
|
import { AnimationConfig } from '@/components/AnimateItems';
|
||||||
import usePathnames from '@/utility/usePathnames';
|
import usePathnames from '@/utility/usePathnames';
|
||||||
|
|
||||||
export default function StateProvider({
|
export default function AppStateProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
|||||||
42
src/state/MoreComponentsProvider.tsx
Normal file
42
src/state/MoreComponentsProvider.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode, useCallback, useState } from 'react';
|
||||||
|
import {
|
||||||
|
MoreComponentsContext,
|
||||||
|
MoreComponentsKey,
|
||||||
|
MoreComponentsState,
|
||||||
|
MORE_COMPONENTS_INITIAL_STATE,
|
||||||
|
MoreComponentsStateForKeyArgument,
|
||||||
|
} from './MoreComponentsState';
|
||||||
|
|
||||||
|
export default function MoreComponentsProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const [state, setState] =
|
||||||
|
useState<MoreComponentsState>(MORE_COMPONENTS_INITIAL_STATE);
|
||||||
|
|
||||||
|
const setStateForKey = useCallback((
|
||||||
|
key: MoreComponentsKey,
|
||||||
|
state: MoreComponentsStateForKeyArgument
|
||||||
|
) => {
|
||||||
|
setState(existingState => ({
|
||||||
|
...existingState,
|
||||||
|
...typeof state === 'function'
|
||||||
|
? { [key]: state(existingState[key]) }
|
||||||
|
: { [key]: { ...existingState[key], ...state } },
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MoreComponentsContext.Provider
|
||||||
|
value={{
|
||||||
|
state,
|
||||||
|
setStateForKey,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MoreComponentsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/state/MoreComponentsState.ts
Normal file
50
src/state/MoreComponentsState.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export type MoreComponentsKey =
|
||||||
|
'PhotosLarge';
|
||||||
|
|
||||||
|
export interface MoreComponentsStateForKey {
|
||||||
|
indexToView: number
|
||||||
|
indexLoaded: number
|
||||||
|
isLoading: boolean
|
||||||
|
lastIndexToLoad?: number
|
||||||
|
components: JSX.Element[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createInitialStateForKey =
|
||||||
|
(): MoreComponentsStateForKey => ({
|
||||||
|
indexToView: 0,
|
||||||
|
indexLoaded: 0,
|
||||||
|
isLoading: false,
|
||||||
|
components: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MoreComponentsState = Record<
|
||||||
|
MoreComponentsKey,
|
||||||
|
MoreComponentsStateForKey
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type MoreComponentsStateForKeyArgument =
|
||||||
|
Partial<MoreComponentsStateForKey> |
|
||||||
|
((existingValue: MoreComponentsStateForKey) => MoreComponentsStateForKey);
|
||||||
|
|
||||||
|
export interface MoreComponentsContext {
|
||||||
|
state: MoreComponentsState
|
||||||
|
setStateForKey: (
|
||||||
|
key: MoreComponentsKey,
|
||||||
|
state: MoreComponentsStateForKeyArgument,
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MORE_COMPONENTS_INITIAL_STATE: MoreComponentsState = {
|
||||||
|
PhotosLarge: createInitialStateForKey(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MoreComponentsContext =
|
||||||
|
createContext<MoreComponentsContext>({
|
||||||
|
state: MORE_COMPONENTS_INITIAL_STATE,
|
||||||
|
setStateForKey: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useMoreComponentsState = () =>
|
||||||
|
useContext(MoreComponentsContext);
|
||||||
Loading…
Reference in New Issue
Block a user