All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 42s
Build & Deploy / 🧪 QA (push) Successful in 5m17s
Build & Deploy / 🏗️ Build (push) Successful in 8m36s
Build & Deploy / 🚀 Deploy (push) Successful in 17s
Build & Deploy / 🧪 Smoke Test (push) Successful in 53s
Build & Deploy / ⚡ Lighthouse (push) Successful in 7m38s
Build & Deploy / 🔔 Notify (push) Successful in 2s
259 lines
10 KiB
TypeScript
259 lines
10 KiB
TypeScript
'use client';
|
||
|
||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||
import Image from 'next/image';
|
||
import { createPortal } from 'react-dom';
|
||
import { m, LazyMotion, AnimatePresence } from 'framer-motion';
|
||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||
|
||
interface LightboxProps {
|
||
isOpen: boolean;
|
||
images: string[];
|
||
initialIndex: number;
|
||
onClose: () => void;
|
||
}
|
||
|
||
export default function Lightbox({ isOpen, images, initialIndex, onClose }: LightboxProps) {
|
||
const router = useRouter();
|
||
const searchParams = useSearchParams();
|
||
const pathname = usePathname();
|
||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||
const [mounted, setMounted] = useState(false);
|
||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
|
||
return () => setMounted(false);
|
||
}, []);
|
||
|
||
const updateUrl = useCallback(
|
||
(index: number | null) => {
|
||
const params = new URLSearchParams(searchParams.toString());
|
||
if (index !== null) {
|
||
params.set('photo', index.toString());
|
||
} else {
|
||
params.delete('photo');
|
||
}
|
||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
||
},
|
||
[pathname, router, searchParams],
|
||
);
|
||
|
||
const prevImage = useCallback(() => {
|
||
setCurrentIndex((prev) => {
|
||
const next = prev === 0 ? images.length - 1 : prev - 1;
|
||
updateUrl(next);
|
||
return next;
|
||
});
|
||
}, [images.length, updateUrl]);
|
||
|
||
const nextImage = useCallback(() => {
|
||
setCurrentIndex((prev) => {
|
||
const next = prev === images.length - 1 ? 0 : prev + 1;
|
||
updateUrl(next);
|
||
return next;
|
||
});
|
||
}, [images.length, updateUrl]);
|
||
|
||
useEffect(() => {
|
||
const photoParam = searchParams.get('photo');
|
||
if (photoParam !== null) {
|
||
const index = parseInt(photoParam, 10);
|
||
if (!isNaN(index) && index >= 0 && index < images.length) {
|
||
setCurrentIndex(index); // eslint-disable-line react-hooks/set-state-in-effect
|
||
}
|
||
}
|
||
}, [searchParams, images.length]);
|
||
|
||
const handleClose = useCallback(() => {
|
||
updateUrl(null);
|
||
onClose();
|
||
}, [updateUrl, onClose]);
|
||
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
updateUrl(currentIndex);
|
||
}
|
||
}, [isOpen, currentIndex, updateUrl]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) {
|
||
if (previousFocusRef.current) {
|
||
previousFocusRef.current.focus();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Capture previous focus
|
||
previousFocusRef.current = document.activeElement as HTMLElement;
|
||
|
||
// Focus close button on open
|
||
setTimeout(() => closeButtonRef.current?.focus(), 100);
|
||
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') handleClose();
|
||
if (e.key === 'ArrowLeft') prevImage();
|
||
if (e.key === 'ArrowRight') nextImage();
|
||
|
||
// Focus Trap
|
||
if (e.key === 'Tab') {
|
||
const focusableElements = document.querySelectorAll(
|
||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||
);
|
||
const modalElements = Array.from(focusableElements).filter((el) =>
|
||
document.querySelector('[role="dialog"]')?.contains(el),
|
||
);
|
||
|
||
if (modalElements.length === 0) return;
|
||
|
||
const firstElement = modalElements[0] as HTMLElement;
|
||
const lastElement = modalElements[modalElements.length - 1] as HTMLElement;
|
||
|
||
if (e.shiftKey) {
|
||
if (document.activeElement === firstElement) {
|
||
lastElement.focus();
|
||
e.preventDefault();
|
||
}
|
||
} else {
|
||
if (document.activeElement === lastElement) {
|
||
firstElement.focus();
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// Lock scroll
|
||
const originalStyle = window.getComputedStyle(document.body).overflow;
|
||
document.body.style.overflow = 'hidden';
|
||
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
|
||
return () => {
|
||
document.body.style.overflow = originalStyle;
|
||
window.removeEventListener('keydown', handleKeyDown);
|
||
};
|
||
}, [isOpen, prevImage, nextImage, handleClose]);
|
||
|
||
if (!mounted) return null;
|
||
|
||
return createPortal(
|
||
<LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
|
||
<AnimatePresence>
|
||
{isOpen && (
|
||
<div
|
||
className="fixed inset-0 z-[99999] flex items-center justify-center"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
>
|
||
<m.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.5 }}
|
||
className="absolute inset-0 bg-primary/95 backdrop-blur-xl"
|
||
onClick={handleClose}
|
||
/>
|
||
|
||
<m.button
|
||
initial={{ opacity: 0, scale: 0.5 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.5 }}
|
||
transition={{ delay: 0.1, duration: 0.4 }}
|
||
ref={closeButtonRef}
|
||
onClick={handleClose}
|
||
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10"
|
||
aria-label="Close lightbox"
|
||
>
|
||
<div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500">
|
||
<span className="text-3xl font-extralight leading-none mb-1">×</span>
|
||
</div>
|
||
</m.button>
|
||
|
||
<m.button
|
||
initial={{ opacity: 0, x: -20 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
exit={{ opacity: 0, x: -20 }}
|
||
transition={{ delay: 0.2, duration: 0.4 }}
|
||
onClick={prevImage}
|
||
className="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
|
||
aria-label="Previous image"
|
||
>
|
||
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">
|
||
‹
|
||
</span>
|
||
</m.button>
|
||
|
||
<m.button
|
||
initial={{ opacity: 0, x: 20 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
exit={{ opacity: 0, x: 20 }}
|
||
transition={{ delay: 0.2, duration: 0.4 }}
|
||
onClick={nextImage}
|
||
className="absolute right-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
|
||
aria-label="Next image"
|
||
>
|
||
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">
|
||
›
|
||
</span>
|
||
</m.button>
|
||
|
||
<m.div
|
||
initial={{ opacity: 0, y: 40, scale: 0.95 }}
|
||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||
exit={{ opacity: 0, y: 20, scale: 0.98 }}
|
||
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||
className="relative w-full h-full max-w-6xl max-h-[85vh] flex flex-col items-center justify-center p-4 md:p-12 z-20 pointer-events-none"
|
||
>
|
||
<div className="pointer-events-auto w-full h-full flex flex-col items-center justify-center">
|
||
<div className="relative w-full h-full shadow-[0_40px_100px_-20px_rgba(0,0,0,0.6)] ring-1 ring-white/20 overflow-hidden bg-primary-dark/50 rounded-2xl flex items-center justify-center">
|
||
<AnimatePresence mode="wait" initial={false}>
|
||
<m.div
|
||
key={currentIndex}
|
||
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
|
||
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
|
||
exit={{ opacity: 0, scale: 0.9, filter: 'blur(10px)' }}
|
||
transition={{ duration: 0.7, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||
className="relative w-full h-full"
|
||
>
|
||
<Image
|
||
src={images[currentIndex]}
|
||
alt={`Gallery image ${currentIndex + 1}`}
|
||
fill
|
||
className="object-cover transition-transform duration-1000 hover:scale-[1.03]"
|
||
unoptimized
|
||
/>
|
||
</m.div>
|
||
</AnimatePresence>
|
||
|
||
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
|
||
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[url('/grid.svg')] bg-repeat z-10" />
|
||
|
||
{/* Premium Reflection: Subtle gradient to give material feel */}
|
||
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
|
||
</div>
|
||
|
||
<m.div
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: 10 }}
|
||
transition={{ delay: 0.3, duration: 0.4 }}
|
||
className="mt-8 flex items-center gap-4"
|
||
>
|
||
<div className="h-px w-12 bg-white/20" />
|
||
<div className="bg-white/5 backdrop-blur-2xl text-white px-6 py-2 rounded-full border border-white/10 text-[11px] font-bold tracking-[0.2em] uppercase">
|
||
{currentIndex + 1} <span className="text-accent mx-3">/</span> {images.length}
|
||
</div>
|
||
<div className="h-px w-12 bg-white/20" />
|
||
</m.div>
|
||
</div>
|
||
</m.div>
|
||
</div>
|
||
)}
|
||
</AnimatePresence>
|
||
</LazyMotion>,
|
||
document.body,
|
||
);
|
||
}
|