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>

View file

@ -127,11 +127,17 @@
<FormField name="images">
<FormItem>
<FormLabel>Product Images</FormLabel>
<FormDescription>Add images to showcase your product</FormDescription>
<div class="text-center py-8 border-2 border-dashed rounded-lg">
<Package class="w-8 h-8 mx-auto mb-2 text-muted-foreground" />
<p class="text-sm text-muted-foreground">Image upload coming soon</p>
</div>
<FormDescription>Add up to 5 images to showcase your product. The first image will be the primary display image.</FormDescription>
<ImageUpload
v-model="uploadedImages"
:multiple="true"
:max-files="5"
:max-size-mb="10"
:show-primary-button="true"
:disabled="isCreating"
:allow-camera="true"
placeholder="Add product photos"
/>
<FormMessage />
</FormItem>
</FormField>
@ -218,12 +224,13 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Package } from 'lucide-vue-next'
import type { NostrmarketAPI, Stall, CreateProductRequest } from '../services/nostrmarketAPI'
import type { Product } from '../types/market'
import { auth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
import type { ImageUploadService } from '@/modules/base/services/ImageUploadService'
// Props and emits
interface Props {
@ -242,11 +249,13 @@ const emit = defineEmits<{
// Services
const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
const toast = useToast()
// Local state
const isCreating = ref(false)
const createError = ref<string | null>(null)
const uploadedImages = ref<any[]>([]) // Track uploaded images with their metadata
// Computed properties
const isEditMode = computed(() => !!props.product?.id)
@ -311,18 +320,31 @@ const updateProduct = async (formData: any) => {
return
}
const {
name,
description,
price,
quantity,
categories,
images,
active,
use_autoreply,
autoreply_message
const {
name,
description,
price,
quantity,
categories,
active,
use_autoreply,
autoreply_message
} = formData
// Get uploaded image URLs from the image service
const images: string[] = []
if (uploadedImages.value && uploadedImages.value.length > 0) {
for (const img of uploadedImages.value) {
if (img.alias) {
// Get the full URL for the image
const imageUrl = imageService.getImageUrl(img.alias)
if (imageUrl) {
images.push(imageUrl)
}
}
}
}
isCreating.value = true
createError.value = null
@ -382,18 +404,31 @@ const createProduct = async (formData: any) => {
return
}
const {
name,
description,
price,
quantity,
categories,
images,
active,
use_autoreply,
autoreply_message
const {
name,
description,
price,
quantity,
categories,
active,
use_autoreply,
autoreply_message
} = formData
// Get uploaded image URLs from the image service
const images: string[] = []
if (uploadedImages.value && uploadedImages.value.length > 0) {
for (const img of uploadedImages.value) {
if (img.alias) {
// Get the full URL for the image
const imageUrl = imageService.getImageUrl(img.alias)
if (imageUrl) {
images.push(imageUrl)
}
}
}
}
isCreating.value = true
createError.value = null
@ -470,6 +505,34 @@ watch(() => props.isOpen, async (isOpen) => {
// Reset form with appropriate initial values
resetForm({ values: initialValues })
// Convert existing image URLs to the format expected by ImageUpload component
if (props.product?.images && props.product.images.length > 0) {
// For existing products, we need to convert URLs back to a format ImageUpload can display
uploadedImages.value = props.product.images.map((url, index) => {
let alias = url
// If it's a full pict-rs URL, extract just the file ID
if (url.includes('/image/original/')) {
const parts = url.split('/image/original/')
if (parts.length > 1 && parts[1]) {
alias = parts[1]
}
} else if (url.startsWith('http://') || url.startsWith('https://')) {
// Keep full URLs as-is
alias = url
}
return {
alias: alias,
delete_token: '',
isPrimary: index === 0,
details: {}
}
})
} else {
uploadedImages.value = []
}
// Wait for reactivity
await nextTick()

View file

@ -1,11 +1,11 @@
<template>
<Card class="overflow-hidden hover:shadow-lg transition-shadow duration-200">
<!-- Product Image -->
<div class="relative">
<!-- Show actual image if available -->
<!-- Product Image with Cycling and Progressive Loading -->
<div class="relative group">
<!-- Show actual image with progressive loading if available -->
<ProgressiveImage
v-if="product.images?.[0]"
:src="product.images[0]"
v-if="currentImage"
:src="currentImage"
:alt="product.name"
container-class="w-full h-48 bg-muted/50"
image-class="w-full h-48 object-cover"
@ -23,7 +23,43 @@
<span class="text-xs text-muted-foreground">No image available</span>
</div>
</div>
<!-- Image Navigation Arrows (show on hover if multiple images) -->
<div v-if="hasMultipleImages" class="opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<!-- Previous Image Button -->
<Button
@click="previousImage"
type="button"
size="sm"
variant="secondary"
class="absolute left-2 top-1/2 transform -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white border-0 h-8 w-8 p-0"
>
<ChevronLeft class="w-4 h-4" />
</Button>
<!-- Next Image Button -->
<Button
@click="nextImage"
type="button"
size="sm"
variant="secondary"
class="absolute right-2 top-1/2 transform -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white border-0 h-8 w-8 p-0"
>
<ChevronRight class="w-4 h-4" />
</Button>
</div>
<!-- Image Indicators (dots) -->
<div v-if="hasMultipleImages" class="absolute bottom-2 left-1/2 transform -translate-x-1/2 flex gap-1">
<div
v-for="(_, index) in productImages"
:key="index"
@click="setCurrentImage(index)"
class="w-2 h-2 rounded-full cursor-pointer transition-colors duration-200"
:class="currentImageIndex === index ? 'bg-white' : 'bg-white/50 hover:bg-white/70'"
/>
</div>
<!-- Add to Cart Button -->
<Button
@click="addToCart"
@ -33,7 +69,7 @@
>
<ShoppingCart class="w-4 h-4" />
</Button>
<!-- Out of Stock Badge -->
<Badge
v-if="product.quantity < 1"
@ -115,12 +151,12 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed, watch } from 'vue'
import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import ProgressiveImage from '@/components/ui/ProgressiveImage.vue'
import { ShoppingCart, Package } from 'lucide-vue-next'
import { ShoppingCart, Package, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import type { Product } from '@/modules/market/stores/market'
interface Props {
@ -136,6 +172,53 @@ const emit = defineEmits<{
}>()
const imageError = ref(false)
const currentImageIndex = ref(0)
// Computed properties for image cycling
const productImages = computed(() => {
if (!props.product.images || props.product.images.length === 0) {
return []
}
return props.product.images.filter(img => img && img.trim() !== '')
})
const hasMultipleImages = computed(() => productImages.value.length > 1)
const currentImage = computed(() => {
if (productImages.value.length === 0) {
return null
}
return productImages.value[currentImageIndex.value]
})
// Image cycling methods
const nextImage = (event?: Event) => {
event?.stopPropagation()
if (productImages.value.length > 0) {
currentImageIndex.value = (currentImageIndex.value + 1) % productImages.value.length
}
}
const previousImage = (event?: Event) => {
event?.stopPropagation()
if (productImages.value.length > 0) {
currentImageIndex.value = currentImageIndex.value === 0
? productImages.value.length - 1
: currentImageIndex.value - 1
}
}
const setCurrentImage = (index: number, event?: Event) => {
event?.stopPropagation()
if (index >= 0 && index < productImages.value.length) {
currentImageIndex.value = index
}
}
// Reset image index when product changes
watch(() => props.product.id, () => {
currentImageIndex.value = 0
})
const addToCart = () => {
emit('add-to-cart', props.product)

View file

@ -8,50 +8,22 @@
</DialogClose>
<div class="grid gap-6 md:grid-cols-2">
<!-- Product Images -->
<!-- Product Images with Lightbox -->
<div class="space-y-4">
<!-- Main Image -->
<div class="aspect-square rounded-lg overflow-hidden bg-gray-100">
<ProgressiveImage
v-if="currentImage"
:src="currentImage"
:alt="product.name"
container-class="w-full h-full"
image-class="w-full h-full object-cover"
:blur-radius="12"
:transition-duration="500"
loading="lazy"
:show-loading-indicator="true"
@error="handleImageError"
/>
<div v-else 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-24 h-24 mx-auto text-muted-foreground mb-4" />
<span class="text-sm text-muted-foreground">No image available</span>
</div>
</div>
</div>
<!-- Image Thumbnails -->
<div v-if="productImages.length > 1" class="flex gap-2 overflow-x-auto">
<button
v-for="(image, index) in productImages"
:key="index"
@click="currentImageIndex = index"
class="relative w-20 h-20 rounded-lg overflow-hidden border-2 transition-all"
:class="currentImageIndex === index ? 'border-primary' : 'border-gray-200 hover:border-gray-400'"
>
<ProgressiveImage
:src="image"
:alt="`${product.name} - Image ${index + 1}`"
container-class="w-full h-full"
image-class="w-full h-full object-cover"
:blur-radius="6"
:transition-duration="300"
loading="lazy"
/>
</button>
</div>
<ProgressiveImageGallery
:images="productImages"
:alt="product.name"
container-class="aspect-square rounded-lg overflow-hidden bg-gray-100"
image-class="w-full h-full object-cover"
:blur-radius="12"
:transition-duration="500"
:show-thumbnails="productImages.length > 1"
:show-lightbox="true"
:show-badge="productImages.length > 1"
:is-embedded="true"
@error="handleImageError"
@image-change="handleImageChange"
/>
</div>
<!-- Product Details -->
@ -173,8 +145,8 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import ProgressiveImage from '@/components/ui/ProgressiveImage.vue'
import { Package, ShoppingCart, Store, Plus, Minus, X } from 'lucide-vue-next'
import ProgressiveImageGallery from '@/components/ui/ProgressiveImageGallery.vue'
import { ShoppingCart, Store, Plus, Minus, X } from 'lucide-vue-next'
import { useToast } from '@/core/composables/useToast'
import type { Product } from '../types/market'
@ -194,7 +166,6 @@ const toast = useToast()
// Local state
const quantity = ref(1)
const currentImageIndex = ref(0)
const imageLoadError = ref(false)
// Computed properties
@ -205,13 +176,6 @@ const productImages = computed(() => {
return props.product.images.filter(img => img && img.trim() !== '')
})
const currentImage = computed(() => {
if (productImages.value.length === 0) {
return null
}
return productImages.value[currentImageIndex.value]
})
// Methods
const formatPrice = (price: number, currency: string) => {
if (currency === 'sat' || currency === 'sats') {
@ -246,22 +210,26 @@ const handleClose = () => {
emit('close')
}
const handleImageError = () => {
const handleImageError = (error: Event) => {
imageLoadError.value = true
console.warn('Image failed to load:', error)
}
const handleImageChange = (index: number, src: string) => {
// Optional: Handle image change events if needed
console.log('Image changed to index:', index, 'src:', src)
}
// Reset state when dialog opens/closes
watch(() => props.isOpen, (newVal) => {
if (newVal) {
quantity.value = 1
currentImageIndex.value = 0
imageLoadError.value = false
}
})
// Reset image index when product changes
// Reset image error when product changes
watch(() => props.product?.id, () => {
currentImageIndex.value = 0
imageLoadError.value = false
})
</script>

View file

@ -238,7 +238,6 @@ const addToCart = (product: Product, quantity?: number) => {
marketStore.addToStallCart(product, quantity || 1)
}
const viewStall = (stallId: string) => {
// Navigate to the stall view page
router.push(`/market/stall/${stallId}`)