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:
parent
4aec9539d6
commit
c9b0571de9
13 changed files with 106 additions and 34 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
57
src/components/ImageWithFallback.tsx
Normal file
57
src/components/ImageWithFallback.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 ? '🏪' : '🛹'}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue