- 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.
241 lines
No EOL
6.6 KiB
Vue
241 lines
No EOL
6.6 KiB
Vue
<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> |