SkateSpots/src/components/ImageLightbox.tsx
SkateSpots 8d8a46a73f 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.
2026-09-26 17:03:30 +07:00

131 lines
4.7 KiB
TypeScript

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>
);
}