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 ? ( - {crew.name} - ) : ( - - )} + } + />
@@ -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 ? ( - {profile.title} - ) : ( - - )} + } + />
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 ( + {alt} 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' }`} > - {item.title} +
) : (
{primaryImage && ( - {item.title} + )}

{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 && (
- {issue.title} +
)} 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 ? (
- {item.title} +
) : ( {category.emoji} @@ -451,7 +452,7 @@ function SpotBottomSheet({ spot, onClose }: { spot: SkateSpot; onClose: () => vo {/* Thumbnail */}
{spot.images[0] ? ( - {spot.title} + ) : (
{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 && ( - 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 && (
- {spot.title} +
)}
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.name} {shop.images.slice(1, 5).map((img, idx) => (
- {`${shop.name} +
))}
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 ? ( <>
- {spot.title} {allImages.slice(1, 5).map((img, idx) => (
- {`${spot.title} + {!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] ? {item.title} : {category.emoji}} + {item.images?.[0] ? {category.emoji}} /> : {category.emoji}}
@@ -186,7 +187,7 @@ function StashItemCard({ item, onEdit, onDelete, onListOnMarketplace, isWTB = fa
{item.images?.[0] ? ( - {item.title} + ) : (
{category.emoji}