feat: introduce ImageLightbox and ImageViewer components for enhanced image handling

- Added ImageLightbox component to provide a modal view for images with navigation and keyboard support.
- Implemented ImageViewer component to display images with features like thumbnails, cycling controls, and lightbox integration.
- Updated ProgressiveImage component for improved loading and error handling.
- Refactored image imports in ProductCard, ProductDetailPage, and CheckoutPage to align with new component structure.

These changes significantly enhance the user experience for viewing and interacting with product images across the application.
This commit is contained in:
padreug 2025-09-28 12:48:02 +02:00
parent 3aec5bbdb3
commit ca0ac2b9ad
9 changed files with 15 additions and 266 deletions

View file

@ -1,188 +0,0 @@
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)
}