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>
This commit is contained in:
shakespeare.diy 2026-09-07 19:46:40 +07:00
parent 4aec9539d6
commit c9b0571de9
13 changed files with 106 additions and 34 deletions

View file

@ -1,7 +1,7 @@
{
"name": "skate-spots",
"private": true,
"version": "1.2.17",
"version": "1.2.18",
"type": "module",
"homepage": "https://skate-spots.com",
"scripts": {

View file

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import {
Dialog,
DialogContent,
@ -104,11 +105,12 @@ function ReceiptCards({ tool, data, onNavigate }: { tool: string; data: unknown;
className="group flex items-center gap-2.5 rounded-xl border border-teal-500/20 bg-white/[0.03] p-2 transition-all hover:border-teal-500/50 hover:bg-white/[0.06]"
>
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-teal-500/15">
{item.image ? (
<img src={item.image} alt="" className="h-full w-full object-cover" />
) : (
<MapPin className="h-4 w-4 text-teal-400" />
)}
<ImageWithFallback
src={item.image}
alt=""
className="h-full w-full object-cover"
fallback={<MapPin className="h-4 w-4 text-teal-400" />}
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-white">{item.title}</p>

View file

@ -1,5 +1,6 @@
import { Link } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { Users, GraduationCap, ChevronRight, Link2, BadgeCheck } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useCrew } from '@/hooks/useCrews';
@ -60,11 +61,12 @@ function CrewRow({ coord, selfCoord }: { coord: string; selfCoord?: string }) {
className="flex items-center gap-3 rounded-lg border border-gray-700 bg-black/40 p-2.5 hover:bg-black/60 hover:border-teal-500/40 transition-colors"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-teal-500/15">
{crew?.image ? (
<img src={crew.image} alt={crew.name} className="h-full w-full object-cover" />
) : (
<Users className="h-4 w-4 text-teal-400" />
)}
<ImageWithFallback
src={crew?.image}
alt={crew?.name ?? 'Crew'}
className="h-full w-full object-cover"
fallback={<Users className="h-4 w-4 text-teal-400" />}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
@ -98,11 +100,12 @@ function LabRow({ coord, selfCoord }: { coord: string; selfCoord?: string }) {
className="flex items-center gap-3 rounded-lg border border-gray-700 bg-black/40 p-2.5 hover:bg-black/60 hover:border-teal-500/40 transition-colors"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/5">
{profile?.image ? (
<img src={profile.image} alt={profile.title} className="h-full w-full object-cover" />
) : (
<GraduationCap className="h-4 w-4 text-gray-300" />
)}
<ImageWithFallback
src={profile?.image}
alt={profile?.title ?? 'Lab profile'}
className="h-full w-full object-cover"
fallback={<GraduationCap className="h-4 w-4 text-gray-300" />}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">

View file

@ -0,0 +1,57 @@
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;

View file

@ -5,6 +5,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import {
Dialog,
DialogContent,
@ -216,7 +217,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
isWTB ? 'border-blue-500/30' : 'border-orange-500/30'
}`}
>
<img src={primaryImage} alt={item.title} className="w-full h-full object-cover" />
<ImageWithFallback src={primaryImage} alt={item.title} className="w-full h-full object-cover" />
</div>
) : (
<div
@ -431,7 +432,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
{/* Item details */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted">
{primaryImage && (
<img src={primaryImage} alt={item.title} className="w-16 h-16 rounded object-cover" />
<ImageWithFallback src={primaryImage} alt={item.title} className="w-16 h-16 rounded object-cover" />
)}
<div className="flex-1">
<h4 className="font-bold">{item.title}</h4>

View file

@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
@ -167,7 +168,7 @@ export function NewsletterIssueView({ pubkey, identifier }: { pubkey: string; id
{issue.image && (
<div className="mb-6 aspect-video w-full overflow-hidden rounded-2xl border border-white/10">
<img src={issue.image} alt={issue.title} className="h-full w-full object-cover" />
<ImageWithFallback src={issue.image} alt={issue.title} className="h-full w-full object-cover" />
</div>
)}

View file

@ -11,6 +11,7 @@ import { CrewInviteCard } from '@/components/CrewInviteCard';
import { JoinRequestCard } from '@/components/JoinRequestCard';
import { CollectiveJoinRequestCard } from '@/components/CollectiveJoinRequestCard';
import { ActivityNotificationCard } from '@/components/ActivityNotificationCard';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
import { useActivityNotifications } from '@/hooks/useActivityNotifications';
import { useNotificationsRead } from '@/hooks/useNotificationsRead';
@ -156,11 +157,12 @@ function NewsletterItem({
)}
>
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl bg-[#ccff00]/15">
{issue.image ? (
<img src={issue.image} alt="" className="h-full w-full object-cover" />
) : (
<Radio className="h-4 w-4 text-[#ccff00]" />
)}
<ImageWithFallback
src={issue.image}
alt=""
className="h-full w-full object-cover"
fallback={<Radio className="h-4 w-4 text-[#ccff00]" />}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">

View file

@ -34,6 +34,7 @@ import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useBatchSpotStatus } from '@/hooks/useSpotStatus';
import { useMultipleSpotWeather } from '@/hooks/useSpotWeather';
import { useCommentCounts } from '@/hooks/useCommentCounts';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { useStash, STASH_CATEGORIES, CONDITION_LABELS, RARITY_TIERS, type StashItem } from '@/hooks/useStash';
import { useAuthor } from '@/hooks/useAuthor';
import { useSatsToUsd } from '@/hooks/useExchangeRate';
@ -113,7 +114,7 @@ function MiniStashCard({ item, onDelete }: { item: StashItem; onDelete: () => vo
{/* Image or emoji */}
{hasImage ? (
<div className="w-12 h-12 rounded-lg overflow-hidden flex-shrink-0 border border-white/10">
<img src={item.images![0]} alt={item.title} className="w-full h-full object-cover" />
<ImageWithFallback src={item.images![0]} alt={item.title} className="w-full h-full object-cover" />
</div>
) : (
<span className="text-2xl">{category.emoji}</span>
@ -451,7 +452,7 @@ function SpotBottomSheet({ spot, onClose }: { spot: SkateSpot; onClose: () => vo
{/* Thumbnail */}
<div className="w-16 h-16 rounded-xl overflow-hidden bg-white/5 flex-shrink-0 border border-white/10">
{spot.images[0] ? (
<img src={spot.images[0]} alt={spot.title} className="w-full h-full object-cover" />
<ImageWithFallback src={spot.images[0]} alt={spot.title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-2xl">
{isShop ? '🏪' : '🛹'}

View file

@ -1,6 +1,7 @@
import { nip19 } from 'nostr-tools';
import { useParams, Link } from 'react-router-dom';
import { Layout } from '@/components/Layout';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
@ -223,7 +224,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
{/* Banner */}
<div className="relative h-32 sm:h-48 rounded-xl overflow-hidden bg-gradient-to-br from-[#ff0040]/30 to-[#ccff00]/20 mb-4">
{banner && (
<img src={banner} alt="Banner" className="w-full h-full object-cover" />
<ImageWithFallback src={banner} alt="Banner" className="w-full h-full object-cover" fallback={null} />
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
</div>

View file

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import { Layout } from '@/components/Layout';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
@ -911,7 +912,7 @@ function SpotOfTheWeekBlock({ spot }: { spot: SpotOfTheWeek }) {
<div className="mt-4 rounded-2xl border-2 border-[#ff0040]/30 bg-[#ff0040]/5 overflow-hidden hover:border-[#ff0040]/50 transition-colors">
{spot.image && (
<div className="aspect-video w-full overflow-hidden">
<img src={spot.image} alt={spot.title} className="w-full h-full object-cover" />
<ImageWithFallback src={spot.image} alt={spot.title} className="w-full h-full object-cover" />
</div>
)}
<div className="p-5">

View file

@ -4,6 +4,7 @@ import { useParams, Link, useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import ngeohash from 'ngeohash';
import { Layout } from '@/components/Layout';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { SpotMap } from '@/components/SpotMap';
import { RouteDialog } from '@/components/RouteDialog';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -215,7 +216,7 @@ export default function ShopDetailPage() {
{/* Image Gallery */}
{shop.images.length > 0 ? (
<div className="aspect-video rounded-xl overflow-hidden bg-muted">
<img
<ImageWithFallback
src={shop.images[0]}
alt={shop.name}
className="w-full h-full object-cover"
@ -232,7 +233,7 @@ export default function ShopDetailPage() {
<div className="grid grid-cols-4 gap-2">
{shop.images.slice(1, 5).map((img, idx) => (
<div key={idx} className="aspect-square rounded-lg overflow-hidden bg-muted">
<img src={img} alt={`${shop.name} ${idx + 2}`} className="w-full h-full object-cover" />
<ImageWithFallback src={img} alt={`${shop.name} ${idx + 2}`} className="w-full h-full object-cover" />
</div>
))}
</div>

View file

@ -4,6 +4,7 @@ import { useParams, Link, useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import ngeohash from 'ngeohash';
import { Layout } from '@/components/Layout';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { SpotMap } from '@/components/SpotMap';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
@ -257,7 +258,7 @@ export default function SpotDetailPage() {
return allImages.length > 0 ? (
<>
<div className="aspect-video rounded-xl overflow-hidden bg-muted relative">
<img
<ImageWithFallback
src={allImages[0].url}
alt={spot.title}
className="w-full h-full object-cover"
@ -275,7 +276,7 @@ export default function SpotDetailPage() {
<div className="grid grid-cols-4 gap-2">
{allImages.slice(1, 5).map((img, idx) => (
<div key={idx} className="aspect-square rounded-lg overflow-hidden bg-muted relative">
<img src={img.url} alt={`${spot.title} ${idx + 2}`} className="w-full h-full object-cover" />
<ImageWithFallback src={img.url} alt={`${spot.title} ${idx + 2}`} className="w-full h-full object-cover" />
{!img.isOwner && (
<div className="absolute bottom-1 left-1 px-1.5 py-0.5 bg-black/70 rounded text-[10px] text-[#ccff00] flex items-center gap-0.5">
<Users className="h-2.5 w-2.5" />

View file

@ -14,6 +14,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Dialog
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { LoginArea } from '@/components/auth/LoginArea';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { useStash, STASH_CATEGORIES, CONDITION_LABELS, RARITY_TIERS, type StashItem } from '@/hooks/useStash';
import { useStashShowcaseSync } from '@/hooks/useStashShowcase';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -98,7 +99,7 @@ function StashListRow({ item, onEdit, onDelete, onListOnMarketplace, isWTB = fal
)}>
<div className={cn('absolute left-0 top-0 bottom-0 w-1 rounded-l-xl bg-gradient-to-b', RARITY_COLORS[rarity])} />
<div className="w-14 h-14 flex-shrink-0 rounded-lg overflow-hidden bg-black/50 border border-white/10 flex items-center justify-center ml-1">
{item.images?.[0] ? <img src={item.images[0]} alt={item.title} className="w-full h-full object-cover" /> : <span className="text-2xl">{category.emoji}</span>}
{item.images?.[0] ? <ImageWithFallback src={item.images[0]} alt={item.title} className="w-full h-full object-cover" fallback={<span className="text-2xl">{category.emoji}</span>} /> : <span className="text-2xl">{category.emoji}</span>}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
@ -186,7 +187,7 @@ function StashItemCard({ item, onEdit, onDelete, onListOnMarketplace, isWTB = fa
<div className="h-40 bg-gradient-to-br from-black/50 to-black/30 flex items-center justify-center relative overflow-hidden">
<div className={cn('absolute inset-2 rounded-lg border-2 opacity-20', RARITY_BORDER[rarity])} />
{item.images?.[0] ? (
<img src={item.images[0]} alt={item.title} className="w-full h-full object-cover" />
<ImageWithFallback src={item.images[0]} alt={item.title} className="w-full h-full object-cover" />
) : (
<div className="flex flex-col items-center gap-2">
<span className="text-5xl">{category.emoji}</span>