Files
klz-cables.com/components/Lightbox.tsx
Marc Mintel 16d06d3275
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 23s
Build & Deploy / 🧪 QA (push) Successful in 2m1s
Build & Deploy / 🏗️ Build (push) Successful in 7m43s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Successful in 1m10s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m20s
Build & Deploy / 🔔 Notify (push) Successful in 2s
perf: deep react code splitting, next-intl payload scoping, and SVG hardware acceleration for PageSpeed 100
2026-02-20 11:53:42 +01:00

259 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import Image from 'next/image';
import { createPortal } from 'react-dom';
import { m, LazyMotion, domAnimation, 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={domAnimation}>
<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,
);
}