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>
57 lines
1.6 KiB
TypeScript
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;
|