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:
parent
f7405bc26e
commit
bff158cb74
5 changed files with 561 additions and 93 deletions
355
src/components/ui/ProgressiveImageGallery.vue
Normal file
355
src/components/ui/ProgressiveImageGallery.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue