image gallery modal
This commit is contained in:
@@ -11,10 +11,11 @@ const Categories = () => {
|
|||||||
const parentCategories = data?.results?.data?.filter(cat => !cat.parent) || []
|
const parentCategories = data?.results?.data?.filter(cat => !cat.parent) || []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='w-full'>
|
<div className='w-full flex justify-center'>
|
||||||
<Swiper
|
<Swiper
|
||||||
spaceBetween={16}
|
spaceBetween={16}
|
||||||
slidesPerView={'auto'}
|
slidesPerView={'auto'}
|
||||||
|
centeredSlides={true}
|
||||||
className='h-full'
|
className='h-full'
|
||||||
breakpoints={{
|
breakpoints={{
|
||||||
640: {
|
640: {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ const ActionProduct: FC<ActionProductProps> = ({ product }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='absolute top-6 right-4'>
|
<div className='absolute top-6 right-4' onClick={(e) => e.stopPropagation()}>
|
||||||
<div className='flex flex-col gap-3'>
|
<div className='flex flex-col gap-3'>
|
||||||
<button
|
<button
|
||||||
onClick={handleWishlist}
|
onClick={handleWishlist}
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
'use client'
|
||||||
|
import { FC, useEffect, useState, useRef, MouseEvent } from 'react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { CloseCircle, ArrowLeft2, ArrowRight2, SearchZoomIn, SearchZoomOut } from 'iconsax-react'
|
||||||
|
|
||||||
|
interface ImageGalleryModalProps {
|
||||||
|
images: string[]
|
||||||
|
currentIndex: number
|
||||||
|
productName: string
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onIndexChange: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageGalleryModal: FC<ImageGalleryModalProps> = ({
|
||||||
|
images,
|
||||||
|
currentIndex,
|
||||||
|
productName,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onIndexChange,
|
||||||
|
}) => {
|
||||||
|
const [isZoomed, setIsZoomed] = useState(false)
|
||||||
|
const [position, setPosition] = useState({ x: 50, y: 50 })
|
||||||
|
const imageContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = 'unset'
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = 'unset'
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!isOpen) return
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (isZoomed) {
|
||||||
|
setIsZoomed(false)
|
||||||
|
} else {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowLeft' && !isZoomed) {
|
||||||
|
handleNext()
|
||||||
|
} else if (e.key === 'ArrowRight' && !isZoomed) {
|
||||||
|
handlePrev()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isOpen, currentIndex, isZoomed])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsZoomed(false)
|
||||||
|
}, [currentIndex])
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
const newIndex = currentIndex === 0 ? images.length - 1 : currentIndex - 1
|
||||||
|
onIndexChange(newIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
const newIndex = currentIndex === images.length - 1 ? 0 : currentIndex + 1
|
||||||
|
onIndexChange(newIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleZoom = () => {
|
||||||
|
setIsZoomed(!isZoomed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isZoomed || !imageContainerRef.current) return
|
||||||
|
|
||||||
|
const rect = imageContainerRef.current.getBoundingClientRect()
|
||||||
|
const x = ((e.clientX - rect.left) / rect.width) * 100
|
||||||
|
const y = ((e.clientY - rect.top) / rect.height) * 100
|
||||||
|
|
||||||
|
setPosition({ x: Math.max(0, Math.min(100, x)), y: Math.max(0, Math.min(100, y)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='fixed top-0 z-[99999] inset-0 bg-black/80 flex flex-col'
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className='flex justify-between items-center p-4 sm:p-6'>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className='text-white hover:text-gray-300 transition-colors'
|
||||||
|
aria-label='بستن'
|
||||||
|
>
|
||||||
|
<CloseCircle color='white' size={32} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleZoom}
|
||||||
|
className='text-white hover:text-gray-300 transition-colors hidden sm:block'
|
||||||
|
aria-label={isZoomed ? 'خروج از حالت زوم' : 'بزرگنمایی'}
|
||||||
|
>
|
||||||
|
{isZoomed ? (
|
||||||
|
<SearchZoomOut color='white' size={28} />
|
||||||
|
) : (
|
||||||
|
<SearchZoomIn color='white' size={28} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className='text-white text-sm sm:text-base'>
|
||||||
|
{currentIndex + 1} / {images.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Image */}
|
||||||
|
<div className='flex-1 flex items-center justify-center px-4 sm:px-16 relative'>
|
||||||
|
<div
|
||||||
|
ref={imageContainerRef}
|
||||||
|
className={`relative w-full h-full max-w-4xl max-h-[60vh] sm:max-h-[70vh] overflow-hidden ${isZoomed ? 'cursor-zoom-out' : 'cursor-zoom-in'
|
||||||
|
}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
toggleZoom()
|
||||||
|
}}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='w-full h-full transition-transform duration-200 ease-out'
|
||||||
|
style={{
|
||||||
|
transform: isZoomed ? 'scale(2.5)' : 'scale(1)',
|
||||||
|
transformOrigin: `${position.x}% ${position.y}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={images[currentIndex]}
|
||||||
|
alt={`${productName} - تصویر ${currentIndex + 1}`}
|
||||||
|
fill
|
||||||
|
className='object-contain'
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Buttons */}
|
||||||
|
{images.length > 1 && !isZoomed && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handlePrev()
|
||||||
|
}}
|
||||||
|
className='absolute left-4 sm:left-8 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/20 text-white rounded-full p-2 sm:p-3 transition-all backdrop-blur-sm'
|
||||||
|
aria-label='تصویر قبلی'
|
||||||
|
>
|
||||||
|
<ArrowLeft2 color='white' size={24} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleNext()
|
||||||
|
}}
|
||||||
|
className='absolute right-4 sm:right-8 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/20 text-white rounded-full p-2 sm:p-3 transition-all backdrop-blur-sm'
|
||||||
|
aria-label='تصویر بعدی'
|
||||||
|
>
|
||||||
|
<ArrowRight2 color='white' size={24} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* راهنما */}
|
||||||
|
{isZoomed && (
|
||||||
|
<div className='text-center text-white/80 text-xs sm:text-sm pb-2'>
|
||||||
|
برای خروج از حالت زوم ESC یا دوباره کلیک کنید
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Thumbnails */}
|
||||||
|
<div
|
||||||
|
className='p-4 sm:p-6 flex gap-2 sm:gap-3 justify-center overflow-x-auto'
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{images.map((image, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`size-14 sm:size-16 md:size-20 shrink-0 rounded-lg overflow-hidden cursor-pointer transition-all border-2 ${currentIndex === index
|
||||||
|
? 'border-primary ring-2 ring-primary/50'
|
||||||
|
: 'border-white/30 hover:border-white/60'
|
||||||
|
}`}
|
||||||
|
onClick={() => onIndexChange(index)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
width={80}
|
||||||
|
height={80}
|
||||||
|
alt={`${productName} - تصویر ${index + 1}`}
|
||||||
|
className='w-full h-full object-contain bg-white/5'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImageGalleryModal
|
||||||
@@ -4,6 +4,7 @@ import { FC, useState } from 'react'
|
|||||||
import ActionProduct from './ActionProduct'
|
import ActionProduct from './ActionProduct'
|
||||||
import { Product } from '@/types/product.types'
|
import { Product } from '@/types/product.types'
|
||||||
import ModalReport from './ModalReport'
|
import ModalReport from './ModalReport'
|
||||||
|
import ImageGalleryModal from './ImageGalleryModal'
|
||||||
|
|
||||||
interface ProductImageProps {
|
interface ProductImageProps {
|
||||||
product: Product
|
product: Product
|
||||||
@@ -11,19 +12,23 @@ interface ProductImageProps {
|
|||||||
|
|
||||||
const ProductImage: FC<ProductImageProps> = ({ product }) => {
|
const ProductImage: FC<ProductImageProps> = ({ product }) => {
|
||||||
const [selectedImageIndex, setSelectedImageIndex] = useState(0)
|
const [selectedImageIndex, setSelectedImageIndex] = useState(0)
|
||||||
|
const [isGalleryOpen, setIsGalleryOpen] = useState(false)
|
||||||
|
|
||||||
const allImages = [product.imagesUrl.cover, ...product.imagesUrl.list]
|
const allImages = [product.imagesUrl.cover, ...product.imagesUrl.list]
|
||||||
const selectedImage = allImages[selectedImageIndex]
|
const selectedImage = allImages[selectedImageIndex]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='w-full max-w-[380px] mx-auto lg:mx-0'>
|
<div className='w-full max-w-[380px] mx-auto lg:mx-0'>
|
||||||
<div className='w-full border border-border rounded-xl h-[300px] sm:h-[350px] md:h-[400px] flex justify-center items-center relative'>
|
<div
|
||||||
|
className='w-full border border-border rounded-xl h-[300px] sm:h-[350px] md:h-[400px] flex justify-center items-center relative cursor-pointer group'
|
||||||
|
onClick={() => setIsGalleryOpen(true)}
|
||||||
|
>
|
||||||
<Image
|
<Image
|
||||||
src={selectedImage}
|
src={selectedImage}
|
||||||
width={300}
|
width={300}
|
||||||
height={300}
|
height={300}
|
||||||
alt={product.title_fa}
|
alt={product.title_fa}
|
||||||
className='max-w-[200px] max-h-[200px] sm:max-w-[240px] sm:max-h-[240px] object-contain'
|
className='max-w-[200px] max-h-[200px] sm:max-w-[240px] sm:max-h-[240px] object-contain transition-transform group-hover:scale-105'
|
||||||
/>
|
/>
|
||||||
<ActionProduct product={product} />
|
<ActionProduct product={product} />
|
||||||
</div>
|
</div>
|
||||||
@@ -48,6 +53,15 @@ const ProductImage: FC<ProductImageProps> = ({ product }) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<ModalReport productId={product._id} productName={product.title_fa} />
|
<ModalReport productId={product._id} productName={product.title_fa} />
|
||||||
|
|
||||||
|
<ImageGalleryModal
|
||||||
|
images={allImages}
|
||||||
|
currentIndex={selectedImageIndex}
|
||||||
|
productName={product.title_fa}
|
||||||
|
isOpen={isGalleryOpen}
|
||||||
|
onClose={() => setIsGalleryOpen(false)}
|
||||||
|
onIndexChange={setSelectedImageIndex}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user