SkateSpots/src/components/ImageWithFallback.tsx
shakespeare.diy c9b0571de9 feat(ux): graceful image fallbacks for broken content images (1.2.18)
Added <ImageWithFallback> — a drop-in <img> that shows a subtle placeholder
(skate emoji or a caller-supplied icon) instead of the browser's broken-image
glyph when a user-supplied URL is missing or 404s. Fills the same layout box so
nothing shifts.

Swapped it into the content-image surfaces that display user-uploaded URLs:
- Marketplace item cards (grid + purchase dialog)
- Spot detail (hero + gallery thumbs) and map list/popup cards
- Shop detail (hero + gallery)
- Crew & Lab cross-link cards (icon fallback)
- Crew agent panel, newsletter issue view + notification bell newsletter thumb
- Stash gear cards (list + grid), profile banner (NIP19), newsletter spot cards

Left untouched: avatars (shadcn Avatar already has fallbacks), QR codes and
rider-card/print images (data URLs, never 404), and in-form upload previews
(local object URLs).

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-07 19:46:40 +07:00

57 lines
1.6 KiB
TypeScript

import { useState } from 'react';
import { cn } from '@/lib/utils';
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.
*/
export function ImageWithFallback({
src,
alt = '',
className,
fallback,
...props
}: ImageWithFallbackProps) {
const [failed, setFailed] = useState(false);
if (!src || failed) {
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={src}
alt={alt}
className={className}
onError={() => setFailed(true)}
{...props}
/>
);
}
export default ImageWithFallback;