Improve tooltip segues (#272)

* Update GH issue template

* Create custom tooltip display engine

* Fix tooltip cleanup behavior

* Make tooltip position size-aware

* Refine tooltip og positioning

* Refine og tooltip behavior

* Refine og image loading behavior
This commit is contained in:
Sam Becker 2025-06-22 15:03:18 -05:00 committed by GitHub
parent 9a3b60bcd9
commit d2e62a9091
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 432 additions and 399 deletions

View File

@ -7,10 +7,10 @@ assignees: ''
--- ---
**Is your feature request related to a problem? Please describe.** **Is your feature request related to a problem?**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like** **Describe the feature/solution you'd like**
A clear and concise description of what you want to happen. A clear and concise description of what you want to happen.
**Live deployment** **Live deployment**

View File

@ -28,6 +28,7 @@ import { revalidatePath } from 'next/cache';
import RecipeModal from '@/recipe/RecipeModal'; import RecipeModal from '@/recipe/RecipeModal';
import ThemeColors from '@/app/ThemeColors'; import ThemeColors from '@/app/ThemeColors';
import AppTextProvider from '@/i18n/state/AppTextProvider'; import AppTextProvider from '@/i18n/state/AppTextProvider';
import OGTooltipProvider from '@/components/og/OGTooltipProvider';
import '../tailwind.css'; import '../tailwind.css';
@ -95,6 +96,7 @@ export default function RootLayout({
<ThemeColors /> <ThemeColors />
<ThemeProvider attribute="class" defaultTheme={DEFAULT_THEME}> <ThemeProvider attribute="class" defaultTheme={DEFAULT_THEME}>
<SwrConfigClient> <SwrConfigClient>
<OGTooltipProvider>
<div className={clsx( <div className={clsx(
'mx-3 mb-3', 'mx-3 mb-3',
'lg:mx-6 lg:mb-6', 'lg:mx-6 lg:mb-6',
@ -132,6 +134,7 @@ export default function RootLayout({
<Footer /> <Footer />
</div> </div>
<CommandK /> <CommandK />
</OGTooltipProvider>
</SwrConfigClient> </SwrConfigClient>
<Analytics debug={false} /> <Analytics debug={false} />
<SpeedInsights debug={false} /> <SpeedInsights debug={false} />

View File

@ -2,7 +2,7 @@
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRange } from '@/photo';
import { pathForCamera, pathForCameraImage } from '@/app/paths'; import { pathForCamera, pathForCameraImage } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/og/OGTile'; import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
import { Camera } from '.'; import { Camera } from '.';
import { descriptionForCameraPhotos, titleForCamera } from './meta'; import { descriptionForCameraPhotos, titleForCamera } from './meta';
import { useAppText } from '@/i18n/state/client'; import { useAppText } from '@/i18n/state/client';
@ -10,29 +10,22 @@ import { useAppText } from '@/i18n/state/client';
export default function CameraOGTile({ export default function CameraOGTile({
camera, camera,
photos, photos,
loadingState: loadingStateExternal,
riseOnHover,
onLoad,
onFail,
retryTime,
count, count,
dateRange, dateRange,
...props
}: { }: {
camera: Camera camera: Camera
photos: Photo[] photos: Photo[]
loadingState?: OGLoadingState
onLoad?: () => void
onFail?: () => void
riseOnHover?: boolean
retryTime?: number
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { } & OGTilePropsCore) {
const appText = useAppText(); const appText = useAppText();
return ( return (
<OGTile {...{ <OGTile {...{
...props,
title: titleForCamera(camera, photos, appText, count), title: titleForCamera(camera, photos, appText, count),
description: descriptionForCameraPhotos( description:
descriptionForCameraPhotos(
photos, photos,
appText, appText,
true, true,
@ -41,11 +34,6 @@ export default function CameraOGTile({
), ),
path: pathForCamera(camera), path: pathForCamera(camera),
pathImage: pathForCameraImage(camera), pathImage: pathForCameraImage(camera),
loadingState: loadingStateExternal,
onLoad,
onFail,
riseOnHover,
retryTime,
}}/> }}/>
); );
}; };

View File

@ -1,49 +1,41 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import Spinner from '@/components/Spinner'; import Spinner from '@/components/Spinner';
import { IMAGE_OG_DIMENSION } from '@/image-response'; import { IMAGE_OG_DIMENSION } from '@/image-response';
import { TbPhotoQuestion } from 'react-icons/tb'; import { TbPhotoQuestion } from 'react-icons/tb';
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed'; type LoadingState = 'loading' | 'loaded' | 'failed';
export default function OGLoaderImage({ export default function OGLoaderImage({
title, title,
path, path,
loadingState: loadingStateExternal,
onLoad,
onFail,
retryTime, retryTime,
className, className,
enabled = true,
}: { }: {
title: string title: string
path: string path: string
loadingState?: OGLoadingState
onLoad?: () => void
onFail?: () => void
retryTime?: number retryTime?: number
className?: string className?: string
enabled?: boolean
}) { }) {
const ref = useRef<HTMLImageElement>(null);
const [loadingStateInternal, setLoadingStateInternal] = const [loadingState, setLoadingState] = useState<LoadingState>('loading');
useState(loadingStateExternal ?? 'unloaded');
const loadingState = loadingStateExternal ?? loadingStateInternal;
useEffect(() => {
if (
!loadingStateExternal &&
loadingStateInternal === 'unloaded'
) {
setLoadingStateInternal('loading');
}
}, [loadingStateExternal, loadingStateInternal]);
const { width, height, aspectRatio } = IMAGE_OG_DIMENSION; const { width, height, aspectRatio } = IMAGE_OG_DIMENSION;
useEffect(() => {
if (!ref.current?.complete) {
setLoadingState('loading');
}
}, [path]);
return ( return (
<div <div
key={path}
className={clsx( className={clsx(
'relative', 'relative',
className, className,
@ -67,6 +59,7 @@ export default function OGLoaderImage({
</div>} </div>}
{(loadingState === 'loading' || loadingState === 'loaded') && {(loadingState === 'loading' || loadingState === 'loaded') &&
<img <img
ref={ref}
alt={title} alt={title}
className={clsx( className={clsx(
'absolute top-0 left-0 right-0 bottom-0 z-0', 'absolute top-0 left-0 right-0 bottom-0 z-0',
@ -74,25 +67,18 @@ export default function OGLoaderImage({
loadingState === 'loading' && 'opacity-0', loadingState === 'loading' && 'opacity-0',
'transition-opacity', 'transition-opacity',
)} )}
src={path} src={enabled ? path : ''}
width={width} width={width}
height={height} height={height}
onLoad={() => { onLoadStart={() => setLoadingState('loading')}
if (onLoad) { onLoad={() => setLoadingState('loaded')}
onLoad(); onError={e => {
} else { setLoadingState('failed');
setLoadingStateInternal('loaded');
}
}}
onError={() => {
if (onFail) {
onFail();
} else {
setLoadingStateInternal('failed');
}
if (retryTime !== undefined) { if (retryTime !== undefined) {
setLoadingState('loading');
setTimeout(() => { setTimeout(() => {
setLoadingStateInternal('loading'); e.currentTarget.src = '';
e.currentTarget.src = path;
}, retryTime); }, retryTime);
} }
}} }}

View File

@ -6,7 +6,10 @@ import Link from 'next/link';
import useVisible from '@/utility/useVisible'; import useVisible from '@/utility/useVisible';
import OGLoaderImage from './OGLoaderImage'; import OGLoaderImage from './OGLoaderImage';
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed'; export type OGTilePropsCore = Omit<
ComponentProps<typeof OGTile>,
'title' | 'description' | 'path' | 'pathImage'
>;
export default function OGTile({ export default function OGTile({
path, path,

View File

@ -1,8 +1,16 @@
import { ComponentProps, ReactNode } from 'react'; import { ComponentProps, ReactNode, useRef, useEffect } from 'react';
import TooltipPrimitive from '../primitives/TooltipPrimitive';
import OGLoaderImage from './OGLoaderImage'; import OGLoaderImage from './OGLoaderImage';
import { IMAGE_OG_DIMENSION } from '@/image-response'; import { IMAGE_OG_DIMENSION } from '@/image-response';
import clsx from 'clsx/lite'; import clsx from 'clsx/lite';
import { useOGTooltipState } from './state';
import useSupportsHover from '@/utility/useSupportsHover';
const { aspectRatio } = IMAGE_OG_DIMENSION;
const width = 300;
const height = width / aspectRatio;
const offsetAbove = -1;
const offsetBelow = -6;
export default function OGTooltip({ export default function OGTooltip({
children, children,
@ -12,15 +20,21 @@ export default function OGTooltip({
children :ReactNode children :ReactNode
caption?: ReactNode caption?: ReactNode
} & ComponentProps<typeof OGLoaderImage>) { } & ComponentProps<typeof OGLoaderImage>) {
const { aspectRatio } = IMAGE_OG_DIMENSION; const ref = useRef<HTMLDivElement>(null);
return (
<TooltipPrimitive const { showTooltip, dismissTooltip } = useOGTooltipState();
className="max-w-none p-1!"
classNameTrigger="max-w-full" const supportsHover = useSupportsHover();
disableHoverableContent
content={<div useEffect(() => {
const trigger = ref.current;
return () => dismissTooltip?.(trigger);
}, [dismissTooltip]);
const content =
<div
className="relative" className="relative"
style={{ width: 300, aspectRatio }} style={{ width, height }}
> >
<OGLoaderImage <OGLoaderImage
{...props} {...props}
@ -38,9 +52,21 @@ export default function OGTooltip({
)}> )}>
{caption} {caption}
</div>} </div>}
</div>} </div>;
return (
<div
className="max-w-full"
ref={ref}
onMouseEnter={() => supportsHover &&
showTooltip?.(
ref.current,
{ content, width, height, offsetAbove, offsetBelow },
)}
onMouseLeave={() => supportsHover &&
dismissTooltip?.(ref.current)}
> >
{children} {children}
</TooltipPrimitive> </div>
); );
} }

View File

@ -0,0 +1,131 @@
'use client';
import {
CSSProperties,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { OGTooltipContext, Tooltip } from './state';
import { AnimatePresence, motion } from 'framer-motion';
import MenuSurface from '../primitives/MenuSurface';
const DELAY_INITIAL_HOVER = 200;
const DELAY_DISMISS = 200;
const VIEWPORT_SAFE_AREA = 12;
const TOOLTIP_MARGIN = 12;
export default function OGTooltipProvider({
children,
}: {
children: ReactNode
}) {
const [currentTooltip, setCurrentTooltip] = useState<Tooltip>();
const [tooltipStyle, setTooltipStyle] = useState<CSSProperties>();
const currentTriggerRef = useRef<HTMLElement>(null);
const timeoutInitialHoverRef = useRef<NodeJS.Timeout>(undefined);
const timeoutDismissRef = useRef<NodeJS.Timeout>(undefined);
const clearTimeouts = useCallback(() => {
clearTimeout(timeoutInitialHoverRef.current);
timeoutInitialHoverRef.current = undefined;
clearTimeout(timeoutDismissRef.current);
timeoutDismissRef.current = undefined;
}, []);
const clearState = useCallback((delay = 0) => {
clearTimeouts();
if (delay) {
timeoutDismissRef.current = setTimeout(() => {
setCurrentTooltip(undefined);
currentTriggerRef.current = null;
}, delay);
} else {
setCurrentTooltip(undefined);
currentTriggerRef.current = null;
}
}, [clearTimeouts]);
const showTooltip = useCallback((
_trigger: HTMLElement | null,
tooltip: Tooltip,
) => {
if (_trigger) {
currentTriggerRef.current = _trigger;
const displayTooltip = () => {
// Update current trigger ref on display
currentTriggerRef.current = _trigger;
setCurrentTooltip(tooltip);
const trigger = _trigger.getBoundingClientRect();
const top =
trigger.top - (tooltip.height + TOOLTIP_MARGIN) < VIEWPORT_SAFE_AREA
// Position below trigger
? trigger.bottom + TOOLTIP_MARGIN + tooltip.offsetBelow
// Position above trigger
: trigger.top - (tooltip.height + TOOLTIP_MARGIN)
+ tooltip.offsetAbove;
const horizontalOffset =
// eslint-disable-next-line max-len
window.innerWidth - (trigger.left + tooltip.width) < VIEWPORT_SAFE_AREA
? { right: VIEWPORT_SAFE_AREA }
: { left: trigger.left };
setTooltipStyle({ top, ...horizontalOffset });
clearTimeouts();
};
if (currentTooltip) {
// Don't apply delay if tooltip's already visible
displayTooltip();
} else {
timeoutInitialHoverRef.current =
setTimeout(displayTooltip, DELAY_INITIAL_HOVER);
}
}
}, [currentTooltip, clearTimeouts]);
const dismissTooltip = useCallback((trigger: HTMLElement | null) => {
if (trigger === currentTriggerRef.current) {
clearState(DELAY_DISMISS);
}
}, [clearState]);
useEffect(() => {
const onWindowChange = () => clearState(0);
window.addEventListener('mouseup', onWindowChange);
window.addEventListener('mousewheel', onWindowChange);
window.addEventListener('resize', onWindowChange);
return () => {
window.removeEventListener('mouseup', onWindowChange);
window.removeEventListener('mousewheel', onWindowChange);
window.removeEventListener('resize', onWindowChange);
};
}, [clearState]);
return (
<OGTooltipContext.Provider value={{ showTooltip, dismissTooltip }}>
<div className="relative inset-0 z-50 pointer-events-none">
<AnimatePresence>
{currentTooltip &&
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
layoutId="tooltip"
className="fixed"
style={tooltipStyle}
>
<MenuSurface className="max-w-none p-1!">
{currentTooltip.content}
</MenuSurface>
</motion.div>}
</AnimatePresence>
</div>
{children}
</OGTooltipContext.Provider>
);
}

View File

@ -0,0 +1,18 @@
import { createContext, ReactNode, use } from 'react';
export type Tooltip = {
content: ReactNode
width: number
height: number
offsetAbove: number
offsetBelow: number
}
export type OGTooltipState = {
showTooltip?: (trigger: HTMLElement | null, tooltip: Tooltip) => void
dismissTooltip?: (trigger: HTMLElement | null) => void
}
export const OGTooltipContext = createContext<OGTooltipState>({});
export const useOGTooltipState = () => use(OGTooltipContext);

View File

@ -5,44 +5,31 @@ import {
pathForFilm, pathForFilm,
pathForFilmImage, pathForFilmImage,
} from '@/app/paths'; } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/og/OGTile'; import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
import { descriptionForFilmPhotos, titleForFilm } from '.'; import { descriptionForFilmPhotos, titleForFilm } from '.';
import { useAppText } from '@/i18n/state/client'; import { useAppText } from '@/i18n/state/client';
export default function FilmOGTile({ export default function FilmOGTile({
film, film,
photos, photos,
loadingState: loadingStateExternal,
riseOnHover,
onLoad,
onFail,
retryTime,
count, count,
dateRange, dateRange,
...props
}: { }: {
film: string film: string
photos: Photo[] photos: Photo[]
loadingState?: OGLoadingState
onLoad?: () => void
onFail?: () => void
riseOnHover?: boolean
retryTime?: number
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { } & OGTilePropsCore) {
const appText = useAppText(); const appText = useAppText();
return ( return (
<OGTile {...{ <OGTile {...{
...props,
title: titleForFilm(film, photos, appText, count), title: titleForFilm(film, photos, appText, count),
description: description:
descriptionForFilmPhotos(photos, appText, true, count, dateRange), descriptionForFilmPhotos(photos, appText, true, count, dateRange),
path: pathForFilm(film), path: pathForFilm(film),
pathImage: pathForFilmImage(film), pathImage: pathForFilmImage(film),
loadingState: loadingStateExternal,
onLoad,
onFail,
riseOnHover,
retryTime,
}}/> }}/>
); );
}; };

View File

@ -5,36 +5,29 @@ import {
pathForFocalLength, pathForFocalLength,
pathForFocalLengthImage, pathForFocalLengthImage,
} from '@/app/paths'; } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/og/OGTile'; import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
import { descriptionForFocalLengthPhotos, titleForFocalLength } from '.'; import { descriptionForFocalLengthPhotos, titleForFocalLength } from '.';
import { useAppText } from '@/i18n/state/client'; import { useAppText } from '@/i18n/state/client';
export default function FocalLengthOGTile({ export default function FocalLengthOGTile({
focal, focal,
photos, photos,
loadingState: loadingStateExternal,
riseOnHover,
onLoad,
onFail,
retryTime,
count, count,
dateRange, dateRange,
...props
}: { }: {
focal: number focal: number
photos: Photo[] photos: Photo[]
loadingState?: OGLoadingState
onLoad?: () => void
onFail?: () => void
riseOnHover?: boolean
retryTime?: number
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { } & OGTilePropsCore) {
const appText = useAppText(); const appText = useAppText();
return ( return (
<OGTile {...{ <OGTile {...{
...props,
title: titleForFocalLength(focal, photos, appText, count), title: titleForFocalLength(focal, photos, appText, count),
description: descriptionForFocalLengthPhotos( description:
descriptionForFocalLengthPhotos(
photos, photos,
appText, appText,
true, true,
@ -43,11 +36,6 @@ export default function FocalLengthOGTile({
), ),
path: pathForFocalLength(focal), path: pathForFocalLength(focal),
pathImage: pathForFocalLengthImage(focal), pathImage: pathForFocalLengthImage(focal),
loadingState: loadingStateExternal,
onLoad,
onFail,
riseOnHover,
retryTime,
}}/> }}/>
); );
}; };

View File

@ -101,6 +101,7 @@ export const TEXT = {
setupIncomplete: 'সেটআপ সম্পূর্ণ করুন', setupIncomplete: 'সেটআপ সম্পূর্ণ করুন',
setupSignIn: 'ছবি আপলোড করতে সাইন ইন করুন', setupSignIn: 'ছবি আপলোড করতে সাইন ইন করুন',
setupFirstPhoto: 'আপনার প্রথম ছবি যোগ করুন', setupFirstPhoto: 'আপনার প্রথম ছবি যোগ করুন',
// eslint-disable-next-line max-len
setupConfig: 'পরিবেশ ভেরিয়েবল সম্পাদনা করে সাইটের নাম এবং অন্যান্য কনফিগারেশন পরিবর্তন করুন', setupConfig: 'পরিবেশ ভেরিয়েবল সম্পাদনা করে সাইটের নাম এবং অন্যান্য কনফিগারেশন পরিবর্তন করুন',
}, },
misc: { misc: {

View File

@ -1,6 +1,6 @@
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRange } from '@/photo';
import { pathForLens, pathForLensImage } from '@/app/paths'; import { pathForLens, pathForLensImage } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/og/OGTile'; import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
import { Lens } from '.'; import { Lens } from '.';
import { titleForLens, descriptionForLensPhotos } from './meta'; import { titleForLens, descriptionForLensPhotos } from './meta';
import { useAppText } from '@/i18n/state/client'; import { useAppText } from '@/i18n/state/client';
@ -8,27 +8,19 @@ import { useAppText } from '@/i18n/state/client';
export default function LensOGTile({ export default function LensOGTile({
lens, lens,
photos, photos,
loadingState: loadingStateExternal,
riseOnHover,
onLoad,
onFail,
retryTime,
count, count,
dateRange, dateRange,
...props
}: { }: {
lens: Lens lens: Lens
photos: Photo[] photos: Photo[]
loadingState?: OGLoadingState
onLoad?: () => void
onFail?: () => void
riseOnHover?: boolean
retryTime?: number
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { } & OGTilePropsCore) {
const appText = useAppText(); const appText = useAppText();
return ( return (
<OGTile {...{ <OGTile {...{
...props,
title: titleForLens(lens, photos, appText, count), title: titleForLens(lens, photos, appText, count),
description: descriptionForLensPhotos( description: descriptionForLensPhotos(
photos, photos,
@ -39,11 +31,6 @@ export default function LensOGTile({
), ),
path: pathForLens(lens), path: pathForLens(lens),
pathImage: pathForLensImage(lens), pathImage: pathForLensImage(lens),
loadingState: loadingStateExternal,
onLoad,
onFail,
riseOnHover,
retryTime,
}}/> }}/>
); );
}; };

View File

@ -7,35 +7,23 @@ import {
} from '@/photo'; } from '@/photo';
import { PhotoSetCategory } from '../category'; import { PhotoSetCategory } from '../category';
import { pathForPhoto, pathForPhotoImage } from '@/app/paths'; import { pathForPhoto, pathForPhotoImage } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/og/OGTile'; import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
export default function PhotoOGTile({ export default function PhotoOGTile({
photo, photo,
loadingState: loadingStateExternal,
riseOnHover, riseOnHover,
onLoad,
onFail,
retryTime, retryTime,
onVisible, onVisible,
...categories ...categories
}: { }: {
photo: Photo photo: Photo
loadingState?: OGLoadingState } & PhotoSetCategory & OGTilePropsCore) {
onLoad?: () => void
onFail?: () => void
riseOnHover?: boolean
retryTime?: number
onVisible?: () => void
} & PhotoSetCategory) {
return ( return (
<OGTile {...{ <OGTile {...{
title: titleForPhoto(photo), title: titleForPhoto(photo),
description: descriptionForPhoto(photo), description: descriptionForPhoto(photo),
path: pathForPhoto({ photo, ...categories }), path: pathForPhoto({ photo, ...categories }),
pathImage: pathForPhotoImage(photo), pathImage: pathForPhotoImage(photo),
loadingState: loadingStateExternal,
onLoad,
onFail,
riseOnHover, riseOnHover,
retryTime, retryTime,
onVisible, onVisible,

View File

@ -1,69 +1,22 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react';
import { Photo } from '@/photo'; import { Photo } from '@/photo';
import PhotoOGTile from './PhotoOGTile'; import PhotoOGTile from './PhotoOGTile';
import { OGLoadingState } from '@/components/og/OGTile';
const DEFAULT_MAX_CONCURRENCY = 3;
type PhotoLoadingState = Record<string, OGLoadingState>;
export default function StaggeredOgPhotos({ export default function StaggeredOgPhotos({
photos, photos,
maxConcurrency = DEFAULT_MAX_CONCURRENCY,
onLastPhotoVisible, onLastPhotoVisible,
}: { }: {
photos: Photo[] photos: Photo[]
maxConcurrency?: number maxConcurrency?: number
onLastPhotoVisible?: () => void onLastPhotoVisible?: () => void
}) { }) {
const [loadingState, setLoadingState] = useState(
photos.reduce((acc, photo) => ({
...acc,
[photo.id]: 'unloaded' as const,
}), {} as PhotoLoadingState),
);
const recomputeLoadingState = useCallback((
updatedState: PhotoLoadingState = {},
) => setLoadingState(currentLoadingState => {
const initialLoadingState = {
...currentLoadingState,
...updatedState,
};
const updatedLoadingState = {
...currentLoadingState,
...updatedState,
};
let imagesLoadingCount = 0;
Object.entries(initialLoadingState).forEach(([id, state]) => {
if (state === 'loading') {
imagesLoadingCount++;
} else if (imagesLoadingCount < maxConcurrency && state === 'unloaded') {
updatedLoadingState[id] = 'loading';
imagesLoadingCount++;
}
});
return updatedLoadingState;
})
, [maxConcurrency]);
useEffect(() => {
recomputeLoadingState();
}, [recomputeLoadingState]);
return ( return (
<div className="grid gap-3 grid-cols-2 md:grid-cols-3 lg:grid-cols-4"> <div className="grid gap-3 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{photos.map((photo, index) => {photos.map((photo, index) =>
<PhotoOGTile <PhotoOGTile
key={photo.id} key={photo.id}
photo={photo} photo={photo}
loadingState={loadingState[photo.id]}
onLoad={() => recomputeLoadingState({ [photo.id]: 'loaded' })}
onFail={() => recomputeLoadingState({ [photo.id]: 'failed' })}
onVisible={index === photos.length - 1 onVisible={index === photos.length - 1
? onLastPhotoVisible ? onLastPhotoVisible
: undefined} : undefined}

View File

@ -1,33 +1,25 @@
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRange } from '@/photo';
import { pathForRecipe, pathForRecipeImage } from '@/app/paths'; import { pathForRecipe, pathForRecipeImage } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/og/OGTile'; import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
import { descriptionForRecipePhotos, titleForRecipe } from '.'; import { descriptionForRecipePhotos, titleForRecipe } from '.';
import { useAppText } from '@/i18n/state/client'; import { useAppText } from '@/i18n/state/client';
export default function RecipeOGTile({ export default function RecipeOGTile({
recipe, recipe,
photos, photos,
loadingState: loadingStateExternal,
riseOnHover,
onLoad,
onFail,
retryTime,
count, count,
dateRange, dateRange,
...props
}: { }: {
recipe: string recipe: string
photos: Photo[] photos: Photo[]
loadingState?: OGLoadingState
onLoad?: () => void
onFail?: () => void
riseOnHover?: boolean
retryTime?: number
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { } & OGTilePropsCore) {
const appText = useAppText(); const appText = useAppText();
return ( return (
<OGTile {...{ <OGTile {...{
...props,
title: titleForRecipe(recipe, photos, appText, count), title: titleForRecipe(recipe, photos, appText, count),
description: descriptionForRecipePhotos( description: descriptionForRecipePhotos(
photos, photos,
@ -38,11 +30,6 @@ export default function RecipeOGTile({
), ),
path: pathForRecipe(recipe), path: pathForRecipe(recipe),
pathImage: pathForRecipeImage(recipe), pathImage: pathForRecipeImage(recipe),
loadingState: loadingStateExternal,
onLoad,
onFail,
riseOnHover,
retryTime,
}}/> }}/>
); );
}; };

View File

@ -2,34 +2,26 @@
import { Photo, PhotoDateRange } from '@/photo'; import { Photo, PhotoDateRange } from '@/photo';
import { pathForTag, pathForTagImage } from '@/app/paths'; import { pathForTag, pathForTagImage } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/og/OGTile'; import OGTile, { OGTilePropsCore } from '@/components/og/OGTile';
import { descriptionForTaggedPhotos, titleForTag } from '.'; import { descriptionForTaggedPhotos, titleForTag } from '.';
import { useAppText } from '@/i18n/state/client'; import { useAppText } from '@/i18n/state/client';
export default function TagOGTile({ export default function TagOGTile({
tag, tag,
photos, photos,
loadingState: loadingStateExternal,
riseOnHover,
onLoad,
onFail,
retryTime,
count, count,
dateRange, dateRange,
...props
}: { }: {
tag: string tag: string
photos: Photo[] photos: Photo[]
loadingState?: OGLoadingState
onLoad?: () => void
onFail?: () => void
riseOnHover?: boolean
retryTime?: number
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { } & OGTilePropsCore) {
const appText = useAppText(); const appText = useAppText();
return ( return (
<OGTile {...{ <OGTile {...{
...props,
title: titleForTag(tag, photos, appText, count), title: titleForTag(tag, photos, appText, count),
description: descriptionForTaggedPhotos( description: descriptionForTaggedPhotos(
photos, photos,
@ -40,11 +32,6 @@ export default function TagOGTile({
), ),
path: pathForTag(tag), path: pathForTag(tag),
pathImage: pathForTagImage(tag), pathImage: pathForTagImage(tag),
loadingState: loadingStateExternal,
onLoad,
onFail,
riseOnHover,
retryTime,
}}/> }}/>
); );
}; };