Vercel/src/components/image/ImageWithFallback.tsx
2025-06-24 10:14:08 -05:00

167 lines
4.6 KiB
TypeScript

'use client';
/* eslint-disable jsx-a11y/alt-text */
import { BLUR_ENABLED } from '@/app/config';
import { useAppState } from '@/state/AppState';
import { clsx} from 'clsx/lite';
import Image, { ImageProps } from 'next/image';
import { useCallback, useEffect, useRef, useState } from 'react';
export default function ImageWithFallback({
className,
classNameImage = 'object-cover h-full',
debug,
priority,
blurDataURL,
blurCompatibilityLevel = 'low',
...props
}: ImageProps & {
blurCompatibilityLevel?: 'none' | 'low' | 'high'
classNameImage?: string
debug?: boolean
}) {
const {
shouldDebugImageFallbacks,
areAdminDebugToolsEnabled,
} = useAppState();
const [wasCached, setWasCached] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [didError, setDidError] = useState(false);
const onLoad = useCallback(() => setIsLoading(false), []);
const onError = useCallback(() => setDidError(true), []);
const [hideFallback, setHideFallback] = useState(false);
const refImage = useRef<HTMLImageElement>(null);
const refFallback = useRef<HTMLDivElement>(null);
useEffect(() => {
setWasCached(
Boolean(refImage.current?.complete) &&
(refImage.current?.naturalWidth ?? 0) > 0,
);
}, []);
const shouldDebugFallback = areAdminDebugToolsEnabled && debug;
const debugFallbackStyles = useCallback(() => {
const stylesMap = refFallback.current?.computedStyleMap();
const styles = stylesMap
? Array.from(stylesMap.entries()).reduce((acc, [key, value]) => {
acc[key] = value.toString();
return acc;
}, {} as Record<string, string>) : {};
const opacity = (stylesMap?.get('opacity') as CSSUnitValue)?.value;
return {
styles,
opacity,
classList: refFallback.current?.classList,
};
}, []);
const outerTimeout = useRef<NodeJS.Timeout>(undefined);
const innerTimeout = useRef<NodeJS.Timeout>(undefined);
useEffect(() => {
if (!isLoading && !didError) {
outerTimeout.current = setTimeout(() => {
if (refFallback.current) {
const fallbackOpacity = (refFallback
.current
.computedStyleMap()
.get('opacity') as CSSUnitValue
)?.value;
// Address race condition where cached image is initially loaded
// and fallback is still being shown at full opacity
if (fallbackOpacity === 0) {
// Image has loaded and fallback is already hidden
setHideFallback(true);
if (shouldDebugFallback) {
console.log('Hide fallback: 01', debugFallbackStyles());
}
} else {
// Image has loaded but fallback is still visible
// Delay hiding fallback to avoid abrupt transition
innerTimeout.current = setTimeout(() =>{
setHideFallback(true);
if (shouldDebugFallback) {
console.log('Hide fallback: 02', debugFallbackStyles());
}
}, 1000);
}
}
}, 1000);
return () => {
clearTimeout(outerTimeout.current);
clearTimeout(innerTimeout.current);
};
}
}, [
isLoading,
didError,
shouldDebugFallback,
debugFallbackStyles,
]);
const showFallback =
!wasCached &&
!hideFallback;
const getBlurClass = () => {
switch (blurCompatibilityLevel) {
case 'high':
// Fix poorly blurred placeholder data generated on client
return 'blur-[4px] @xs:blue-md scale-[1.05]';
case 'low':
return 'blur-[2px] @xs:blue-md scale-[1.01]';
}
};
return (
<div
className={clsx(
'flex relative',
className,
)}
>
<Image {...{
...props,
ref: refImage,
priority,
className: classNameImage,
onLoad,
onError,
}} />
<div
ref={refFallback}
className={clsx(
'@container',
'absolute inset-0 pointer-events-none',
'overflow-hidden',
(showFallback || shouldDebugImageFallbacks) &&
'transition-opacity duration-300 ease-in',
!(BLUR_ENABLED && blurDataURL) && 'bg-main',
(isLoading || shouldDebugImageFallbacks)
? 'opacity-100'
: 'opacity-0',
)}
>
{(BLUR_ENABLED && blurDataURL)
? <img {...{
...props,
src: blurDataURL,
className: clsx(
getBlurClass(),
classNameImage,
),
}} />
: <div className={clsx(
'w-full h-full',
'bg-gray-100/50 dark:bg-gray-900/50',
)} />}
</div>
</div>
);
}