feat: add ProductDetailPage introduce ImageViewer and ImageLightbox components for enhanced image display

- ProductDetailPage is being used in lieu of a modal becaues Lightbox
image gallery (modal) being embedded in another modal was causing too
much buggy behavior
- Added ImageViewer component to manage and display product images with
features like lightbox, thumbnails, and image cycling controls.
- Replaced ProgressiveImageGallery with ImageViewer in
ProductDetailDialog and ProductDetailPage for improved user experience
and maintainability.
- Implemented useImageLightbox composable to handle lightbox
functionality, including keyboard navigation and swipe gestures.
- Updated routing to include a dedicated product detail page for better
navigation and user flow.

These changes significantly enhance the image viewing experience in the
product detail context, providing a more dynamic and user-friendly
interface.
This commit is contained in:
padreug 2025-09-28 12:22:39 +02:00
parent bff158cb74
commit 3aec5bbdb3
8 changed files with 1100 additions and 384 deletions

View file

@ -0,0 +1,188 @@
import { ref, computed, watch, onBeforeUnmount } from 'vue'
export interface LightboxImage {
src: string
alt?: string
}
export interface UseImageLightboxOptions {
/**
* Whether to enable keyboard navigation (arrow keys, escape)
*/
enableKeyboardNavigation?: boolean
/**
* Whether to close lightbox when clicking backdrop
*/
closeOnBackdropClick?: boolean
/**
* Whether to enable swipe gestures on touch devices
*/
enableSwipeGestures?: boolean
}
export function useImageLightbox(
images: LightboxImage[],
options: UseImageLightboxOptions = {}
) {
const {
enableKeyboardNavigation = true,
closeOnBackdropClick = true,
enableSwipeGestures = true
} = options
// Core reactive state
const isOpen = ref(false)
const currentIndex = ref(0)
// Computed properties
const currentImage = computed(() => {
if (!images.length || currentIndex.value < 0) return null
return images[Math.min(currentIndex.value, images.length - 1)]
})
const hasPrevious = computed(() => images.length > 1)
const hasNext = computed(() => images.length > 1)
const totalImages = computed(() => images.length)
// Navigation methods
const open = (index: number = 0) => {
if (images.length === 0) return
currentIndex.value = Math.max(0, Math.min(index, images.length - 1))
isOpen.value = true
// Prevent body scroll when lightbox is open
document.body.style.overflow = 'hidden'
}
const close = () => {
isOpen.value = false
// Restore body scroll
document.body.style.overflow = ''
}
const goToPrevious = () => {
if (!hasPrevious.value) return
currentIndex.value = currentIndex.value > 0
? currentIndex.value - 1
: images.length - 1
}
const goToNext = () => {
if (!hasNext.value) return
currentIndex.value = currentIndex.value < images.length - 1
? currentIndex.value + 1
: 0
}
const goToIndex = (index: number) => {
if (index < 0 || index >= images.length) return
currentIndex.value = index
}
// Keyboard navigation
const handleKeydown = (event: KeyboardEvent) => {
if (!isOpen.value || !enableKeyboardNavigation) return
switch (event.key) {
case 'Escape':
event.preventDefault()
close()
break
case 'ArrowLeft':
event.preventDefault()
goToPrevious()
break
case 'ArrowRight':
event.preventDefault()
goToNext()
break
case ' ': // Spacebar
event.preventDefault()
goToNext()
break
}
}
// Touch/swipe gesture handling
let touchStartX = 0
let touchStartY = 0
const swipeThreshold = 50
const handleTouchStart = (event: TouchEvent) => {
if (!enableSwipeGestures || !isOpen.value) return
touchStartX = event.touches[0].clientX
touchStartY = event.touches[0].clientY
}
const handleTouchEnd = (event: TouchEvent) => {
if (!enableSwipeGestures || !isOpen.value) return
const touchEndX = event.changedTouches[0].clientX
const touchEndY = event.changedTouches[0].clientY
const deltaX = touchEndX - touchStartX
const deltaY = touchEndY - touchStartY
// Only process horizontal swipes (ignore mostly vertical swipes)
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > swipeThreshold) {
if (deltaX > 0) {
goToPrevious()
} else {
goToNext()
}
}
}
// Setup event listeners
watch(isOpen, (newIsOpen) => {
if (newIsOpen) {
document.addEventListener('keydown', handleKeydown)
document.addEventListener('touchstart', handleTouchStart, { passive: true })
document.addEventListener('touchend', handleTouchEnd, { passive: true })
} else {
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('touchstart', handleTouchStart)
document.removeEventListener('touchend', handleTouchEnd)
}
})
// Cleanup on unmount
onBeforeUnmount(() => {
close()
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('touchstart', handleTouchStart)
document.removeEventListener('touchend', handleTouchEnd)
})
return {
// State
isOpen: readonly(isOpen),
currentIndex: readonly(currentIndex),
currentImage,
// Computed
hasPrevious,
hasNext,
totalImages,
// Methods
open,
close,
goToPrevious,
goToNext,
goToIndex
}
}
// Helper to create readonly refs
function readonly<T>(ref: import('vue').Ref<T>) {
return computed(() => ref.value)
}