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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue