feat: enhance product management with new dialog and image handling features

- Introduced ProductDetailDialog component for displaying detailed product information, including images, price, and availability.
- Implemented image cycling functionality in ProductCard for better user experience when viewing multiple product images.
- Enhanced CreateProductDialog to support image uploads with improved validation and navigation protection during form editing.
- Added logic to manage uploaded images and ensure proper handling of existing product images.
- Updated MarketPage to integrate the new ProductDetailDialog, allowing users to view product details seamlessly.

These changes significantly improve the product management experience, enhancing both the display and interaction with product images.
This commit is contained in:
padreug 2025-09-28 04:05:20 +02:00
parent f7405bc26e
commit bff158cb74
5 changed files with 561 additions and 93 deletions

View file

@ -0,0 +1,355 @@
<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>