web-app/src/modules/base/components/ImageDisplay.vue
padreug f7405bc26e feat: implement image upload functionality with new components and service
- Added ImageUpload and ImageDisplay components for handling image uploads and displaying images, respectively.
- Introduced ImageUploadService to manage image uploads, including validation, processing, and deletion.
- Updated app configuration to include image upload settings and integrated the service into the dependency injection container.
- Enhanced the .env.example file to include image upload configuration options.
- Provided a comprehensive README for the new components, detailing usage and integration examples.

These changes significantly enhance the application's capability to manage image uploads, improving user experience and flexibility in handling images.
2025-09-28 04:08:41 +02:00

238 lines
No EOL
7.6 KiB
Vue

<template>
<div class="image-display">
<!-- Primary image display -->
<div v-if="primaryImage" class="primary-image relative">
<img
:src="getImageUrl(primaryImage, props.sizing?.primaryImageOptions)"
:alt="alt || 'Image'"
:class="imageClass"
@click="showLightbox && openLightbox(primaryImage)"
:style="{ cursor: showLightbox ? 'pointer' : 'default' }"
/>
<Badge
v-if="showBadge && images.length > 1"
class="absolute top-2 right-2"
variant="secondary"
>
1 of {{ images.length }}
</Badge>
</div>
<!-- Thumbnail gallery -->
<div
v-if="showThumbnails && images.length > 1"
class="thumbnail-list flex gap-2 mt-3 overflow-x-auto"
>
<div
v-for="(image, index) in images"
:key="image.alias"
@click="selectImage(image)"
class="thumbnail-item flex-shrink-0 cursor-pointer rounded-md overflow-hidden border-2 transition-colors"
:class="{
'border-primary': image.alias === primaryImage?.alias,
'border-transparent hover:border-muted-foreground': image.alias !== primaryImage?.alias
}"
>
<img
:src="imageService.getThumbnailUrl(image.alias, thumbnailSize)"
:alt="`Thumbnail ${index + 1}`"
class="w-16 h-16 object-cover"
/>
</div>
</div>
<!-- Lightbox modal -->
<Dialog v-if="!isEmbedded" v-model:open="lightboxOpen">
<DialogContent class="max-w-4xl p-0">
<div class="relative">
<img
v-if="lightboxImage"
:src="getImageUrl(lightboxImage)"
:alt="alt || 'Full size image'"
class="w-full h-auto"
/>
<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="previousImage"
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="nextImage"
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>
</div>
</DialogContent>
</Dialog>
<!-- Embedded lightbox (when used inside another dialog) -->
<div
v-if="isEmbedded && lightboxOpen"
class="fixed inset-0 z-[60] 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">
<img
v-if="lightboxImage"
:src="getImageUrl(lightboxImage)"
:alt="alt || 'Full size image'"
class="max-w-full max-h-[90vh] object-contain rounded-lg"
/>
<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="previousImage"
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="nextImage"
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>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, defineProps } from 'vue'
import { X, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
} from '@/components/ui/dialog'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ImageUploadService, ImageUrlOptions } from '../services/ImageUploadService'
export interface DisplayImage {
alias: string
isPrimary?: boolean
}
interface ImageDisplayOptions {
showThumbnails?: boolean
showLightbox?: boolean
showBadge?: boolean
isEmbedded?: boolean
}
interface ImageSizingOptions {
thumbnailSize?: number
primaryImageOptions?: ImageUrlOptions
}
const props = defineProps<{
images: DisplayImage[]
alt?: string
imageClass?: string
options?: ImageDisplayOptions
sizing?: ImageSizingOptions
}>()
// Inject the image upload service
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
// Component state
const selectedImageAlias = ref<string | null>(null)
const lightboxOpen = ref(false)
const lightboxImage = ref<DisplayImage | null>(null)
// Computed properties
const primaryImage = computed(() => {
if (selectedImageAlias.value) {
return props.images.find(img => img.alias === selectedImageAlias.value)
}
return props.images.find(img => img.isPrimary) || props.images[0]
})
const thumbnailSize = computed(() => props.sizing?.thumbnailSize || 100)
const isEmbedded = computed(() => props.options?.isEmbedded || false)
const showThumbnails = computed(() => props.options?.showThumbnails || false)
const showLightbox = computed(() => props.options?.showLightbox || false)
const showBadge = computed(() => props.options?.showBadge || false)
// Methods
const getImageUrl = (image: DisplayImage, options?: ImageUrlOptions) => {
// Check if the alias is already a full URL
if (image.alias.startsWith('http://') || image.alias.startsWith('https://')) {
// Already a full URL, return as-is
return image.alias
}
// Otherwise, use the image service to generate the URL
const defaultOptions = options || props.sizing?.primaryImageOptions || { resize: 800 }
return imageService.getImageUrl(image.alias, defaultOptions)
}
const selectImage = (image: DisplayImage) => {
selectedImageAlias.value = image.alias
}
const openLightbox = (image: DisplayImage) => {
lightboxImage.value = image
lightboxOpen.value = true
}
const getCurrentImageIndex = () => {
if (!lightboxImage.value) return 0
return props.images.findIndex(img => img.alias === lightboxImage.value?.alias)
}
const previousImage = () => {
const currentIndex = getCurrentImageIndex()
const newIndex = currentIndex > 0 ? currentIndex - 1 : props.images.length - 1
lightboxImage.value = props.images[newIndex]
}
const nextImage = () => {
const currentIndex = getCurrentImageIndex()
const newIndex = currentIndex < props.images.length - 1 ? currentIndex + 1 : 0
lightboxImage.value = props.images[newIndex]
}
// Expose methods for parent components
defineExpose({
selectImage,
openLightbox,
getCurrentImage: () => primaryImage.value
})
</script>