diff --git a/package.json b/package.json
index 1a5d45a..23dcf95 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src/components/CrewAgentPanel.tsx b/src/components/CrewAgentPanel.tsx
index e9d6b01..e6be41e 100644
--- a/src/components/CrewAgentPanel.tsx
+++ b/src/components/CrewAgentPanel.tsx
@@ -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]"
>
- {item.image ? (
-

- ) : (
-
- )}
+
}
+ />
{item.title}
diff --git a/src/components/CrossLinkCards.tsx b/src/components/CrossLinkCards.tsx
index 610ceab..9d1a3f3 100644
--- a/src/components/CrossLinkCards.tsx
+++ b/src/components/CrossLinkCards.tsx
@@ -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"
>
- {crew?.image ? (
-

- ) : (
-
- )}
+
}
+ />
@@ -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"
>
- {profile?.image ? (
-

- ) : (
-
- )}
+
}
+ />
diff --git a/src/components/ImageWithFallback.tsx b/src/components/ImageWithFallback.tsx
new file mode 100644
index 0000000..a9904ef
--- /dev/null
+++ b/src/components/ImageWithFallback.tsx
@@ -0,0 +1,57 @@
+import { useState } from 'react';
+import { cn } from '@/lib/utils';
+
+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.
+ */
+export function ImageWithFallback({
+ src,
+ alt = '',
+ className,
+ fallback,
+ ...props
+}: ImageWithFallbackProps) {
+ const [failed, setFailed] = useState(false);
+
+ if (!src || failed) {
+ return (
+
+ {fallback ?? 🛹}
+
+ );
+ }
+
+ return (
+
setFailed(true)}
+ {...props}
+ />
+ );
+}
+
+export default ImageWithFallback;
diff --git a/src/components/MarketplaceItemCard.tsx b/src/components/MarketplaceItemCard.tsx
index a7216ea..02a366a 100644
--- a/src/components/MarketplaceItemCard.tsx
+++ b/src/components/MarketplaceItemCard.tsx
@@ -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'
}`}
>
-
+
) : (
{primaryImage && (
-

+
)}
{item.title}
diff --git a/src/components/NewsletterIssueView.tsx b/src/components/NewsletterIssueView.tsx
index 763405d..18ba199 100644
--- a/src/components/NewsletterIssueView.tsx
+++ b/src/components/NewsletterIssueView.tsx
@@ -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 && (
-

+
)}
diff --git a/src/components/NotificationBell.tsx b/src/components/NotificationBell.tsx
index a3c81c1..079c248 100644
--- a/src/components/NotificationBell.tsx
+++ b/src/components/NotificationBell.tsx
@@ -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({
)}
>
- {issue.image ? (
-

- ) : (
-
- )}
+
}
+ />
diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx
index 664e55e..628e7d5 100644
--- a/src/pages/MapPage.tsx
+++ b/src/pages/MapPage.tsx
@@ -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 ? (
-

+
) : (
{category.emoji}
@@ -451,7 +452,7 @@ function SpotBottomSheet({ spot, onClose }: { spot: SkateSpot; onClose: () => vo
{/* Thumbnail */}
{spot.images[0] ? (
-

+
) : (
{isShop ? '🏪' : '🛹'}
diff --git a/src/pages/NIP19Page.tsx b/src/pages/NIP19Page.tsx
index 8071c5d..bb3612f 100644
--- a/src/pages/NIP19Page.tsx
+++ b/src/pages/NIP19Page.tsx
@@ -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 */}
{banner && (
-

+
)}
diff --git a/src/pages/NewsletterPage.tsx b/src/pages/NewsletterPage.tsx
index 69d3e5e..43c33dd 100644
--- a/src/pages/NewsletterPage.tsx
+++ b/src/pages/NewsletterPage.tsx
@@ -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 }) {
{spot.image && (
-

+
)}
diff --git a/src/pages/ShopDetailPage.tsx b/src/pages/ShopDetailPage.tsx
index 64d1afe..0cbc630 100644
--- a/src/pages/ShopDetailPage.tsx
+++ b/src/pages/ShopDetailPage.tsx
@@ -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 ? (
-

{shop.images.slice(1, 5).map((img, idx) => (
-

+
))}
diff --git a/src/pages/SpotDetailPage.tsx b/src/pages/SpotDetailPage.tsx
index 87c0268..e05307b 100644
--- a/src/pages/SpotDetailPage.tsx
+++ b/src/pages/SpotDetailPage.tsx
@@ -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 ? (
<>
-

{allImages.slice(1, 5).map((img, idx) => (
-

+
{!img.isOwner && (
diff --git a/src/pages/StashPage.tsx b/src/pages/StashPage.tsx
index 02ad816..2c90c1e 100644
--- a/src/pages/StashPage.tsx
+++ b/src/pages/StashPage.tsx
@@ -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
)}>
- {item.images?.[0] ?

:
{category.emoji}}
+ {item.images?.[0] ?
{category.emoji}} /> : {category.emoji}}
@@ -186,7 +187,7 @@ function StashItemCard({ item, onEdit, onDelete, onListOnMarketplace, isWTB = fa
{item.images?.[0] ? (
-

+
) : (
{category.emoji}