From 8d8a46a73f51968dc082ea213a1e395a4116dab3 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sat, 26 Sep 2026 17:03:30 +0700 Subject: [PATCH] feat(spot): full-screen photo lightbox for spot galleries Gallery images were inert (click did nothing) and only the first five were reachable. Adds ImageLightbox: click any photo to view full size, arrow-key / button navigation across the whole gallery, position counter, caption and community-photo attribution. Extra images show a +N overlay. --- src/components/ImageLightbox.tsx | 131 +++++++++++++++++++++++++++++++ src/pages/SpotDetailPage.tsx | 117 +++++++++++++++++---------- 2 files changed, 206 insertions(+), 42 deletions(-) create mode 100644 src/components/ImageLightbox.tsx diff --git a/src/components/ImageLightbox.tsx b/src/components/ImageLightbox.tsx new file mode 100644 index 0000000..ed9af86 --- /dev/null +++ b/src/components/ImageLightbox.tsx @@ -0,0 +1,131 @@ +import { useCallback, useEffect } from 'react'; +import { ChevronLeft, ChevronRight, Users } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { ImageWithFallback } from '@/components/ImageWithFallback'; + +export interface LightboxImage { + url: string; + caption?: string; + /** Owner-uploaded images carry no "Community" badge. */ + isOwner?: boolean; +} + +interface ImageLightboxProps { + images: LightboxImage[]; + /** Index of the open image, or null when closed. */ + index: number | null; + onIndexChange: (index: number) => void; + onClose: () => void; +} + +/** + * Full-screen photo viewer for spot/shop galleries. + * + * Detail-page grids previously rendered images that did nothing on click and + * showed at most five of them. This lets users view every photo at full size, + * with arrow-key / button navigation. Images come from arbitrary Blossom hosts, + * so it reuses ImageWithFallback to degrade gracefully on dead URLs. + */ +export function ImageLightbox({ images, index, onIndexChange, onClose }: ImageLightboxProps) { + const open = index !== null && index >= 0 && index < images.length; + const current = open ? images[index as number] : undefined; + + const go = useCallback( + (delta: number) => { + if (images.length === 0) return; + const base = index ?? 0; + onIndexChange((base + delta + images.length) % images.length); + }, + [images.length, index, onIndexChange], + ); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + e.preventDefault(); + go(-1); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + go(1); + } else if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, go, onClose]); + + return ( + { if (!o) onClose(); }}> + + {current?.caption || 'Photo'} + Photo viewer + + {current && ( +
+
+ +
+ + {/* Position counter */} +
+ {(index as number) + 1} / {images.length} +
+ + {/* Prev / next */} + {images.length > 1 && ( + <> + + + + )} + + {/* Caption / attribution */} + {((current.caption ?? '') !== '' || current.isOwner === false) && ( +
+ {current.caption && ( +

{current.caption}

+ )} + {current.isOwner === false && ( + + Community photo + + )} +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/pages/SpotDetailPage.tsx b/src/pages/SpotDetailPage.tsx index 98c856a..3be657e 100644 --- a/src/pages/SpotDetailPage.tsx +++ b/src/pages/SpotDetailPage.tsx @@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query'; import ngeohash from 'ngeohash'; import { Layout } from '@/components/Layout'; import { ImageWithFallback } from '@/components/ImageWithFallback'; +import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox'; import { SpotMap } from '@/components/SpotMap'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -134,6 +135,7 @@ export default function SpotDetailPage() { const [showReportDialog, setShowReportDialog] = useState(false); const [showStatusDialog, setShowStatusDialog] = useState(false); const [showRouteDialog, setShowRouteDialog] = useState(false); + const [lightboxIndex, setLightboxIndex] = useState(null); const isOwner = user && spot && user.pubkey === spot.pubkey; const isAdmin = isAdminPubkey(user?.pubkey); @@ -160,6 +162,12 @@ export default function SpotDetailPage() { // npub for linking to the mapper's profile / messaging them const mapperNpub = spot?.pubkey ? nip19.npubEncode(spot.pubkey) : ''; + // Full photo gallery: owner photos first, then community contributions. + const galleryImages: LightboxImage[] = [ + ...(spot?.images ?? []).map((url) => ({ url, isOwner: true })), + ...(communityPhotos ?? []).map((p) => ({ url: p.url, isOwner: false, caption: p.caption ?? undefined })), + ]; + const coordinates = useMemo(() => { if (!spot?.geohash) return null; const { latitude, longitude } = ngeohash.decode(spot.geohash); @@ -248,51 +256,68 @@ export default function SpotDetailPage() {
{/* Main Content */}
- {/* Image Gallery - Combines owner photos + community photos */} - {(() => { - const allImages = [ - ...spot.images.map(url => ({ url, isOwner: true })), - ...(communityPhotos || []).map(p => ({ url: p.url, isOwner: false, caption: p.caption })), - ]; - - return allImages.length > 0 ? ( - <> -
- - {!allImages[0].isOwner && ( -
- - Community -
- )} -
- - {/* Additional images */} - {allImages.length > 1 && ( -
- {allImages.slice(1, 5).map((img, idx) => ( -
- - {!img.isOwner && ( -
+ {/* Image Gallery - owner photos + community photos; click to view full size */} + {galleryImages.length > 0 ? ( + <> + + + {/* Additional images */} + {galleryImages.length > 1 && ( +
+ {galleryImages.slice(1, 5).map((img, idx) => { + const realIndex = idx + 1; + const remaining = galleryImages.length - 5; + return ( +
- ))} -
- )} - - ) : ( -
- -
- ); - })()} + {idx === 3 && remaining > 0 && ( +
+ +{remaining} +
+ )} + + ); + })} +
+ )} + + ) : ( +
+ +
+ )} {/* Spot Info */}
@@ -1043,6 +1068,14 @@ export default function SpotDetailPage() { destination={coordinates} destinationLabel={spot?.title ?? 'Spot'} /> + + {/* Full-screen photo viewer */} + setLightboxIndex(null)} + /> ); }