467 lines
18 KiB
TypeScript
467 lines
18 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import Image from "next/image";
|
|
import {
|
|
X,
|
|
ZoomIn,
|
|
ZoomOut,
|
|
RotateCw,
|
|
RotateCcw,
|
|
Maximize2,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
} from "lucide-react";
|
|
import { useTranslations } from "next-intl";
|
|
interface ProductImageGalleryProps {
|
|
images: string[];
|
|
productName: string;
|
|
noImageText: string;
|
|
}
|
|
|
|
export function ProductImageGallery({
|
|
images,
|
|
productName,
|
|
noImageText,
|
|
}: ProductImageGalleryProps) {
|
|
const [selectedImage, setSelectedImage] = useState(0);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [zoom, setZoom] = useState(1);
|
|
const [rotation, setRotation] = useState(0);
|
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
|
const t = useTranslations();
|
|
const autoplayTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
|
const modalImageRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
setSelectedImage(0);
|
|
}, [images]);
|
|
|
|
useEffect(() => {
|
|
if (images.length <= 1 || isModalOpen) return;
|
|
|
|
const startAutoplay = () => {
|
|
autoplayTimerRef.current = setInterval(() => {
|
|
setSelectedImage((prev) => (prev + 1) % images.length);
|
|
}, 3000);
|
|
};
|
|
|
|
startAutoplay();
|
|
return () => {
|
|
if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current);
|
|
};
|
|
}, [images.length, isModalOpen]);
|
|
|
|
useEffect(() => {
|
|
if (isModalOpen) {
|
|
document.body.style.overflow = "hidden";
|
|
if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current);
|
|
} else {
|
|
document.body.style.overflow = "unset";
|
|
}
|
|
return () => {
|
|
document.body.style.overflow = "unset";
|
|
};
|
|
}, [isModalOpen]);
|
|
|
|
const handleImageSelect = useCallback(
|
|
(index: number) => {
|
|
setSelectedImage(index);
|
|
if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current);
|
|
if (images.length > 1 && !isModalOpen) {
|
|
autoplayTimerRef.current = setInterval(() => {
|
|
setSelectedImage((prev) => (prev + 1) % images.length);
|
|
}, 3000);
|
|
}
|
|
},
|
|
[images.length, isModalOpen],
|
|
);
|
|
|
|
const openModal = () => {
|
|
setIsModalOpen(true);
|
|
resetTransform();
|
|
};
|
|
|
|
const closeModal = () => {
|
|
setIsModalOpen(false);
|
|
resetTransform();
|
|
};
|
|
|
|
const resetTransform = () => {
|
|
setZoom(1);
|
|
setRotation(0);
|
|
setPosition({ x: 0, y: 0 });
|
|
};
|
|
|
|
const handleZoomIn = () => {
|
|
setZoom((prev) => Math.min(prev + 0.25, 5));
|
|
};
|
|
|
|
const handleZoomOut = () => {
|
|
setZoom((prev) => Math.max(prev - 0.25, 0.5));
|
|
};
|
|
|
|
const handleRotateClockwise = () => {
|
|
setRotation((prev) => (prev + 90) % 360);
|
|
};
|
|
|
|
const handleRotateCounterClockwise = () => {
|
|
setRotation((prev) => (prev - 90 + 360) % 360);
|
|
};
|
|
|
|
const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => {
|
|
if (zoom > 1) {
|
|
setIsDragging(true);
|
|
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
|
|
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
|
|
setDragStart({
|
|
x: clientX - position.x,
|
|
y: clientY - position.y,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleMouseMove = (e: React.MouseEvent | React.TouchEvent) => {
|
|
if (isDragging && zoom > 1) {
|
|
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
|
|
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
|
|
setPosition({
|
|
x: clientX - dragStart.x,
|
|
y: clientY - dragStart.y,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsDragging(false);
|
|
};
|
|
|
|
const handleWheel = (e: React.WheelEvent) => {
|
|
e.preventDefault();
|
|
if (e.deltaY < 0) {
|
|
handleZoomIn();
|
|
} else {
|
|
handleZoomOut();
|
|
}
|
|
};
|
|
|
|
const handleModalImageChange = (direction: "prev" | "next") => {
|
|
if (direction === "next") {
|
|
setSelectedImage((prev) => (prev + 1) % images.length);
|
|
} else {
|
|
setSelectedImage((prev) => (prev - 1 + images.length) % images.length);
|
|
}
|
|
resetTransform();
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="w-full lg:flex-1 max-w-2xl">
|
|
<div className="relative">
|
|
<div
|
|
className="relative aspect-square w-full rounded-xl md:rounded-2xl overflow-hidden bg-gradient-to-br from-gray-50 to-gray-100 cursor-pointer group shadow-sm hover:shadow-md transition-all"
|
|
onClick={openModal}
|
|
>
|
|
{images.length > 0 && images[selectedImage] ? (
|
|
<>
|
|
<Image
|
|
src={images[selectedImage]}
|
|
alt={productName}
|
|
fill
|
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
|
className="object-contain transition-transform group-hover:scale-105"
|
|
priority
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/20 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
|
<div className="bg-white/90 backdrop-blur-sm rounded-full p-2 md:p-3 transform translate-y-4 group-hover:translate-y-0 transition-transform">
|
|
<Maximize2 className="w-4 h-4 md:w-5 md:h-5 text-gray-800" />
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full text-gray-400 text-sm md:text-base">
|
|
{noImageText}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{images.length > 1 && (
|
|
<div className="mt-4 flex gap-2 overflow-x-auto pb-2">
|
|
{images.map((image, index) => (
|
|
<button
|
|
key={index}
|
|
onClick={() => handleImageSelect(index)}
|
|
className={`relative w-16 h-16 shrink-0 rounded cursor-pointer overflow-hidden border-2 transition-all ${
|
|
selectedImage === index
|
|
? "border-primary ring-2 ring-primary/20"
|
|
: "border-gray-200 hover:border-gray-300"
|
|
}`}
|
|
>
|
|
<Image
|
|
src={image}
|
|
alt={`${productName} ${index + 1}`}
|
|
fill
|
|
className="object-cover"
|
|
/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modal */}
|
|
{isModalOpen && (
|
|
<div className="fixed inset-0 z-99 bg-gradient-to-br from-gray-900/95 via-gray-800/95 to-gray-900/95 backdrop-blur-xl flex flex-col">
|
|
{/* Top Bar */}
|
|
<div className="absolute top-0 left-0 right-0 p-3 md:p-4 z-20 bg-gradient-to-b from-black/20 to-transparent">
|
|
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
|
<div className="flex items-center gap-2 md:gap-3 min-w-0 flex-1">
|
|
<div className="w-1 h-4 md:h-6 bg-blue-500 rounded-full shrink-0" />
|
|
<span className="text-white font-medium text-sm md:text-base truncate">
|
|
{productName}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={closeModal}
|
|
className="p-2 md:p-2.5 bg-white/10 hover:bg-white/20 rounded-lg md:rounded-xl transition-all backdrop-blur-sm border border-white/10 shrink-0 ml-2"
|
|
aria-label="Close"
|
|
>
|
|
<X className="w-4 h-4 md:w-5 md:h-5 text-white" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Image Area */}
|
|
<div className="flex-1 flex items-center justify-center relative px-2 md:px-16 lg:px-20">
|
|
{/* Left Arrow - Desktop */}
|
|
{images.length > 1 && (
|
|
<button
|
|
onClick={() => handleModalImageChange("prev")}
|
|
className="hidden md:flex absolute left-3 lg:left-6 p-2.5 md:p-3 bg-white/10 hover:bg-white/20 rounded-xl md:rounded-2xl transition-all backdrop-blur-md border border-white/10 hover:scale-110 z-10 group"
|
|
aria-label="Previous image"
|
|
>
|
|
<ChevronLeft className="w-5 h-5 md:w-6 md:h-6 text-white" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Image Container */}
|
|
<div
|
|
ref={modalImageRef}
|
|
className="w-full h-full flex items-center justify-center overflow-hidden"
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
onTouchStart={handleMouseDown}
|
|
onTouchMove={handleMouseMove}
|
|
onTouchEnd={handleMouseUp}
|
|
onWheel={handleWheel}
|
|
style={{
|
|
cursor:
|
|
zoom > 1 ? (isDragging ? "grabbing" : "grab") : "default",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
transform: `translate(${position.x}px, ${position.y}px) scale(${zoom}) rotate(${rotation}deg)`,
|
|
transition: isDragging
|
|
? "none"
|
|
: "transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
|
}}
|
|
className="relative w-[90vw] h-[60vh] md:w-[75vw] md:h-[70vh]"
|
|
>
|
|
<Image
|
|
src={images[selectedImage]}
|
|
alt={productName}
|
|
fill
|
|
className="object-contain pointer-events-none select-none"
|
|
priority
|
|
draggable={false}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Arrow - Desktop */}
|
|
{images.length > 1 && (
|
|
<button
|
|
onClick={() => handleModalImageChange("next")}
|
|
className="hidden md:flex absolute right-3 lg:right-6 p-2.5 md:p-3 bg-white/10 hover:bg-white/20 rounded-xl md:rounded-2xl transition-all backdrop-blur-md border border-white/10 hover:scale-110 z-10 group"
|
|
aria-label="Next image"
|
|
>
|
|
<ChevronRight className="w-5 h-5 md:w-6 md:h-6 text-white" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bottom Control Bar */}
|
|
<div className="bg-gradient-to-t from-black/40 via-black/20 to-transparent backdrop-blur-xl border-t border-white/10">
|
|
<div className="max-w-7xl mx-auto px-3 md:px-6 py-3 md:py-4">
|
|
{/* Mobile Layout */}
|
|
<div className="md:hidden flex flex-col gap-2">
|
|
{/* Row 1: Navigation */}
|
|
{images.length > 1 && (
|
|
<div className="flex items-center justify-between gap-2">
|
|
<button
|
|
onClick={() => handleModalImageChange("prev")}
|
|
className="flex-1 p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all border border-white/10"
|
|
aria-label="Previous"
|
|
>
|
|
<ChevronLeft className="w-5 h-5 text-white mx-auto" />
|
|
</button>
|
|
<div className="px-4 py-2 bg-white/10 backdrop-blur-md rounded-lg border border-white/10">
|
|
<span className="text-white text-sm font-medium whitespace-nowrap">
|
|
{selectedImage + 1} / {images.length}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => handleModalImageChange("next")}
|
|
className="flex-1 p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all border border-white/10"
|
|
aria-label="Next"
|
|
>
|
|
<ChevronRight className="w-5 h-5 text-white mx-auto" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Row 2: Zoom & Rotate */}
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-1 bg-white/10 backdrop-blur-md rounded-lg p-1 border border-white/10 flex-1">
|
|
<button
|
|
onClick={handleZoomOut}
|
|
className="p-2 hover:bg-white/20 rounded-md transition-all flex-1"
|
|
aria-label="Zoom out"
|
|
>
|
|
<ZoomOut className="w-4 h-4 text-white mx-auto" />
|
|
</button>
|
|
<div className="px-2 py-1 bg-white/10 rounded text-center min-w-[50px]">
|
|
<span className="text-white text-xs font-medium">
|
|
{Math.round(zoom * 100)}%
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={handleZoomIn}
|
|
className="p-2 hover:bg-white/20 rounded-md transition-all flex-1"
|
|
aria-label="Zoom in"
|
|
>
|
|
<ZoomIn className="w-4 h-4 text-white mx-auto" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 bg-white/10 backdrop-blur-md rounded-lg p-1 border border-white/10">
|
|
<button
|
|
onClick={handleRotateCounterClockwise}
|
|
className="p-2 hover:bg-white/20 rounded-md transition-all"
|
|
aria-label="Rotate counter-clockwise"
|
|
>
|
|
<RotateCcw className="w-4 h-4 text-white" />
|
|
</button>
|
|
<button
|
|
onClick={handleRotateClockwise}
|
|
className="p-2 hover:bg-white/20 rounded-md transition-all"
|
|
aria-label="Rotate clockwise"
|
|
>
|
|
<RotateCw className="w-4 h-4 text-white" />
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
onClick={resetTransform}
|
|
className="px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all text-white text-xs font-medium border border-white/10"
|
|
aria-label="Reset view"
|
|
>
|
|
{t("reset")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desktop Layout */}
|
|
<div className="hidden md:flex items-center justify-center gap-2">
|
|
<button
|
|
onClick={() => handleModalImageChange("prev")}
|
|
disabled={images.length <= 1}
|
|
className="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all disabled:opacity-30 disabled:cursor-not-allowed border border-white/10"
|
|
aria-label="Previous"
|
|
>
|
|
<ChevronLeft className="w-4 h-4 text-white" />
|
|
</button>
|
|
|
|
<div className="flex items-center gap-1.5 bg-white/10 backdrop-blur-md rounded-lg p-1 border border-white/10">
|
|
<button
|
|
onClick={handleZoomOut}
|
|
className="p-2 hover:bg-white/20 rounded-md transition-all"
|
|
aria-label="Zoom out"
|
|
>
|
|
<ZoomOut className="w-4 h-4 text-white" />
|
|
</button>
|
|
<div className="px-3 py-1 bg-white/10 rounded min-w-[60px] text-center">
|
|
<span className="text-white text-sm font-medium">
|
|
{Math.round(zoom * 100)}%
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={handleZoomIn}
|
|
className="p-2 hover:bg-white/20 rounded-md transition-all"
|
|
aria-label="Zoom in"
|
|
>
|
|
<ZoomIn className="w-4 h-4 text-white" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="w-px h-8 bg-white/20" />
|
|
|
|
<div className="flex items-center gap-1.5 bg-white/10 backdrop-blur-md rounded-lg p-1 border border-white/10">
|
|
<button
|
|
onClick={handleRotateCounterClockwise}
|
|
className="p-2 hover:bg-white/20 rounded-md transition-all"
|
|
aria-label="Rotate counter-clockwise"
|
|
>
|
|
<RotateCcw className="w-4 h-4 text-white" />
|
|
</button>
|
|
<button
|
|
onClick={handleRotateClockwise}
|
|
className="p-2 hover:bg-white/20 rounded-md transition-all"
|
|
aria-label="Rotate clockwise"
|
|
>
|
|
<RotateCw className="w-4 h-4 text-white" />
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
onClick={resetTransform}
|
|
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all text-white text-sm font-medium border border-white/10"
|
|
aria-label="Reset view"
|
|
>
|
|
Reset
|
|
</button>
|
|
|
|
<div className="w-px h-8 bg-white/20" />
|
|
|
|
<button
|
|
onClick={() => handleModalImageChange("next")}
|
|
disabled={images.length <= 1}
|
|
className="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all disabled:opacity-30 disabled:cursor-not-allowed border border-white/10"
|
|
aria-label="Next"
|
|
>
|
|
<ChevronRight className="w-4 h-4 text-white" />
|
|
</button>
|
|
|
|
{images.length > 1 && (
|
|
<>
|
|
<div className="w-px h-8 bg-white/20" />
|
|
<div className="px-4 py-2 bg-white/10 backdrop-blur-md rounded-lg border border-white/10">
|
|
<span className="text-white text-sm font-medium">
|
|
{selectedImage + 1} / {images.length}
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|