144 lines
4.1 KiB
TypeScript
144 lines
4.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
|
|
interface ImageLightboxProps {
|
|
images: { src: string; alt: string }[];
|
|
initialIndex: number;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export default function ImageLightbox({ images, initialIndex, onClose }: ImageLightboxProps) {
|
|
const [currentIndex, setCurrentIndex] = useState(
|
|
Math.max(0, Math.min(initialIndex, images.length - 1)),
|
|
);
|
|
|
|
const goTo = useCallback(
|
|
(i: number) => {
|
|
setCurrentIndex(Math.max(0, Math.min(i, images.length - 1)));
|
|
},
|
|
[images.length],
|
|
);
|
|
|
|
// Close on Escape key, navigate with arrows
|
|
useEffect(() => {
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
if (e.key === "ArrowLeft") goTo(currentIndex - 1);
|
|
if (e.key === "ArrowRight") goTo(currentIndex + 1);
|
|
};
|
|
window.addEventListener("keydown", handleKey);
|
|
return () => window.removeEventListener("keydown", handleKey);
|
|
}, [onClose, currentIndex, goTo]);
|
|
|
|
// Prevent body scroll while open
|
|
useEffect(() => {
|
|
document.body.style.overflow = "hidden";
|
|
return () => {
|
|
document.body.style.overflow = "";
|
|
};
|
|
}, []);
|
|
|
|
if (!images.length) return null;
|
|
|
|
const current = images[currentIndex];
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Image viewer"
|
|
>
|
|
{/* Faded backdrop */}
|
|
<div
|
|
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
|
onClick={onClose}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
{/* Close button — top right */}
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="absolute top-4 right-4 z-10 rounded-full p-2 text-white/70 hover:text-white transition-colors"
|
|
aria-label="Close image"
|
|
>
|
|
<svg
|
|
className="h-8 w-8"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Navigation — previous */}
|
|
{images.length > 1 && currentIndex > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => goTo(currentIndex - 1)}
|
|
className="absolute left-4 z-10 rounded-full p-2 text-white/70 hover:text-white transition-colors"
|
|
aria-label="Previous image"
|
|
>
|
|
<svg
|
|
className="h-8 w-8"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<polyline points="15 18 9 12 15 6" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
|
|
{/* Navigation — next */}
|
|
{images.length > 1 && currentIndex < images.length - 1 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => goTo(currentIndex + 1)}
|
|
className="absolute right-4 z-10 rounded-full p-2 text-white/70 hover:text-white transition-colors"
|
|
aria-label="Next image"
|
|
>
|
|
<svg
|
|
className="h-8 w-8"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<polyline points="9 18 15 12 9 6" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
|
|
{/* Full image */}
|
|
<div className="relative z-0 max-w-[90vw] max-h-[85vh] flex flex-col items-center">
|
|
<img
|
|
src={current.src}
|
|
alt={current.alt}
|
|
className="max-w-full max-h-[80vh] object-contain rounded-lg shadow-2xl"
|
|
/>
|
|
<p className="mt-3 text-sm text-white/70 text-center max-w-lg">{current.alt}</p>
|
|
|
|
{/* Image counter */}
|
|
{images.length > 1 && (
|
|
<p className="mt-1 text-xs text-white/50">
|
|
{currentIndex + 1} / {images.length}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|