- 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.
238 lines
No EOL
7.6 KiB
Vue
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> |