import { useEffect, useMemo, useState } from 'react'; import { cn } from '@/lib/utils'; import { imageCandidates } from '@/lib/imageFallback'; interface ImageWithFallbackProps extends React.ImgHTMLAttributes { /** * 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 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, ...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 (
{fallback ?? 🛹}
); } return ( {alt} { if (index + 1 < candidates.length) { setIndex(index + 1); return; } setFailed(true); onError?.(event); }} {...props} /> ); } export default ImageWithFallback;