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:
parent
bff158cb74
commit
3aec5bbdb3
8 changed files with 1100 additions and 384 deletions
241
src/components/ui/ImageLightbox.vue
Normal file
241
src/components/ui/ImageLightbox.vue
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
<template>
|
||||||
|
<!-- Simple lightbox overlay - always teleported to body -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="lightbox.isOpen.value"
|
||||||
|
class="image-lightbox-overlay fixed inset-0 bg-background/90 backdrop-blur-sm z-[9999] flex items-center justify-center"
|
||||||
|
@click="lightbox.close"
|
||||||
|
>
|
||||||
|
<!-- Lightbox container -->
|
||||||
|
<div
|
||||||
|
class="image-lightbox-container relative max-w-[95vw] max-h-[95vh] bg-transparent rounded-lg overflow-hidden"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<!-- Main image display -->
|
||||||
|
<div class="image-lightbox-content relative">
|
||||||
|
<ProgressiveImage
|
||||||
|
v-if="lightbox.currentImage.value"
|
||||||
|
:src="lightbox.currentImage.value.src"
|
||||||
|
:alt="lightbox.currentImage.value.alt || 'Lightbox image'"
|
||||||
|
container-class="flex items-center justify-center"
|
||||||
|
image-class="max-w-full max-h-[95vh] object-contain"
|
||||||
|
:blur-radius="8"
|
||||||
|
:transition-duration="400"
|
||||||
|
:show-loading-indicator="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Close button -->
|
||||||
|
<Button
|
||||||
|
@click.stop="lightbox.close"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="absolute top-4 right-4 bg-background/80 backdrop-blur hover:bg-background/90 text-foreground shadow-lg border border-border/50"
|
||||||
|
aria-label="Close lightbox"
|
||||||
|
>
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Navigation buttons -->
|
||||||
|
<template v-if="lightbox.hasPrevious.value">
|
||||||
|
<Button
|
||||||
|
@click.stop="lightbox.goToPrevious"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="absolute left-4 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur hover:bg-background/90 text-foreground shadow-lg border border-border/50"
|
||||||
|
aria-label="Previous image"
|
||||||
|
>
|
||||||
|
<ChevronLeft class="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="lightbox.hasNext.value">
|
||||||
|
<Button
|
||||||
|
@click.stop="lightbox.goToNext"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="absolute right-4 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur hover:bg-background/90 text-foreground shadow-lg border border-border/50"
|
||||||
|
aria-label="Next image"
|
||||||
|
>
|
||||||
|
<ChevronRight class="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Image counter -->
|
||||||
|
<div
|
||||||
|
v-if="lightbox.totalImages.value > 1"
|
||||||
|
class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-background/80 backdrop-blur rounded-lg px-3 py-1.5 text-sm font-medium border border-border/50 shadow-lg"
|
||||||
|
>
|
||||||
|
{{ lightbox.currentIndex.value + 1 }} / {{ lightbox.totalImages.value }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Keyboard navigation hint (visible for a few seconds) -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-opacity duration-300"
|
||||||
|
leave-active-class="transition-opacity duration-300"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showKeyboardHint"
|
||||||
|
class="absolute top-4 left-1/2 -translate-x-1/2 bg-background/90 backdrop-blur rounded-lg px-4 py-2 text-sm text-muted-foreground border border-border/50 shadow-lg"
|
||||||
|
>
|
||||||
|
Use ← → arrow keys or swipe to navigate • ESC to close
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
import { X, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import ProgressiveImage from './ProgressiveImage.vue'
|
||||||
|
import { useImageLightbox, type LightboxImage, type UseImageLightboxOptions } from '@/composables/useImageLightbox'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Array of images to display in the lightbox
|
||||||
|
*/
|
||||||
|
images: LightboxImage[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightbox configuration options
|
||||||
|
*/
|
||||||
|
options?: UseImageLightboxOptions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show the keyboard navigation hint
|
||||||
|
*/
|
||||||
|
showKeyboardHint?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duration to show keyboard hint in milliseconds
|
||||||
|
*/
|
||||||
|
keyboardHintDuration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
options: () => ({}),
|
||||||
|
showKeyboardHint: true,
|
||||||
|
keyboardHintDuration: 3000
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
open: [index: number]
|
||||||
|
close: []
|
||||||
|
navigate: [index: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Initialize lightbox composable
|
||||||
|
const lightbox = useImageLightbox(props.images, props.options)
|
||||||
|
|
||||||
|
// Keyboard hint visibility
|
||||||
|
const showKeyboardHint = ref(false)
|
||||||
|
let keyboardHintTimeout: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
// Watch for lightbox open/close events
|
||||||
|
watch(lightbox.isOpen, (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
emit('open', lightbox.currentIndex.value)
|
||||||
|
|
||||||
|
// Show keyboard hint
|
||||||
|
if (props.showKeyboardHint) {
|
||||||
|
showKeyboardHint.value = true
|
||||||
|
|
||||||
|
if (keyboardHintTimeout) {
|
||||||
|
clearTimeout(keyboardHintTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyboardHintTimeout = setTimeout(() => {
|
||||||
|
showKeyboardHint.value = false
|
||||||
|
}, props.keyboardHintDuration)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emit('close')
|
||||||
|
showKeyboardHint.value = false
|
||||||
|
|
||||||
|
if (keyboardHintTimeout) {
|
||||||
|
clearTimeout(keyboardHintTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch for navigation events
|
||||||
|
watch(lightbox.currentIndex, (newIndex) => {
|
||||||
|
emit('navigate', newIndex)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cleanup timeout on unmount
|
||||||
|
onMounted(() => {
|
||||||
|
return () => {
|
||||||
|
if (keyboardHintTimeout) {
|
||||||
|
clearTimeout(keyboardHintTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Expose lightbox methods for parent components
|
||||||
|
defineExpose({
|
||||||
|
open: lightbox.open,
|
||||||
|
close: lightbox.close,
|
||||||
|
goToPrevious: lightbox.goToPrevious,
|
||||||
|
goToNext: lightbox.goToNext,
|
||||||
|
goToIndex: lightbox.goToIndex,
|
||||||
|
isOpen: lightbox.isOpen,
|
||||||
|
currentIndex: lightbox.currentIndex,
|
||||||
|
currentImage: lightbox.currentImage
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Ensure lightbox appears above all other content */
|
||||||
|
.image-lightbox-overlay {
|
||||||
|
/* Using high z-index to ensure proper stacking */
|
||||||
|
z-index: 9999;
|
||||||
|
|
||||||
|
/* Smooth backdrop animation */
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-lightbox-container {
|
||||||
|
/* Smooth container animation */
|
||||||
|
animation: scaleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent content from jumping when overlay appears */
|
||||||
|
.image-lightbox-overlay * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation keyframes */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accessibility: respect reduced motion preference */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.image-lightbox-overlay,
|
||||||
|
.image-lightbox-container {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
333
src/components/ui/ImageViewer.vue
Normal file
333
src/components/ui/ImageViewer.vue
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
<template>
|
||||||
|
<div class="image-viewer">
|
||||||
|
<!-- Primary image display with progressive loading -->
|
||||||
|
<div v-if="currentImageSrc" class="primary-image relative">
|
||||||
|
<ProgressiveImage
|
||||||
|
:src="currentImageSrc"
|
||||||
|
:alt="alt || 'Image'"
|
||||||
|
:container-class="containerClass"
|
||||||
|
:image-class="[imageClass, showLightbox ? 'cursor-pointer' : ''].join(' ')"
|
||||||
|
:blur-radius="blurRadius"
|
||||||
|
:transition-duration="transitionDuration"
|
||||||
|
:loading="loading"
|
||||||
|
:show-loading-indicator="showLoadingIndicator"
|
||||||
|
@click="handleImageClick"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Image counter badge -->
|
||||||
|
<Badge
|
||||||
|
v-if="showBadge && images.length > 1"
|
||||||
|
class="absolute top-2 right-2"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{{ currentImageIndex + 1 }} of {{ images.length }}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<!-- Image cycling controls (if multiple images) -->
|
||||||
|
<template v-if="images.length > 1 && showCycleControls">
|
||||||
|
<Button
|
||||||
|
@click="previousImage"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="absolute left-2 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur hover:bg-background/90 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
aria-label="Previous image"
|
||||||
|
>
|
||||||
|
<ChevronLeft class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
@click="nextImage"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur hover:bg-background/90 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
aria-label="Next image"
|
||||||
|
>
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fallback when no image -->
|
||||||
|
<div v-else :class="containerClass">
|
||||||
|
<div class="w-full h-full bg-gradient-to-br from-muted/50 to-muted flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<Package class="w-12 h-12 mx-auto text-muted-foreground mb-2" />
|
||||||
|
<span class="text-xs text-muted-foreground">No image available</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Thumbnail gallery -->
|
||||||
|
<div
|
||||||
|
v-if="showThumbnails && images.length > 1"
|
||||||
|
class="thumbnail-gallery flex gap-2 mt-3 overflow-x-auto"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="(imageSrc, index) in images"
|
||||||
|
:key="index"
|
||||||
|
@click="selectImage(index)"
|
||||||
|
class="thumbnail-item flex-shrink-0 rounded-md overflow-hidden border-2 transition-all"
|
||||||
|
:class="{
|
||||||
|
'border-primary': index === currentImageIndex,
|
||||||
|
'border-transparent hover:border-muted-foreground': index !== currentImageIndex
|
||||||
|
}"
|
||||||
|
:aria-label="`View image ${index + 1}`"
|
||||||
|
>
|
||||||
|
<ProgressiveImage
|
||||||
|
:src="imageSrc"
|
||||||
|
:alt="`Thumbnail ${index + 1}`"
|
||||||
|
container-class="w-16 h-16"
|
||||||
|
image-class="w-16 h-16 object-cover"
|
||||||
|
:blur-radius="4"
|
||||||
|
:transition-duration="200"
|
||||||
|
loading="lazy"
|
||||||
|
:show-loading-indicator="false"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lightbox -->
|
||||||
|
<ImageLightbox
|
||||||
|
v-if="showLightbox"
|
||||||
|
ref="lightboxRef"
|
||||||
|
:images="lightboxImages"
|
||||||
|
:options="lightboxOptions"
|
||||||
|
@open="handleLightboxOpen"
|
||||||
|
@close="handleLightboxClose"
|
||||||
|
@navigate="handleLightboxNavigate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { ChevronLeft, ChevronRight, Package } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import ProgressiveImage from './ProgressiveImage.vue'
|
||||||
|
import ImageLightbox from './ImageLightbox.vue'
|
||||||
|
import type { LightboxImage, UseImageLightboxOptions } from '@/composables/useImageLightbox'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Array of image URLs to display
|
||||||
|
*/
|
||||||
|
images: string[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alt text for images
|
||||||
|
*/
|
||||||
|
alt?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS classes for the container
|
||||||
|
*/
|
||||||
|
containerClass?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS classes for the image element
|
||||||
|
*/
|
||||||
|
imageClass?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blur radius for progressive loading placeholder
|
||||||
|
*/
|
||||||
|
blurRadius?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition duration for progressive loading
|
||||||
|
*/
|
||||||
|
transitionDuration?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Image loading strategy
|
||||||
|
*/
|
||||||
|
loading?: 'lazy' | 'eager'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show loading indicator
|
||||||
|
*/
|
||||||
|
showLoadingIndicator?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show thumbnail gallery
|
||||||
|
*/
|
||||||
|
showThumbnails?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to enable lightbox functionality
|
||||||
|
*/
|
||||||
|
showLightbox?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show image counter badge
|
||||||
|
*/
|
||||||
|
showBadge?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show image cycling controls on hover
|
||||||
|
*/
|
||||||
|
showCycleControls?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial image index to display
|
||||||
|
*/
|
||||||
|
initialIndex?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightbox configuration options
|
||||||
|
*/
|
||||||
|
lightboxOptions?: UseImageLightboxOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
alt: '',
|
||||||
|
containerClass: 'w-full h-48 bg-muted/50 group',
|
||||||
|
imageClass: 'w-full h-48 object-cover',
|
||||||
|
blurRadius: 8,
|
||||||
|
transitionDuration: 400,
|
||||||
|
loading: 'lazy',
|
||||||
|
showLoadingIndicator: true,
|
||||||
|
showThumbnails: true,
|
||||||
|
showLightbox: true,
|
||||||
|
showBadge: true,
|
||||||
|
showCycleControls: true,
|
||||||
|
initialIndex: 0,
|
||||||
|
lightboxOptions: () => ({})
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
error: [error: Event]
|
||||||
|
imageChange: [index: number, src: string]
|
||||||
|
lightboxOpen: [index: number]
|
||||||
|
lightboxClose: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Component state
|
||||||
|
const currentImageIndex = ref(props.initialIndex)
|
||||||
|
const lightboxRef = ref<InstanceType<typeof ImageLightbox>>()
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const filteredImages = computed(() => {
|
||||||
|
return props.images.filter(img => img && img.trim() !== '')
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentImageSrc = computed(() => {
|
||||||
|
if (filteredImages.value.length === 0) return null
|
||||||
|
const index = Math.min(currentImageIndex.value, filteredImages.value.length - 1)
|
||||||
|
return filteredImages.value[index]
|
||||||
|
})
|
||||||
|
|
||||||
|
const lightboxImages = computed((): LightboxImage[] => {
|
||||||
|
return filteredImages.value.map((src, index) => ({
|
||||||
|
src,
|
||||||
|
alt: `${props.alt || 'Image'} ${index + 1}`
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const selectImage = (index: number) => {
|
||||||
|
if (index >= 0 && index < filteredImages.value.length) {
|
||||||
|
currentImageIndex.value = index
|
||||||
|
emit('imageChange', index, filteredImages.value[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousImage = () => {
|
||||||
|
const newIndex = currentImageIndex.value > 0
|
||||||
|
? currentImageIndex.value - 1
|
||||||
|
: filteredImages.value.length - 1
|
||||||
|
selectImage(newIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextImage = () => {
|
||||||
|
const newIndex = currentImageIndex.value < filteredImages.value.length - 1
|
||||||
|
? currentImageIndex.value + 1
|
||||||
|
: 0
|
||||||
|
selectImage(newIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageClick = () => {
|
||||||
|
if (props.showLightbox && lightboxRef.value) {
|
||||||
|
lightboxRef.value.open(currentImageIndex.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageError = (error: Event) => {
|
||||||
|
emit('error', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLightboxOpen = (index: number) => {
|
||||||
|
emit('lightboxOpen', index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLightboxClose = () => {
|
||||||
|
emit('lightboxClose')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLightboxNavigate = (index: number) => {
|
||||||
|
// Sync the main viewer with lightbox navigation
|
||||||
|
currentImageIndex.value = index
|
||||||
|
emit('imageChange', index, filteredImages.value[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for changes in images array
|
||||||
|
watch(() => props.images, () => {
|
||||||
|
// Reset to first image if current index is out of bounds
|
||||||
|
if (currentImageIndex.value >= filteredImages.value.length) {
|
||||||
|
currentImageIndex.value = 0
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Watch for initialIndex changes
|
||||||
|
watch(() => props.initialIndex, (newIndex) => {
|
||||||
|
if (newIndex !== currentImageIndex.value) {
|
||||||
|
selectImage(newIndex)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Expose methods for parent components
|
||||||
|
defineExpose({
|
||||||
|
selectImage,
|
||||||
|
openLightbox: () => lightboxRef.value?.open(currentImageIndex.value),
|
||||||
|
closeLightbox: () => lightboxRef.value?.close(),
|
||||||
|
getCurrentIndex: () => currentImageIndex.value,
|
||||||
|
getCurrentImage: () => currentImageSrc.value,
|
||||||
|
previousImage,
|
||||||
|
nextImage
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.image-viewer {
|
||||||
|
@apply w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-gallery {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--muted-foreground)) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-gallery::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-gallery::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-gallery::-webkit-scrollbar-thumb {
|
||||||
|
background-color: hsl(var(--muted-foreground));
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-gallery::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions for all interactive elements */
|
||||||
|
.thumbnail-item,
|
||||||
|
.primary-image button {
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,355 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="progressive-image-gallery">
|
|
||||||
<!-- Primary image display with progressive loading -->
|
|
||||||
<div v-if="currentImage" class="primary-image relative">
|
|
||||||
<ProgressiveImage
|
|
||||||
:src="currentImage"
|
|
||||||
:alt="alt || 'Image'"
|
|
||||||
:container-class="containerClass"
|
|
||||||
:image-class="[imageClass, showLightbox ? 'cursor-pointer' : ''].join(' ')"
|
|
||||||
:blur-radius="blurRadius"
|
|
||||||
:transition-duration="transitionDuration"
|
|
||||||
:loading="loading"
|
|
||||||
:show-loading-indicator="showLoadingIndicator"
|
|
||||||
@click="showLightbox && openLightbox()"
|
|
||||||
@error="handleImageError"
|
|
||||||
/>
|
|
||||||
<Badge
|
|
||||||
v-if="showBadge && images.length > 1"
|
|
||||||
class="absolute top-2 right-2"
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
{{ currentImageIndex + 1 }} of {{ images.length }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fallback when no image -->
|
|
||||||
<div v-else :class="containerClass">
|
|
||||||
<div class="w-full h-full bg-gradient-to-br from-muted/50 to-muted flex items-center justify-center">
|
|
||||||
<div class="text-center">
|
|
||||||
<Package class="w-12 h-12 mx-auto text-muted-foreground mb-2" />
|
|
||||||
<span class="text-xs text-muted-foreground">No image available</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Thumbnail gallery -->
|
|
||||||
<div
|
|
||||||
v-if="showThumbnails && images.length > 1"
|
|
||||||
class="thumbnail-list flex gap-2 mt-3 overflow-x-auto"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-for="(image, index) in images"
|
|
||||||
:key="index"
|
|
||||||
@click="selectImage(index)"
|
|
||||||
class="thumbnail-item flex-shrink-0 rounded-md overflow-hidden border-2 transition-all"
|
|
||||||
:class="{
|
|
||||||
'border-primary': index === currentImageIndex,
|
|
||||||
'border-transparent hover:border-muted-foreground': index !== currentImageIndex
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<ProgressiveImage
|
|
||||||
:src="image"
|
|
||||||
:alt="`Thumbnail ${index + 1}`"
|
|
||||||
container-class="w-16 h-16"
|
|
||||||
image-class="w-16 h-16 object-cover"
|
|
||||||
:blur-radius="4"
|
|
||||||
:transition-duration="200"
|
|
||||||
loading="lazy"
|
|
||||||
:show-loading-indicator="false"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Lightbox modal -->
|
|
||||||
<Dialog v-if="!isEmbedded" v-model:open="lightboxOpen">
|
|
||||||
<DialogContent class="max-w-4xl p-0">
|
|
||||||
<div class="relative">
|
|
||||||
<ProgressiveImage
|
|
||||||
v-if="lightboxImage"
|
|
||||||
:src="lightboxImage"
|
|
||||||
:alt="alt || 'Full size image'"
|
|
||||||
container-class="w-full"
|
|
||||||
image-class="w-full h-auto max-h-[90vh] object-contain"
|
|
||||||
:blur-radius="12"
|
|
||||||
:transition-duration="500"
|
|
||||||
:show-loading-indicator="true"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
@click="lightboxOpen = false"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="absolute top-2 right-2 bg-background/80 backdrop-blur hover:bg-background/90"
|
|
||||||
>
|
|
||||||
<X class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!-- Navigation buttons if multiple images -->
|
|
||||||
<template v-if="images.length > 1">
|
|
||||||
<Button
|
|
||||||
@click="previousLightboxImage"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="absolute left-2 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur hover:bg-background/90"
|
|
||||||
>
|
|
||||||
<ChevronLeft class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
@click="nextLightboxImage"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="absolute right-2 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur hover:bg-background/90"
|
|
||||||
>
|
|
||||||
<ChevronRight class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Image counter -->
|
|
||||||
<div class="absolute bottom-2 left-1/2 -translate-x-1/2 bg-background/80 backdrop-blur rounded px-2 py-1 text-sm">
|
|
||||||
{{ lightboxImageIndex + 1 }} / {{ images.length }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<!-- Embedded lightbox (when used inside another dialog) - using Teleport to escape parent containers -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<div
|
|
||||||
v-if="isEmbedded && lightboxOpen"
|
|
||||||
class="fixed inset-0 z-[9999] bg-background/80 backdrop-blur-sm flex items-center justify-center p-4"
|
|
||||||
@click.self="lightboxOpen = false"
|
|
||||||
>
|
|
||||||
<div class="relative max-w-[90vw] max-h-[90vh] bg-background rounded-lg p-0 shadow-lg">
|
|
||||||
<div class="relative">
|
|
||||||
<ProgressiveImage
|
|
||||||
v-if="lightboxImage"
|
|
||||||
:src="lightboxImage"
|
|
||||||
:alt="alt || 'Full size image'"
|
|
||||||
container-class="max-w-full max-h-[90vh]"
|
|
||||||
image-class="max-w-full max-h-[90vh] object-contain rounded-lg"
|
|
||||||
:blur-radius="12"
|
|
||||||
:transition-duration="500"
|
|
||||||
:show-loading-indicator="true"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
@click="lightboxOpen = false"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="absolute top-2 right-2 bg-background/80 backdrop-blur hover:bg-background/90"
|
|
||||||
>
|
|
||||||
<X class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!-- Navigation buttons if multiple images -->
|
|
||||||
<template v-if="images.length > 1">
|
|
||||||
<Button
|
|
||||||
@click="previousLightboxImage"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="absolute left-2 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur hover:bg-background/90"
|
|
||||||
>
|
|
||||||
<ChevronLeft class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
@click="nextLightboxImage"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="absolute right-2 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur hover:bg-background/90"
|
|
||||||
>
|
|
||||||
<ChevronRight class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Image counter -->
|
|
||||||
<div class="absolute bottom-2 left-1/2 -translate-x-1/2 bg-background/80 backdrop-blur rounded px-2 py-1 text-sm">
|
|
||||||
{{ lightboxImageIndex + 1 }} / {{ images.length }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
import { X, ChevronLeft, ChevronRight, Package } from 'lucide-vue-next'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import ProgressiveImage from './ProgressiveImage.vue'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
images: string[]
|
|
||||||
alt?: string
|
|
||||||
containerClass?: string
|
|
||||||
imageClass?: string
|
|
||||||
blurRadius?: number
|
|
||||||
transitionDuration?: number
|
|
||||||
loading?: 'lazy' | 'eager'
|
|
||||||
showLoadingIndicator?: boolean
|
|
||||||
showThumbnails?: boolean
|
|
||||||
showLightbox?: boolean
|
|
||||||
showBadge?: boolean
|
|
||||||
isEmbedded?: boolean
|
|
||||||
initialIndex?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
alt: '',
|
|
||||||
containerClass: 'w-full h-48 bg-muted/50',
|
|
||||||
imageClass: 'w-full h-48 object-cover',
|
|
||||||
blurRadius: 8,
|
|
||||||
transitionDuration: 400,
|
|
||||||
loading: 'lazy',
|
|
||||||
showLoadingIndicator: true,
|
|
||||||
showThumbnails: true,
|
|
||||||
showLightbox: true,
|
|
||||||
showBadge: true,
|
|
||||||
isEmbedded: false,
|
|
||||||
initialIndex: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
error: [error: Event]
|
|
||||||
imageChange: [index: number, src: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// Component state
|
|
||||||
const currentImageIndex = ref(props.initialIndex)
|
|
||||||
const lightboxOpen = ref(false)
|
|
||||||
const lightboxImageIndex = ref(0)
|
|
||||||
|
|
||||||
// Computed properties
|
|
||||||
const filteredImages = computed(() => {
|
|
||||||
return props.images.filter(img => img && img.trim() !== '')
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentImage = computed(() => {
|
|
||||||
if (filteredImages.value.length === 0) return null
|
|
||||||
const index = Math.min(currentImageIndex.value, filteredImages.value.length - 1)
|
|
||||||
return filteredImages.value[index]
|
|
||||||
})
|
|
||||||
|
|
||||||
const lightboxImage = computed(() => {
|
|
||||||
if (filteredImages.value.length === 0) return null
|
|
||||||
return filteredImages.value[lightboxImageIndex.value]
|
|
||||||
})
|
|
||||||
|
|
||||||
// Methods
|
|
||||||
const selectImage = (index: number) => {
|
|
||||||
if (index >= 0 && index < filteredImages.value.length) {
|
|
||||||
currentImageIndex.value = index
|
|
||||||
emit('imageChange', index, filteredImages.value[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openLightbox = () => {
|
|
||||||
lightboxImageIndex.value = currentImageIndex.value
|
|
||||||
lightboxOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousLightboxImage = () => {
|
|
||||||
lightboxImageIndex.value = lightboxImageIndex.value > 0
|
|
||||||
? lightboxImageIndex.value - 1
|
|
||||||
: filteredImages.value.length - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextLightboxImage = () => {
|
|
||||||
lightboxImageIndex.value = lightboxImageIndex.value < filteredImages.value.length - 1
|
|
||||||
? lightboxImageIndex.value + 1
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleImageError = (error: Event) => {
|
|
||||||
emit('error', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch for changes in images array
|
|
||||||
watch(() => props.images, () => {
|
|
||||||
// Reset to first image if current index is out of bounds
|
|
||||||
if (currentImageIndex.value >= filteredImages.value.length) {
|
|
||||||
currentImageIndex.value = 0
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
// Watch for initialIndex changes
|
|
||||||
watch(() => props.initialIndex, (newIndex) => {
|
|
||||||
if (newIndex !== currentImageIndex.value) {
|
|
||||||
selectImage(newIndex)
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
// Keyboard navigation for lightbox
|
|
||||||
const handleKeydown = (event: KeyboardEvent) => {
|
|
||||||
if (!lightboxOpen.value) return
|
|
||||||
|
|
||||||
switch (event.key) {
|
|
||||||
case 'Escape':
|
|
||||||
lightboxOpen.value = false
|
|
||||||
break
|
|
||||||
case 'ArrowLeft':
|
|
||||||
event.preventDefault()
|
|
||||||
previousLightboxImage()
|
|
||||||
break
|
|
||||||
case 'ArrowRight':
|
|
||||||
event.preventDefault()
|
|
||||||
nextLightboxImage()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add keyboard listeners when lightbox opens
|
|
||||||
watch(lightboxOpen, (isOpen) => {
|
|
||||||
if (isOpen) {
|
|
||||||
document.addEventListener('keydown', handleKeydown)
|
|
||||||
} else {
|
|
||||||
document.removeEventListener('keydown', handleKeydown)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Cleanup keyboard listeners on unmount
|
|
||||||
import { onBeforeUnmount } from 'vue'
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
document.removeEventListener('keydown', handleKeydown)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Expose methods for parent components
|
|
||||||
defineExpose({
|
|
||||||
selectImage,
|
|
||||||
openLightbox,
|
|
||||||
getCurrentIndex: () => currentImageIndex.value,
|
|
||||||
getCurrentImage: () => currentImage.value
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.progressive-image-gallery {
|
|
||||||
@apply w-full;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail-list {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: hsl(var(--muted-foreground)) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail-list::-webkit-scrollbar {
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail-list::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail-list::-webkit-scrollbar-thumb {
|
|
||||||
background-color: hsl(var(--muted-foreground));
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail-list::-webkit-scrollbar-thumb:hover {
|
|
||||||
background-color: hsl(var(--foreground));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
188
src/composables/useImageLightbox.ts
Normal file
188
src/composables/useImageLightbox.ts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
<!-- Product Images with Lightbox -->
|
<!-- Product Images with Lightbox -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<ProgressiveImageGallery
|
<ImageViewer
|
||||||
:images="productImages"
|
:images="productImages"
|
||||||
:alt="product.name"
|
:alt="product.name"
|
||||||
container-class="aspect-square rounded-lg overflow-hidden bg-gray-100"
|
container-class="aspect-square rounded-lg overflow-hidden bg-gray-100"
|
||||||
|
|
@ -20,9 +20,12 @@
|
||||||
:show-thumbnails="productImages.length > 1"
|
:show-thumbnails="productImages.length > 1"
|
||||||
:show-lightbox="true"
|
:show-lightbox="true"
|
||||||
:show-badge="productImages.length > 1"
|
:show-badge="productImages.length > 1"
|
||||||
|
:show-cycle-controls="productImages.length > 1"
|
||||||
:is-embedded="true"
|
:is-embedded="true"
|
||||||
@error="handleImageError"
|
@error="handleImageError"
|
||||||
@image-change="handleImageChange"
|
@image-change="handleImageChange"
|
||||||
|
@lightbox-open="handleLightboxOpen"
|
||||||
|
@lightbox-close="handleLightboxClose"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -145,7 +148,7 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import ProgressiveImageGallery from '@/components/ui/ProgressiveImageGallery.vue'
|
import ImageViewer from '@/components/ui/ImageViewer.vue'
|
||||||
import { ShoppingCart, Store, Plus, Minus, X } from 'lucide-vue-next'
|
import { ShoppingCart, Store, Plus, Minus, X } from 'lucide-vue-next'
|
||||||
import { useToast } from '@/core/composables/useToast'
|
import { useToast } from '@/core/composables/useToast'
|
||||||
import type { Product } from '../types/market'
|
import type { Product } from '../types/market'
|
||||||
|
|
@ -220,6 +223,16 @@ const handleImageChange = (index: number, src: string) => {
|
||||||
console.log('Image changed to index:', index, 'src:', src)
|
console.log('Image changed to index:', index, 'src:', src)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLightboxOpen = (index: number) => {
|
||||||
|
// Optional: Handle lightbox open events if needed
|
||||||
|
console.log('Lightbox opened at index:', index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLightboxClose = () => {
|
||||||
|
// Optional: Handle lightbox close events if needed
|
||||||
|
console.log('Lightbox closed')
|
||||||
|
}
|
||||||
|
|
||||||
// Reset state when dialog opens/closes
|
// Reset state when dialog opens/closes
|
||||||
watch(() => props.isOpen, (newVal) => {
|
watch(() => props.isOpen, (newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
|
|
|
||||||
|
|
@ -27,23 +27,15 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Product Detail Dialog - Now managed internally -->
|
|
||||||
<ProductDetailDialog
|
|
||||||
v-if="selectedProduct"
|
|
||||||
:product="selectedProduct"
|
|
||||||
:isOpen="showProductDetail"
|
|
||||||
@close="closeProductDetail"
|
|
||||||
@add-to-cart="handleDialogAddToCart"
|
|
||||||
/>
|
|
||||||
</LoadingErrorState>
|
</LoadingErrorState>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { Package as EmptyIcon } from 'lucide-vue-next'
|
import { Package as EmptyIcon } from 'lucide-vue-next'
|
||||||
import ProductCard from './ProductCard.vue'
|
import ProductCard from './ProductCard.vue'
|
||||||
import ProductDetailDialog from './ProductDetailDialog.vue'
|
|
||||||
import LoadingErrorState from './LoadingErrorState.vue'
|
import LoadingErrorState from './LoadingErrorState.vue'
|
||||||
import type { Product } from '../types/market'
|
import type { Product } from '../types/market'
|
||||||
|
|
||||||
|
|
@ -99,29 +91,15 @@ const gridClasses = computed(() => {
|
||||||
return classes.join(' ')
|
return classes.join(' ')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Internal state for product detail dialog
|
const router = useRouter()
|
||||||
const showProductDetail = ref(false)
|
|
||||||
const selectedProduct = ref<Product | null>(null)
|
|
||||||
|
|
||||||
// Handle view details internally
|
// Handle view details by navigating to product page
|
||||||
const handleViewDetails = (product: Product) => {
|
const handleViewDetails = (product: Product) => {
|
||||||
selectedProduct.value = product
|
router.push(`/market/product/${product.id}`)
|
||||||
showProductDetail.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeProductDetail = () => {
|
|
||||||
showProductDetail.value = false
|
|
||||||
selectedProduct.value = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle add to cart from product card (quick add, quantity 1)
|
// Handle add to cart from product card (quick add, quantity 1)
|
||||||
const handleAddToCart = (product: Product) => {
|
const handleAddToCart = (product: Product) => {
|
||||||
emit('add-to-cart', product, 1)
|
emit('add-to-cart', product, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle add to cart from dialog (with custom quantity)
|
|
||||||
const handleDialogAddToCart = (product: Product, quantity: number) => {
|
|
||||||
emit('add-to-cart', product, quantity)
|
|
||||||
closeProductDetail()
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -154,6 +154,15 @@ export const marketModule: ModulePlugin = {
|
||||||
title: 'Stall',
|
title: 'Stall',
|
||||||
requiresAuth: false
|
requiresAuth: false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/market/product/:productId',
|
||||||
|
name: 'product-detail',
|
||||||
|
component: () => import('./views/ProductDetailPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Product Details',
|
||||||
|
requiresAuth: false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
] as RouteRecordRaw[],
|
] as RouteRecordRaw[],
|
||||||
|
|
||||||
|
|
|
||||||
309
src/modules/market/views/ProductDetailPage.vue
Normal file
309
src/modules/market/views/ProductDetailPage.vue
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-8 max-w-6xl">
|
||||||
|
<!-- Back Navigation -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<Button
|
||||||
|
@click="goBack"
|
||||||
|
variant="outline"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="w-4 h-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="isLoading" class="flex justify-center items-center min-h-[400px]">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
||||||
|
<p class="text-muted-foreground">Loading product details...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div v-else-if="error" class="flex justify-center items-center min-h-[400px]">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-destructive mb-4">{{ error }}</p>
|
||||||
|
<Button @click="loadProduct" variant="outline">
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product Content -->
|
||||||
|
<div v-else-if="product" class="grid gap-8 md:grid-cols-2">
|
||||||
|
<!-- Product Images with Lightbox -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<ImageViewer
|
||||||
|
:images="productImages"
|
||||||
|
:alt="product.name"
|
||||||
|
container-class="aspect-square rounded-lg overflow-hidden bg-gray-100"
|
||||||
|
image-class="w-full h-full object-cover"
|
||||||
|
:blur-radius="12"
|
||||||
|
:transition-duration="500"
|
||||||
|
:show-thumbnails="productImages.length > 1"
|
||||||
|
:show-lightbox="true"
|
||||||
|
:show-badge="productImages.length > 1"
|
||||||
|
:show-cycle-controls="productImages.length > 1"
|
||||||
|
@error="handleImageError"
|
||||||
|
@image-change="handleImageChange"
|
||||||
|
@lightbox-open="handleLightboxOpen"
|
||||||
|
@lightbox-close="handleLightboxClose"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product Details -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Title and Price -->
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-bold mb-4">{{ product.name }}</h1>
|
||||||
|
<div class="flex items-baseline gap-4">
|
||||||
|
<span class="text-4xl font-bold text-green-600">
|
||||||
|
{{ formatPrice(product.price, product.currency) }}
|
||||||
|
</span>
|
||||||
|
<Badge v-if="product.quantity < 1" variant="destructive">
|
||||||
|
Out of Stock
|
||||||
|
</Badge>
|
||||||
|
<Badge v-else-if="product.quantity <= 5" variant="outline">
|
||||||
|
Only {{ product.quantity }} left
|
||||||
|
</Badge>
|
||||||
|
<Badge v-else variant="secondary">
|
||||||
|
In Stock
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stall Info -->
|
||||||
|
<div class="flex items-center gap-2 pb-4 border-b">
|
||||||
|
<Store class="w-4 h-4 text-gray-500" />
|
||||||
|
<span class="text-sm text-gray-600">Sold by</span>
|
||||||
|
<span class="font-medium">{{ product.stallName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div v-if="product.description">
|
||||||
|
<h3 class="font-semibold mb-2">Description</h3>
|
||||||
|
<p class="text-gray-600 whitespace-pre-wrap">{{ product.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Categories -->
|
||||||
|
<div v-if="product.categories && product.categories.length > 0">
|
||||||
|
<h3 class="font-semibold mb-2">Categories</h3>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
v-for="category in product.categories"
|
||||||
|
:key="category"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{{ category }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add to Cart Section -->
|
||||||
|
<div class="space-y-4 pt-6 border-t">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<Label for="quantity" class="text-sm font-medium">
|
||||||
|
Quantity:
|
||||||
|
</Label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
@click="decrementQuantity"
|
||||||
|
:disabled="quantity <= 1"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
class="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<Minus class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
id="quantity"
|
||||||
|
v-model.number="quantity"
|
||||||
|
type="number"
|
||||||
|
:min="1"
|
||||||
|
:max="product.quantity || 999"
|
||||||
|
class="w-16 h-8 text-center"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
@click="incrementQuantity"
|
||||||
|
:disabled="quantity >= (product.quantity || 999)"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
class="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<Plus class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<Button
|
||||||
|
@click="handleAddToCart"
|
||||||
|
:disabled="product.quantity < 1"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<ShoppingCart class="w-4 h-4 mr-2" />
|
||||||
|
Add to Cart
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
@click="goBack"
|
||||||
|
variant="outline"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
Continue Shopping
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import ImageViewer from '@/components/ui/ImageViewer.vue'
|
||||||
|
import { ShoppingCart, Store, Plus, Minus, ArrowLeft } from 'lucide-vue-next'
|
||||||
|
import { useToast } from '@/core/composables/useToast'
|
||||||
|
import type { Product } from '../types/market'
|
||||||
|
import { useMarketStore } from '../stores/market'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
// Store
|
||||||
|
const marketStore = useMarketStore()
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const product = ref<Product | null>(null)
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const quantity = ref(1)
|
||||||
|
const imageLoadError = ref(false)
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const productImages = computed(() => {
|
||||||
|
if (!product.value?.images || product.value.images.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return product.value.images.filter(img => img && img.trim() !== '')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const formatPrice = (price: number, currency: string) => {
|
||||||
|
if (currency === 'sat' || currency === 'sats') {
|
||||||
|
return `${price.toLocaleString('en-US')} sats`
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency.toUpperCase()
|
||||||
|
}).format(price)
|
||||||
|
}
|
||||||
|
|
||||||
|
const incrementQuantity = () => {
|
||||||
|
const max = product.value?.quantity || 999
|
||||||
|
if (quantity.value < max) {
|
||||||
|
quantity.value++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const decrementQuantity = () => {
|
||||||
|
if (quantity.value > 1) {
|
||||||
|
quantity.value--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddToCart = () => {
|
||||||
|
if (!product.value) return
|
||||||
|
|
||||||
|
// Add to stall cart using market store
|
||||||
|
marketStore.addToStallCart(product.value, quantity.value)
|
||||||
|
toast.success(`Added ${quantity.value} ${product.value.name} to cart`)
|
||||||
|
|
||||||
|
// Optionally navigate to cart or stay on page
|
||||||
|
// router.push('/cart')
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
// Navigate back to previous page or to market if no history
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
router.back()
|
||||||
|
} else {
|
||||||
|
router.push('/market')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadProduct = async () => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
const productId = route.params.productId as string
|
||||||
|
|
||||||
|
if (!productId) {
|
||||||
|
throw new Error('Product ID is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find product in the market store
|
||||||
|
const productData = marketStore.products.find(p => p.id === productId)
|
||||||
|
|
||||||
|
if (!productData) {
|
||||||
|
throw new Error('Product not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
product.value = productData
|
||||||
|
|
||||||
|
// Update page title
|
||||||
|
document.title = `${productData.name} - Product Details`
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load product:', err)
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to load product'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageError = (error: Event) => {
|
||||||
|
imageLoadError.value = true
|
||||||
|
console.warn('Image failed to load:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageChange = (index: number, src: string) => {
|
||||||
|
// Optional: Handle image change events if needed
|
||||||
|
console.log('Image changed to index:', index, 'src:', src)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLightboxOpen = (index: number) => {
|
||||||
|
// Optional: Handle lightbox open events if needed
|
||||||
|
console.log('Lightbox opened at index:', index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLightboxClose = () => {
|
||||||
|
// Optional: Handle lightbox close events if needed
|
||||||
|
console.log('Lightbox closed')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load product on mount
|
||||||
|
onMounted(() => {
|
||||||
|
loadProduct()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Hide number input spinner buttons */
|
||||||
|
input[type="number"]::-webkit-inner-spin-button,
|
||||||
|
input[type="number"]::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue