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.
This commit is contained in:
parent
acdde497b4
commit
8d8a46a73f
2 changed files with 206 additions and 42 deletions
131
src/components/ImageLightbox.tsx
Normal file
131
src/components/ImageLightbox.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-5xl w-[calc(100%-1rem)] p-0 gap-0 bg-black/95 border-white/10 overflow-hidden">
|
||||||
|
<DialogTitle className="sr-only">{current?.caption || 'Photo'}</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">Photo viewer</DialogDescription>
|
||||||
|
|
||||||
|
{current && (
|
||||||
|
<div className="relative flex flex-col">
|
||||||
|
<div className="flex items-center justify-center bg-black">
|
||||||
|
<ImageWithFallback
|
||||||
|
src={current.url}
|
||||||
|
alt={current.caption || 'Photo'}
|
||||||
|
className="max-h-[80vh] max-h-[80dvh] w-auto max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Position counter */}
|
||||||
|
<div className="absolute top-3 left-3 rounded bg-black/70 px-2 py-1 text-xs font-mono text-white/90">
|
||||||
|
{(index as number) + 1} / {images.length}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prev / next */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="Previous photo"
|
||||||
|
onClick={() => go(-1)}
|
||||||
|
className="absolute left-2 top-1/2 h-11 w-11 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/80 hover:text-white"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="Next photo"
|
||||||
|
onClick={() => go(1)}
|
||||||
|
className="absolute right-2 top-1/2 h-11 w-11 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/80 hover:text-white"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Caption / attribution */}
|
||||||
|
{((current.caption ?? '') !== '' || current.isOwner === false) && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/95 to-transparent px-4 pb-4 pt-8">
|
||||||
|
{current.caption && (
|
||||||
|
<p className="break-words text-sm text-white">{current.caption}</p>
|
||||||
|
)}
|
||||||
|
{current.isOwner === false && (
|
||||||
|
<span className="mt-1 inline-flex items-center gap-1 text-xs text-[#ccff00]">
|
||||||
|
<Users className="h-3 w-3" /> Community photo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||||
import ngeohash from 'ngeohash';
|
import ngeohash from 'ngeohash';
|
||||||
import { Layout } from '@/components/Layout';
|
import { Layout } from '@/components/Layout';
|
||||||
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
||||||
|
import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox';
|
||||||
import { SpotMap } from '@/components/SpotMap';
|
import { SpotMap } from '@/components/SpotMap';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
@ -134,6 +135,7 @@ export default function SpotDetailPage() {
|
||||||
const [showReportDialog, setShowReportDialog] = useState(false);
|
const [showReportDialog, setShowReportDialog] = useState(false);
|
||||||
const [showStatusDialog, setShowStatusDialog] = useState(false);
|
const [showStatusDialog, setShowStatusDialog] = useState(false);
|
||||||
const [showRouteDialog, setShowRouteDialog] = useState(false);
|
const [showRouteDialog, setShowRouteDialog] = useState(false);
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const isOwner = user && spot && user.pubkey === spot.pubkey;
|
const isOwner = user && spot && user.pubkey === spot.pubkey;
|
||||||
const isAdmin = isAdminPubkey(user?.pubkey);
|
const isAdmin = isAdminPubkey(user?.pubkey);
|
||||||
|
|
@ -160,6 +162,12 @@ export default function SpotDetailPage() {
|
||||||
// npub for linking to the mapper's profile / messaging them
|
// npub for linking to the mapper's profile / messaging them
|
||||||
const mapperNpub = spot?.pubkey ? nip19.npubEncode(spot.pubkey) : '';
|
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(() => {
|
const coordinates = useMemo(() => {
|
||||||
if (!spot?.geohash) return null;
|
if (!spot?.geohash) return null;
|
||||||
const { latitude, longitude } = ngeohash.decode(spot.geohash);
|
const { latitude, longitude } = ngeohash.decode(spot.geohash);
|
||||||
|
|
@ -248,51 +256,68 @@ export default function SpotDetailPage() {
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="lg:col-span-2 space-y-6 min-w-0">
|
<div className="lg:col-span-2 space-y-6 min-w-0">
|
||||||
{/* Image Gallery - Combines owner photos + community photos */}
|
{/* Image Gallery - owner photos + community photos; click to view full size */}
|
||||||
{(() => {
|
{galleryImages.length > 0 ? (
|
||||||
const allImages = [
|
|
||||||
...spot.images.map(url => ({ url, isOwner: true })),
|
|
||||||
...(communityPhotos || []).map(p => ({ url: p.url, isOwner: false, caption: p.caption })),
|
|
||||||
];
|
|
||||||
|
|
||||||
return allImages.length > 0 ? (
|
|
||||||
<>
|
<>
|
||||||
<div className="aspect-video rounded-xl overflow-hidden bg-muted relative">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLightboxIndex(0)}
|
||||||
|
className="group relative aspect-video w-full cursor-zoom-in overflow-hidden rounded-xl bg-muted"
|
||||||
|
aria-label="View photo full screen"
|
||||||
|
>
|
||||||
<ImageWithFallback
|
<ImageWithFallback
|
||||||
src={allImages[0].url}
|
src={galleryImages[0].url}
|
||||||
alt={spot.title}
|
alt={spot.title}
|
||||||
className="w-full h-full object-cover"
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"
|
||||||
/>
|
/>
|
||||||
{!allImages[0].isOwner && (
|
{galleryImages[0].isOwner === false && (
|
||||||
<div className="absolute bottom-2 left-2 px-2 py-1 bg-black/70 rounded text-xs text-[#ccff00] flex items-center gap-1">
|
<div className="absolute bottom-2 left-2 flex items-center gap-1 rounded bg-black/70 px-2 py-1 text-xs text-[#ccff00]">
|
||||||
<Users className="h-3 w-3" />
|
<Users className="h-3 w-3" />
|
||||||
Community
|
Community
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
{/* Additional images */}
|
{/* Additional images */}
|
||||||
{allImages.length > 1 && (
|
{galleryImages.length > 1 && (
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
{allImages.slice(1, 5).map((img, idx) => (
|
{galleryImages.slice(1, 5).map((img, idx) => {
|
||||||
<div key={idx} className="aspect-square rounded-lg overflow-hidden bg-muted relative">
|
const realIndex = idx + 1;
|
||||||
<ImageWithFallback src={img.url} alt={`${spot.title} ${idx + 2}`} className="w-full h-full object-cover" />
|
const remaining = galleryImages.length - 5;
|
||||||
{!img.isOwner && (
|
return (
|
||||||
<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">
|
<button
|
||||||
|
key={`${img.url}-${realIndex}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLightboxIndex(realIndex)}
|
||||||
|
className="relative aspect-square cursor-zoom-in overflow-hidden rounded-lg bg-muted"
|
||||||
|
aria-label={`View photo ${realIndex + 1} of ${galleryImages.length}`}
|
||||||
|
>
|
||||||
|
<ImageWithFallback
|
||||||
|
src={img.url}
|
||||||
|
alt={`${spot.title} ${realIndex + 1}`}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
{img.isOwner === false && (
|
||||||
|
<div className="absolute bottom-1 left-1 flex items-center gap-0.5 rounded bg-black/70 px-1.5 py-0.5 text-[10px] text-[#ccff00]">
|
||||||
<Users className="h-2.5 w-2.5" />
|
<Users className="h-2.5 w-2.5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{idx === 3 && remaining > 0 && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/70 text-sm font-bold text-white">
|
||||||
|
+{remaining}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="aspect-video rounded-xl bg-gradient-to-br from-primary/20 to-accent/20 flex items-center justify-center">
|
<div className="flex aspect-video items-center justify-center rounded-xl bg-gradient-to-br from-primary/20 to-accent/20">
|
||||||
<MapPin className="h-16 w-16 text-primary/40" />
|
<MapPin className="h-16 w-16 text-primary/40" />
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Spot Info */}
|
{/* Spot Info */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -1043,6 +1068,14 @@ export default function SpotDetailPage() {
|
||||||
destination={coordinates}
|
destination={coordinates}
|
||||||
destinationLabel={spot?.title ?? 'Spot'}
|
destinationLabel={spot?.title ?? 'Spot'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Full-screen photo viewer */}
|
||||||
|
<ImageLightbox
|
||||||
|
images={galleryImages}
|
||||||
|
index={lightboxIndex}
|
||||||
|
onIndexChange={setLightboxIndex}
|
||||||
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue