SkateSpots/src/components/ImageWithFallback.tsx

83 lines
2.6 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { cn } from '@/lib/utils';
import { imageCandidates } from '@/lib/imageFallback';
interface ImageWithFallbackProps extends React.ImgHTMLAttributes<HTMLImageElement> {
/**
* What to show when the image fails to load (or has no src). Defaults to a
* subtle skate emoji on a muted background that fills the same box as the
* image would have, so broken user-uploaded URLs never surface the browser's
* ugly broken-image icon.
*/
fallback?: React.ReactNode;
}
/**
* Drop-in replacement for <img> that degrades gracefully when the source is
* missing or 404s. Content images across the app (spots, events, marketplace
* listings, crew logos, shop photos, tours, stash gear) come from user-supplied
* URLs on arbitrary Blossom/host servers, which routinely go away — without this
* they render a broken-image glyph. The fallback fills the same layout box
* (className is applied to the wrapper) so nothing shifts.
*
* Photo redundancy: before giving up, it retries the SAME file on the other known
* Blossom hosts (see lib/imageFallback — Blossom is content-addressed, so the
* mirror URL is derived from the sha256 in the path). A dead host therefore
* usually costs a moment, not a missing spot photo.
*/
export function ImageWithFallback({
src,
alt = '',
className,
fallback,
onError,
loading = 'lazy',
decoding = 'async',
...props
}: ImageWithFallbackProps) {
const candidates = useMemo(() => imageCandidates(src), [src]);
const [index, setIndex] = useState(0);
const [failed, setFailed] = useState(false);
// Reset when the src changes — list rows recycle these components.
useEffect(() => {
setIndex(0);
setFailed(false);
}, [src]);
if (!src || failed || candidates.length === 0) {
return (
<div
className={cn(
'flex items-center justify-center bg-muted/40 text-muted-foreground',
className,
)}
aria-label={typeof alt === 'string' && alt ? alt : 'Image unavailable'}
role="img"
>
{fallback ?? <span className="text-2xl opacity-40 select-none">🛹</span>}
</div>
);
}
return (
<img
src={candidates[Math.min(index, candidates.length - 1)]}
alt={alt}
className={className}
loading={loading}
decoding={decoding}
onError={(event) => {
if (index + 1 < candidates.length) {
setIndex(index + 1);
return;
}
setFailed(true);
onError?.(event);
}}
{...props}
/>
);
}
export default ImageWithFallback;