373 lines
12 KiB
JavaScript
373 lines
12 KiB
JavaScript
'use client';
|
|
|
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
import { Swiper, SwiperSlide } from "swiper/react";
|
|
import { Navigation, Pagination, Thumbs, FreeMode } from "swiper/modules";
|
|
|
|
import "swiper/css";
|
|
import "swiper/css/navigation";
|
|
import "swiper/css/pagination";
|
|
import "swiper/css/thumbs";
|
|
import "swiper/css/free-mode";
|
|
|
|
const ImageWithRetry = ({ src, alt, className }) => {
|
|
const [imageSrc, setImageSrc] = useState(src);
|
|
const retryCountRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
setImageSrc(src);
|
|
retryCountRef.current = 0;
|
|
}, [src]);
|
|
|
|
const handleError = useCallback(() => {
|
|
if (retryCountRef.current >= 3) return;
|
|
retryCountRef.current += 1;
|
|
const delay = 300 * retryCountRef.current;
|
|
setTimeout(() => {
|
|
const url = new URL(imageSrc, typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
|
|
url.searchParams.set('v', String(Date.now()));
|
|
setImageSrc(url.pathname + url.search);
|
|
}, delay);
|
|
}, [imageSrc]);
|
|
|
|
return (
|
|
<img
|
|
src={imageSrc}
|
|
alt={alt}
|
|
className={className}
|
|
loading="lazy"
|
|
onError={handleError}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const ProductImageGallery = ({ data }) => {
|
|
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
|
const [thumbsSwiper, setThumbsSwiper] = useState(null);
|
|
const thumbsSwiperRef = useRef(null);
|
|
|
|
if (!data?.attachments || data.attachments.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const mainImageIndex = useMemo(() => {
|
|
if (!data.image) return 0;
|
|
|
|
const mainImageIndex = data.attachments.findIndex(
|
|
attachment => attachment.file_url === data.image
|
|
);
|
|
|
|
return mainImageIndex >= 0 ? mainImageIndex : 0;
|
|
}, [data.image, data.attachments]);
|
|
|
|
const currentImage = useMemo(() => {
|
|
return data.attachments[currentImageIndex]?.file_url || data.attachments[0]?.file_url;
|
|
}, [data.attachments, currentImageIndex]);
|
|
|
|
const scrollThumbnailToIndex = useCallback((index) => {
|
|
if (thumbsSwiperRef.current?.swiper) {
|
|
try {
|
|
const swiper = thumbsSwiperRef.current.swiper;
|
|
|
|
if (index === 0) {
|
|
swiper.scrollTo(0, 300);
|
|
} else {
|
|
swiper.slideTo(index, 300);
|
|
}
|
|
} catch (error) {
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const changeMainImage = useCallback((direction) => {
|
|
if (data.attachments.length <= 1) return;
|
|
|
|
let newIndex;
|
|
if (direction === 'next') {
|
|
newIndex = (currentImageIndex + 1) % data.attachments.length;
|
|
} else {
|
|
newIndex = (currentImageIndex - 1 + data.attachments.length) % data.attachments.length;
|
|
}
|
|
|
|
setCurrentImageIndex(newIndex);
|
|
|
|
setTimeout(() => {
|
|
scrollThumbnailToIndex(newIndex);
|
|
}, 100);
|
|
}, [currentImageIndex, data.attachments.length, scrollThumbnailToIndex]);
|
|
|
|
const changeMainImageByIndex = useCallback((index) => {
|
|
setCurrentImageIndex(index);
|
|
|
|
setTimeout(() => {
|
|
scrollThumbnailToIndex(index);
|
|
}, 100);
|
|
}, [scrollThumbnailToIndex]);
|
|
|
|
useEffect(() => {
|
|
setCurrentImageIndex(mainImageIndex);
|
|
}, [mainImageIndex]);
|
|
|
|
const handleKeyDown = useCallback((event) => {
|
|
if (data.attachments.length <= 1) return;
|
|
|
|
switch (event.key) {
|
|
case 'ArrowLeft':
|
|
event.preventDefault();
|
|
changeMainImage('prev');
|
|
break;
|
|
case 'ArrowRight':
|
|
event.preventDefault();
|
|
changeMainImage('next');
|
|
break;
|
|
case 'Home':
|
|
event.preventDefault();
|
|
setCurrentImageIndex(0);
|
|
setTimeout(() => scrollThumbnailToIndex(0), 100);
|
|
break;
|
|
case 'End':
|
|
event.preventDefault();
|
|
setCurrentImageIndex(data.attachments.length - 1);
|
|
setTimeout(() => scrollThumbnailToIndex(data.attachments.length - 1), 100);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}, [data.attachments.length, changeMainImage, scrollThumbnailToIndex]);
|
|
|
|
useEffect(() => {
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
|
|
return () => {
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
}, [handleKeyDown]);
|
|
|
|
const thumbnailSlides = useMemo(() => {
|
|
return data.attachments.map((attachment, index) => (
|
|
<SwiperSlide
|
|
key={index}
|
|
className="!w-16 !h-16"
|
|
onClick={() => changeMainImageByIndex(index)}
|
|
>
|
|
<div
|
|
className={`w-full h-full rounded-lg overflow-hidden cursor-pointer transition-all duration-300 hover:scale-105 focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2 ${
|
|
index === currentImageIndex
|
|
? 'border-2'
|
|
: 'border border-[#eee] hover:border-gray-300'
|
|
}`}
|
|
style={index === currentImageIndex ? { borderColor: '#1A1A1A', borderWidth: '2px' } : {}}
|
|
tabIndex={0}
|
|
role="button"
|
|
aria-label={`View image ${index + 1} / ${data.attachments.length}`}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
changeMainImageByIndex(index);
|
|
}
|
|
}}
|
|
>
|
|
<ImageWithRetry
|
|
src={attachment.file_url}
|
|
alt={`${data?.title || 'Product'} - ${index + 1}`}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
<div className="absolute inset-0 bg-gray-100 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
|
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin"></div>
|
|
</div>
|
|
</div>
|
|
</SwiperSlide>
|
|
));
|
|
}, [data.attachments, currentImageIndex, data?.title, changeMainImageByIndex]);
|
|
|
|
return (
|
|
<div className="w-full md:w-120 flex justify-center md:justify-start mb-4 md:mb-0 max-w-full">
|
|
<div className="relative w-full max-w-[320px] md:max-w-full">
|
|
<div className="relative overflow-hidden rounded-xl shadow-lg mb-6 group/main-image">
|
|
<ImageWithRetry
|
|
src={currentImage}
|
|
alt={data?.title || 'Product Image'}
|
|
className="w-full max-w-[320px] md:max-w-full object-contain"
|
|
/>
|
|
|
|
{data.attachments.length > 1 && (
|
|
<>
|
|
<button
|
|
className="absolute left-2 top-1/2 transform -translate-y-1/2 w-12 h-12 bg-white/40 backdrop-blur-md rounded-full border-0 flex items-center justify-center shadow-[0_8px_32px_rgba(0,0,0,0.08)] hover:bg-white/60 hover:shadow-[0_12px_40px_rgba(0,0,0,0.12)] transition-all duration-500 ease-out z-10 focus:outline-none focus:ring-2 focus:ring-white/30 focus:ring-offset-2 focus:ring-offset-transparent opacity-0 group-hover/main-image:opacity-100 scale-90 group-hover/main-image:scale-100"
|
|
onClick={() => changeMainImage('prev')}
|
|
aria-label={`Previous image (${currentImageIndex === 0 ? data.attachments.length : currentImageIndex} / ${data.attachments.length})`}
|
|
title="Previous image (←)"
|
|
>
|
|
<svg
|
|
className="w-5 h-5 text-gray-700 hover:text-gray-900 transition-all duration-500 ease-out hover:scale-110"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
strokeWidth={1.5}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M15 18l-6-6 6-6" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
className="absolute right-2 top-1/2 transform -translate-y-1/2 w-12 h-12 bg-white/40 backdrop-blur-md rounded-full border-0 flex items-center justify-center shadow-[0_8px_32px_rgba(0,0,0,0.08)] hover:bg-white/60 hover:shadow-[0_12px_40px_rgba(0,0,0,0.12)] transition-all duration-500 ease-out z-10 focus:outline-none focus:ring-2 focus:ring-white/30 focus:ring-offset-2 focus:ring-offset-transparent opacity-0 group-hover/main-image:opacity-100 scale-90 group-hover/main-image:scale-100"
|
|
onClick={() => changeMainImage('next')}
|
|
aria-label={`Next image (${currentImageIndex === data.attachments.length - 1 ? 1 : currentImageIndex + 2} / ${data.attachments.length})`}
|
|
title="Next image (→)"
|
|
>
|
|
<svg
|
|
className="w-5 h-5 text-gray-700 hover:text-gray-900 transition-all duration-500 ease-out hover:scale-110"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
strokeWidth={1.5}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M9 18l6-6-6-6" />
|
|
</svg>
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{data.attachments.length > 1 && (
|
|
<div className="relative">
|
|
<Swiper
|
|
ref={thumbsSwiperRef}
|
|
onSwiper={setThumbsSwiper}
|
|
spaceBetween={16}
|
|
slidesPerView="auto"
|
|
freeMode={true}
|
|
watchSlidesProgress={true}
|
|
modules={[FreeMode, Navigation, Pagination, Thumbs]}
|
|
className="thumbs-swiper"
|
|
grabCursor={true}
|
|
resistance={true}
|
|
resistanceRatio={0.85}
|
|
>
|
|
{thumbnailSlides}
|
|
</Swiper>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<style jsx>{`
|
|
.thumbs-swiper .swiper-slide {
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
width: 64px !important;
|
|
margin-right: 16px !important;
|
|
}
|
|
|
|
.thumbs-swiper .swiper-slide:hover {
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
.thumbs-swiper .swiper-slide:last-child {
|
|
margin-right: 0 !important;
|
|
}
|
|
|
|
.thumbs-swiper .swiper-slide .border-2 {
|
|
}
|
|
|
|
.backdrop-blur-sm {
|
|
backdrop-filter: blur(8px);
|
|
-webkit-backdrop-filter: blur(8px);
|
|
}
|
|
|
|
.group:hover .backdrop-blur-sm {
|
|
backdrop-filter: blur(12px);
|
|
-webkit-backdrop-filter: blur(12px);
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.thumbs-swiper .swiper-slide {
|
|
width: 4rem !important;
|
|
height: 4rem !important;
|
|
}
|
|
}
|
|
|
|
.thumbs-swiper .swiper-scrollbar {
|
|
display: none;
|
|
}
|
|
|
|
.thumbs-swiper {
|
|
touch-action: pan-y pinch-zoom;
|
|
-webkit-user-select: none;
|
|
-moz-user-select: none;
|
|
-ms-user-select: none;
|
|
user-select: none;
|
|
}
|
|
|
|
.thumbs-swiper .swiper-slide {
|
|
touch-action: manipulation;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.animate-spin {
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
.focus-within\\:ring-2:focus-within {
|
|
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
|
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
|
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
|
}
|
|
|
|
.focus-within\\:ring-blue-500:focus-within {
|
|
--tw-ring-opacity: 1;
|
|
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
|
|
}
|
|
|
|
.focus-within\\:ring-offset-2:focus-within {
|
|
--tw-ring-offset-width: 2px;
|
|
}
|
|
|
|
@media (hover: none) and (pointer: coarse) {
|
|
.thumbs-swiper .swiper-slide:hover {
|
|
transform: none;
|
|
}
|
|
}
|
|
|
|
.group\/main-image button {
|
|
border-radius: 50% !important;
|
|
-webkit-border-radius: 50% !important;
|
|
}
|
|
|
|
@media (prefers-contrast: high) {
|
|
.border-gray-200 {
|
|
border-color: #000;
|
|
}
|
|
|
|
.border-blue-500 {
|
|
border-color: #0066cc;
|
|
}
|
|
|
|
.text-gray-600 {
|
|
color: #000;
|
|
}
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.thumbs-swiper .swiper-slide,
|
|
.backdrop-blur-sm {
|
|
transition: none;
|
|
}
|
|
|
|
.animate-spin {
|
|
animation: none;
|
|
}
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ProductImageGallery;
|