webapp/src/components/ui/ImageLightbox.vue
padreug 3aec5bbdb3 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.
2025-09-28 12:39:41 +02:00

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>