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.
This commit is contained in:
padreug 2025-09-28 04:05:01 +02:00
parent 5a59f7ce89
commit f7405bc26e
8 changed files with 1336 additions and 5 deletions

View file

@ -13,6 +13,9 @@ VITE_PUSH_NOTIFICATIONS_ENABLED=true
# Support
VITE_SUPPORT_NPUB=your-support-npub
# Image Upload Configuration (pict-rs)
VITE_PICTRS_BASE_URL=https://img.mydomain.com
# Market Configuration
VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrrvc6r2qf8waehxw309akxucnfw3ejuct5d96xcctw9e5k7tmwdaehgunjv4kxz7f0v96xjmczyqrfrfkxv3m8t4elpe28x065z30zszaaqa4u0744qcmadsz3y50cjqcyqqq82scmcafla
VITE_MARKET_RELAYS=["wss://relay.damus.io","wss://relay.snort.social","wss://nostr-pub.wellorder.net"]

View file

@ -15,6 +15,11 @@ export const appConfig: AppConfig = {
},
pwa: {
autoPrompt: true
},
imageUpload: {
baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com',
maxSizeMB: 10,
acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif']
}
}
},

View file

@ -153,6 +153,9 @@ export const SERVICE_TOKENS = {
// API services
LNBITS_API: Symbol('lnbitsAPI'),
// Image upload services
IMAGE_UPLOAD_SERVICE: Symbol('imageUploadService'),
} as const
// Type-safe injection helpers

View file

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

View file

@ -0,0 +1,476 @@
<template>
<div class="image-upload">
<div
class="upload-area border-2 border-dashed rounded-lg p-6 text-center transition-colors"
:class="{
'border-primary': isDragging,
'opacity-50 cursor-not-allowed': disabled,
'cursor-pointer hover:border-primary': !disabled && images.length > 0
}"
@click="!disabled && images.length > 0 && triggerFileInput()"
@drop.prevent.stop="handleDrop"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
>
<!-- Gallery file input -->
<input
ref="galleryInput"
type="file"
@change.stop.prevent="handleFileSelect"
accept="image/*"
:multiple="multiple"
:disabled="disabled"
tabindex="-1"
hidden
/>
<!-- Camera file input - no multiple attribute for direct camera access -->
<input
ref="cameraInput"
type="file"
@change.stop.prevent="handleFileSelect"
accept="image/*"
capture="environment"
:disabled="disabled"
tabindex="-1"
hidden
/>
<div v-if="!uploading && images.length === 0" class="upload-placeholder">
<div class="flex justify-center gap-4 mb-6">
<!-- Gallery Button -->
<Button
@click.stop="triggerGalleryInput"
type="button"
variant="outline"
class="flex flex-col items-center gap-2 h-auto py-4 px-6"
:disabled="disabled"
>
<Image class="w-6 h-6" />
<span class="text-sm">Gallery</span>
</Button>
<!-- Camera Button -->
<Button
v-if="allowCamera"
@click.stop="triggerCameraInput"
type="button"
variant="outline"
class="flex flex-col items-center gap-2 h-auto py-4 px-6"
:disabled="disabled"
>
<Camera class="w-6 h-6" />
<span class="text-sm">Camera</span>
</Button>
</div>
<p class="text-lg font-medium mb-2">
{{ placeholder || 'Choose photos from gallery or take with camera' }}
</p>
<p class="text-sm text-muted-foreground">
Supports PNG, JPG, WEBP, AVIF Max {{ maxSizeMB }}MB per file
</p>
<p class="text-xs text-muted-foreground mt-2">
Or drag images here to upload
</p>
</div>
<div v-if="uploading" class="uploading flex flex-col items-center gap-3">
<Loader2 class="w-8 h-8 animate-spin" />
<p class="text-sm">Uploading {{ uploadingCount }} image{{ uploadingCount > 1 ? 's' : '' }}...</p>
</div>
</div>
<div v-if="images.length > 0" class="mt-4">
<!-- Image Grid -->
<div class="image-grid grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
<div
v-for="image in images"
:key="image.alias"
class="image-item relative rounded-lg overflow-hidden border-2"
:class="{ 'border-primary': image.isPrimary }"
>
<img
:src="getImageThumbnailUrl(image)"
:alt="image.alias"
class="w-full h-32 object-cover"
/>
<div class="image-actions absolute top-1 right-1 flex gap-1">
<Button
v-if="showPrimaryButton && images.length > 1"
@click.stop.prevent="setAsPrimary(image)"
type="button"
size="sm"
variant="secondary"
class="h-7 px-2 text-xs"
:class="{ 'bg-primary text-primary-foreground': image.isPrimary }"
>
{{ image.isPrimary ? 'Primary' : 'Set Primary' }}
</Button>
<Button
@click.stop.prevent="removeImage(image)"
type="button"
size="sm"
variant="destructive"
class="h-7 w-7 p-0"
:disabled="disabled"
>
<X class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<!-- Add More Images Buttons -->
<div v-if="!maxFiles || images.length < maxFiles" class="flex justify-center gap-3 mt-4 pt-4 border-t border-dashed">
<Button
@click.stop="triggerGalleryInput"
type="button"
variant="outline"
size="sm"
:disabled="disabled"
>
<Image class="w-4 h-4 mr-2" />
Add from Gallery
</Button>
<Button
v-if="allowCamera"
@click.stop="triggerCameraInput"
type="button"
variant="outline"
size="sm"
:disabled="disabled"
>
<Camera class="w-4 h-4 mr-2" />
Take Photo
</Button>
</div>
</div>
<Alert v-if="error" variant="destructive" class="mt-4">
<AlertCircle class="h-4 w-4" />
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
</div>
</template>
<script setup lang="ts">
import { ref, computed, defineEmits, defineProps, nextTick } from 'vue'
import { Camera, X, Loader2, AlertCircle, Image } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ImageUploadService, UploadedImage } from '../services/ImageUploadService'
interface ImageWithMetadata extends UploadedImage {
isPrimary: boolean
}
const props = defineProps<{
modelValue?: ImageWithMetadata[]
multiple?: boolean
maxFiles?: number
maxSizeMB?: number
showPrimaryButton?: boolean
disabled?: boolean
placeholder?: string
allowCamera?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [images: ImageWithMetadata[]]
}>()
// Inject the image upload service
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
// Component state
const galleryInput = ref<HTMLInputElement | null>(null)
const cameraInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false)
const uploadingCount = ref(0)
const error = ref('')
const isDragging = ref(false)
// Use v-model or internal state
const images = computed({
get: () => props.modelValue || [],
set: (value) => {
emit('update:modelValue', value)
}
})
const maxSizeMB = computed(() => props.maxSizeMB || imageService.getStatus().maxFileSizeMB)
// Note: Camera capture is now handled by separate input elements
// Helper function to get thumbnail URL that works for both uploaded images and existing URLs
const getImageThumbnailUrl = (image: ImageWithMetadata): string => {
// Now we always have file IDs as aliases, so we can always use the service
return imageService.getThumbnailUrl(image.alias, 200)
}
/**
* DEFENSIVE FORM SUBMISSION PREVENTION
*
* Mobile browsers (especially on Android) can unpredictably trigger form submissions
* when file inputs are activated, particularly with camera capture. This causes
* intermittent page refreshes that lose form data.
*
* Our multi-layer defense strategy:
* 1. Mutex lock (inputInUse) prevents simultaneous input triggers
* 2. Window-level submit event capture during critical operations
* 3. Form-level submit blocking during async operations
* 4. Vue event modifiers (.stop.prevent) on all user interactions
* 5. Explicit event prevention in handlers as backup
*
* This defensive approach is necessary because the issue is intermittent
* and may be caused by browser quirks, timing issues, or mobile-specific behaviors.
*/
// Track which input is being triggered to prevent conflicts between gallery and camera
const inputInUse = ref<'gallery' | 'camera' | null>(null)
const triggerGalleryInput = () => {
if (!props.disabled && galleryInput.value && inputInUse.value === null) {
inputInUse.value = 'gallery'
// DEFENSIVE: Add temporary form submit blocker at window level
// This catches any form submission attempts during the critical moment
// when the file input is being triggered
const submitBlocker = (e: Event) => {
e.preventDefault()
e.stopImmediatePropagation()
return false
}
window.addEventListener('submit', submitBlocker, true)
galleryInput.value.click()
// Reset after a delay and remove blocker
setTimeout(() => {
inputInUse.value = null
window.removeEventListener('submit', submitBlocker, true)
}, 500)
}
}
const triggerCameraInput = () => {
if (!props.disabled && cameraInput.value && inputInUse.value === null) {
inputInUse.value = 'camera'
// DEFENSIVE: Camera inputs are especially prone to triggering form submissions
// on mobile browsers. This window-level blocker prevents any submission
// during the critical camera activation period
const submitBlocker = (e: Event) => {
e.preventDefault()
e.stopImmediatePropagation()
return false
}
window.addEventListener('submit', submitBlocker, true)
cameraInput.value.click()
// Reset after a delay and remove blocker
setTimeout(() => {
inputInUse.value = null
window.removeEventListener('submit', submitBlocker, true)
}, 500)
}
}
// Note: Click event handling is done via Vue modifiers (@click.stop) in template
// Legacy method for drag & drop compatibility
const triggerFileInput = () => {
// Default to gallery for drag & drop
triggerGalleryInput()
}
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement
const files = Array.from(target.files || [])
// DEFENSIVE: Clear the input immediately to prevent any lingering state
// that might trigger additional events
const filesCopy = [...files]
target.value = ''
// DEFENSIVE: Add extra protection against form submission during file processing
// The console.warn helps debug intermittent issues by showing when submissions are blocked
const submitGuard = (e: Event) => {
e.preventDefault()
e.stopImmediatePropagation()
console.warn('Form submission blocked during file upload')
return false
}
// DEFENSIVE: Block form submissions at the window level during processing
// This catches submissions that might bypass other preventions
window.addEventListener('submit', submitGuard, true)
// Process files after clearing input
if (filesCopy.length > 0) {
// DEFENSIVE: Use nextTick to ensure DOM updates are processed before upload
// This helps prevent timing-related submission issues
nextTick(() => {
uploadFiles(filesCopy).finally(() => {
// Remove guard after upload completes (important for cleanup)
window.removeEventListener('submit', submitGuard, true)
})
})
} else {
window.removeEventListener('submit', submitGuard, true)
}
}
const handleDrop = (event: DragEvent) => {
isDragging.value = false
if (props.disabled) return
const files = Array.from(event.dataTransfer?.files || [])
const imageFiles = files.filter(file => file.type.startsWith('image/'))
uploadFiles(imageFiles)
}
const uploadFiles = async (files: File[]) => {
if (files.length === 0) return
// Check max files limit
if (props.maxFiles && images.value.length + files.length > props.maxFiles) {
error.value = `Maximum ${props.maxFiles} images allowed`
return
}
uploading.value = true
uploadingCount.value = files.length
error.value = ''
// DEFENSIVE: Multiple layers of form submission prevention during upload
// This is our most comprehensive protection, active during the actual upload process
const forms = document.querySelectorAll('form')
const originalHandlers: Array<{ form: HTMLFormElement; onsubmit: any }> = []
forms.forEach(form => {
// Store original handler for restoration
originalHandlers.push({ form, onsubmit: form.onsubmit })
// DEFENSIVE LAYER 1: Override onsubmit handler directly
form.onsubmit = (e) => {
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
return false // Belt and suspenders - return false as extra prevention
}
// DEFENSIVE LAYER 2: Add capturing event listener as backup
// This catches submissions even if onsubmit is somehow bypassed
form.addEventListener('submit', preventSubmit, true)
})
// Helper function for preventing submission with all available methods
function preventSubmit(e: Event) {
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
return false
}
try {
const uploadOptions = {
maxSizeMB: maxSizeMB.value
}
// Upload files with better error handling
const { results: uploadResults, errors: uploadErrors } = await imageService.uploadImages(files, uploadOptions)
// Process successful uploads
const newImages: ImageWithMetadata[] = uploadResults.map((result, index) => ({
...result,
isPrimary: images.value.length === 0 && index === 0 // First image becomes primary
}))
if (newImages.length > 0) {
images.value = [...images.value, ...newImages]
}
// Handle upload errors with user-friendly messages
if (uploadErrors.length > 0) {
if (uploadErrors.length === files.length) {
// All uploads failed
error.value = uploadErrors.length === 1
? uploadErrors[0].error
: `All ${uploadErrors.length} images failed to upload`
} else {
// Partial failure
const failedFiles = uploadErrors.map(e => e.file).join(', ')
error.value = `Some images failed to upload: ${failedFiles}`
}
}
// Clear errors on successful upload (if no upload errors occurred)
if (newImages.length > 0 && uploadErrors.length === 0) {
error.value = ''
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to upload images'
console.error('Upload error:', err)
} finally {
uploading.value = false
uploadingCount.value = 0
// DEFENSIVE CLEANUP: Restore all original form submission handlers
// Critical to restore normal form behavior after upload completes
originalHandlers.forEach(({ form, onsubmit }) => {
form.onsubmit = onsubmit
form.removeEventListener('submit', preventSubmit, true)
})
}
}
const setAsPrimary = (selectedImage: ImageWithMetadata) => {
images.value = images.value.map(img => ({
...img,
isPrimary: img.alias === selectedImage.alias
}))
}
const removeImage = async (imageToRemove: ImageWithMetadata) => {
if (props.disabled) return
try {
// Only try to delete from pict-rs if we have a delete token (newly uploaded images)
if (imageToRemove.delete_token) {
await imageService.deleteImage(imageToRemove.delete_token, imageToRemove.alias)
}
const newImages = images.value.filter(img => img.alias !== imageToRemove.alias)
// If removed image was primary, make first image primary
if (imageToRemove.isPrimary && newImages.length > 0) {
newImages[0].isPrimary = true
}
images.value = newImages
// Clear any errors on successful removal
if (error.value) {
error.value = ''
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to delete image'
console.error('Delete error:', err)
}
}
// Expose utility methods for parent components
defineExpose({
uploadFiles,
clearImages: () => { images.value = [] },
getPrimaryImage: () => images.value.find(img => img.isPrimary),
getAllImages: () => images.value
})
</script>

View file

@ -0,0 +1,229 @@
# Image Upload Components Usage Guide
This guide shows how to use the ImageUploadService and components in your modules.
## Using the ImageUploadService via Dependency Injection
```typescript
// In your module's composable or service
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ImageUploadService } from '@/modules/base/services/ImageUploadService'
export function useMyModuleWithImages() {
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
// Use the service methods
async function uploadProductImage(file: File) {
const result = await imageService.uploadImage(file, {
maxSizeMB: 5,
generateThumbnail: true
})
return result
}
function getProductImageUrl(alias: string) {
return imageService.getResizedUrl(alias, 800)
}
return {
uploadProductImage,
getProductImageUrl
}
}
```
## Using ImageUpload Component
```vue
<template>
<div class="product-form">
<h2>Add Product</h2>
<!-- Image Upload Component -->
<ImageUpload
v-model="productImages"
:multiple="true"
:max-files="5"
:max-size-mb="10"
:show-primary-button="true"
placeholder="Upload product images"
@images-updated="handleImagesUpdated"
/>
<!-- Rest of your form -->
<Button @click="saveProduct" :disabled="productImages.length === 0">
Save Product
</Button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
import { Button } from '@/components/ui/button'
const productImages = ref([])
function handleImagesUpdated(images) {
console.log('Images updated:', images)
// Save image aliases to your product data
const imageAliases = images.map(img => ({
alias: img.alias,
isPrimary: img.isPrimary
}))
// Store these in your product object
}
async function saveProduct() {
// Save product with image aliases
const productData = {
name: 'Product Name',
images: productImages.value.map(img => ({
alias: img.alias,
isPrimary: img.isPrimary
}))
}
// Send to your API
}
</script>
```
## Using ImageDisplay Component
```vue
<template>
<div class="product-view">
<!-- Display product images -->
<ImageDisplay
:images="product.images"
:show-thumbnails="true"
:show-lightbox="true"
:show-badge="true"
:primary-image-options="{ resize: 600 }"
alt="Product image"
image-class="rounded-lg shadow-lg"
/>
<h2>{{ product.name }}</h2>
<p>{{ product.description }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ImageDisplay from '@/modules/base/components/ImageDisplay.vue'
const product = ref({
name: 'Example Product',
description: 'Product description',
images: [
{ alias: 'image1-alias', isPrimary: true },
{ alias: 'image2-alias', isPrimary: false }
]
})
</script>
```
## Module Integration Example - Market Module
```vue
<!-- In market module's product form -->
<template>
<form @submit="onSubmit" class="space-y-6">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>Product Name</FormLabel>
<FormControl>
<Input v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Image upload section -->
<div>
<FormLabel>Product Images</FormLabel>
<ImageUpload
v-model="formImages"
:multiple="true"
:max-files="10"
:show-primary-button="true"
placeholder="Add product photos"
/>
</div>
<Button type="submit" :disabled="!isFormValid || formImages.length === 0">
List Product
</Button>
</form>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useForm } from 'vee-validate'
import * as z from 'zod'
import { toTypedSchema } from '@vee-validate/zod'
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
const formSchema = toTypedSchema(z.object({
name: z.string().min(1, "Product name is required"),
price: z.number().min(0)
}))
const form = useForm({
validationSchema: formSchema
})
const formImages = ref([])
const isFormValid = computed(() => form.meta.value.valid)
const onSubmit = form.handleSubmit(async (values) => {
const productData = {
...values,
images: formImages.value.map(img => ({
alias: img.alias,
isPrimary: img.isPrimary,
deleteToken: img.delete_token // Store for later cleanup if needed
}))
}
// Submit to your API
console.log('Submitting product:', productData)
})
</script>
```
## Service Methods Reference
### ImageUploadService
- `uploadImage(file: File, options?)` - Upload single image
- `uploadImages(files: File[], options?)` - Upload multiple images
- `deleteImage(deleteToken: string, alias: string)` - Delete uploaded image
- `getImageUrl(alias: string, options?)` - Get processed image URL
- `getThumbnailUrl(alias: string, size?)` - Get thumbnail URL
- `getResizedUrl(alias: string, size?)` - Get resized URL
- `getBlurredUrl(alias: string, blur?)` - Get blurred placeholder
- `getCroppedUrl(alias: string, width, height)` - Get cropped URL
- `checkHealth()` - Check if pict-rs server is available
### ImageUpload Component Props
- `v-model` / `modelValue` - Array of uploaded images
- `multiple` - Allow multiple file selection
- `maxFiles` - Maximum number of files
- `maxSizeMB` - Maximum file size in MB
- `showPrimaryButton` - Show button to set primary image
- `disabled` - Disable upload functionality
- `placeholder` - Custom placeholder text
### ImageDisplay Component Props
- `images` - Array of images to display
- `alt` - Alt text for images
- `imageClass` - CSS class for main image
- `showThumbnails` - Show thumbnail gallery
- `showLightbox` - Enable lightbox on click
- `showBadge` - Show image count badge
- `thumbnailSize` - Size of thumbnails
- `primaryImageOptions` - Options for main image processing

View file

@ -16,10 +16,16 @@ import { storageService } from '@/core/services/StorageService'
import { toastService } from '@/core/services/ToastService'
import { InvoiceService } from '@/core/services/invoiceService'
import { LnbitsAPI } from '@/lib/api/lnbits'
import { ImageUploadService } from './services/ImageUploadService'
// Import components
import ImageUpload from './components/ImageUpload.vue'
import ImageDisplay from './components/ImageDisplay.vue'
// Create service instances
const invoiceService = new InvoiceService()
const lnbitsAPI = new LnbitsAPI()
const imageUploadService = new ImageUploadService()
/**
* Base Module Plugin
@ -55,7 +61,10 @@ export const baseModule: ModulePlugin = {
// Register API services
container.provide(SERVICE_TOKENS.LNBITS_API, lnbitsAPI)
// Register image upload service
container.provide(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE, imageUploadService)
// Register PWA service
container.provide('pwaService', pwaService)
@ -86,6 +95,10 @@ export const baseModule: ModulePlugin = {
waitForDependencies: false, // ToastService has no dependencies
maxRetries: 1
})
await imageUploadService.initialize({
waitForDependencies: true, // ImageUploadService depends on ToastService
maxRetries: 3
})
// InvoiceService doesn't need initialization as it's not a BaseService
console.log('✅ Base module installed successfully')
@ -101,12 +114,14 @@ export const baseModule: ModulePlugin = {
await visibilityService.dispose()
await storageService.dispose()
await toastService.dispose()
await imageUploadService.dispose()
// InvoiceService doesn't need disposal as it's not a BaseService
await lnbitsAPI.dispose()
// Remove services from DI container
container.remove(SERVICE_TOKENS.LNBITS_API)
container.remove(SERVICE_TOKENS.INVOICE_SERVICE)
container.remove(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
console.log('✅ Base module uninstalled')
},
@ -119,14 +134,18 @@ export const baseModule: ModulePlugin = {
storageService,
toastService,
invoiceService,
pwaService
pwaService,
imageUploadService
},
// No routes - base module is pure infrastructure
routes: [],
// No UI components at module level - they'll be imported as needed
components: {}
// Export components for use by other modules
components: {
ImageUpload,
ImageDisplay
}
}
export default baseModule

View file

@ -0,0 +1,358 @@
import { BaseService } from '@/core/base/BaseService'
import type { ServiceMetadata } from '@/core/base/BaseService'
import appConfig from '@/app.config'
export interface UploadedImage {
alias: string
delete_token: string
details: {
width: number
height: number
content_type: string
created_at: number
}
}
export interface ImageUploadOptions {
maxSizeMB?: number
acceptedTypes?: string[]
generateThumbnail?: boolean
}
export interface ImageUrlOptions {
thumbnail?: number
resize?: number
blur?: number
crop?: string
format?: 'webp' | 'jpg' | 'png'
}
/**
* Service for handling image uploads to pict-rs
* Provides centralized image management for all modules
*/
export class ImageUploadService extends BaseService {
protected readonly metadata: ServiceMetadata = {
name: 'ImageUploadService',
version: '1.0.0',
dependencies: ['ToastService']
}
private baseUrl: string = ''
private maxFileSizeMB: number = 10
private acceptedTypes: string[] = ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif']
protected async onInitialize(): Promise<void> {
// Get configuration from environment or app config
const baseModuleConfig = appConfig.modules.base.config as any
this.baseUrl = baseModuleConfig.imageUpload?.baseUrl ||
import.meta.env.VITE_PICTRS_BASE_URL ||
'https://img.mydomain.com'
this.maxFileSizeMB = baseModuleConfig.imageUpload?.maxSizeMB || 10
if (baseModuleConfig.imageUpload?.acceptedTypes) {
this.acceptedTypes = baseModuleConfig.imageUpload.acceptedTypes
}
this.debug('ImageUploadService initialized with baseUrl:', this.baseUrl)
// TODO: Explore using LNbits as a proxy service for pict-rs
// This would provide several benefits:
// - Unified authentication with wallet admin keys
// - Consistent CORS handling through LNbits server
// - Better integration with marketplace and events modules
// - Potential for payment-gated image uploads
// - Centralized image management through LNbits extensions
// Implementation would involve creating an LNbits extension that proxies
// pict-rs requests and potentially adds authentication/payment layers
}
/**
* Upload a single image file to pict-rs
*/
async uploadImage(file: File, options: ImageUploadOptions = {}): Promise<UploadedImage> {
try {
// Validate file
this.validateFile(file, options)
const formData = new FormData()
formData.append('images[]', file)
const response = await fetch(`${this.baseUrl}/image`, {
method: 'POST',
body: formData
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ msg: 'Upload failed' }))
throw new Error(errorData.msg || `Upload failed with status ${response.status}`)
}
const data = await response.json()
if (!data.files || data.files.length === 0) {
throw new Error('No file information returned from upload')
}
const uploadedFile = data.files[0]
// Show success toast
this.toastService?.success(`Image uploaded successfully`)
return {
alias: uploadedFile.file,
delete_token: uploadedFile.delete_token,
details: uploadedFile.details
}
} catch (error) {
// Let component handle user feedback for better UX control
throw this.handleError(error, 'uploadImage')
}
}
/**
* Upload multiple images
* Returns successful uploads and logs errors for component to handle
*/
async uploadImages(files: File[], options: ImageUploadOptions = {}): Promise<{
results: UploadedImage[]
errors: Array<{ file: string; error: string }>
}> {
const results: UploadedImage[] = []
const errors: Array<{ file: string; error: string }> = []
for (const file of files) {
try {
const result = await this.uploadImage(file, options)
results.push(result)
} catch (error) {
errors.push({
file: file.name,
error: error instanceof Error ? error.message : String(error)
})
}
}
// Return both successful uploads and errors for component to handle
if (errors.length > 0) {
this.debug('Some images failed to upload:', errors)
}
return { results, errors }
}
/**
* Delete an uploaded image
*/
async deleteImage(deleteToken: string, alias: string): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/image/delete/${deleteToken}/${alias}`, {
method: 'DELETE'
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ msg: 'Delete failed' }))
throw new Error(errorData.msg || `Delete failed with status ${response.status}`)
}
this.debug('Image deleted successfully:', alias)
} catch (error) {
// Let component handle user feedback for better UX control
throw this.handleError(error, 'deleteImage')
}
}
/**
* Get the URL for an image with optional processing using pict-rs
* Supports thumbnail, resize, blur, and crop transformations
*/
getImageUrl(alias: string, options: ImageUrlOptions = {}): string {
if (!alias) {
return ''
}
// If alias is already a full URL, return it as-is to prevent double URLs
if (alias.startsWith('http://') || alias.startsWith('https://')) {
return alias
}
// Extract file ID in case alias contains URL parts
const fileId = this.extractFileId(alias)
// If no processing options specified, return original
if (!options.thumbnail && !options.resize && !options.blur && !options.crop) {
return `${this.baseUrl}/image/original/${fileId}`
}
// Build processing URL with specified options
const format = options.format || 'webp'
let url = `${this.baseUrl}/image/process.${format}?src=${fileId}`
if (options.thumbnail) {
url += `&thumbnail=${options.thumbnail}`
} else if (options.resize) {
url += `&resize=${options.resize}`
}
if (options.blur) {
url += `&blur=${options.blur}`
}
if (options.crop) {
url += `&crop=${options.crop}`
}
return url
}
/**
* Get thumbnail URL for an image using pict-rs processing
*/
getThumbnailUrl(alias: string, size = 256): string {
if (!alias) {
return ''
}
// Extract file ID if alias is a full URL
let fileId = this.extractFileId(alias)
// Use pict-rs thumbnail processing with webp format for better compression
return `${this.baseUrl}/image/process.webp?src=${fileId}&thumbnail=${size}`
}
/**
* Get resized URL for an image using pict-rs Lanczos2 filter (better quality than thumbnail)
*/
getResizedUrl(alias: string, size = 800): string {
if (!alias) {
return ''
}
// Extract file ID if alias is a full URL
let fileId = this.extractFileId(alias)
// Use pict-rs resize processing with webp format for better compression
return `${this.baseUrl}/image/process.webp?src=${fileId}&resize=${size}`
}
/**
* Get blurred placeholder URL using pict-rs gaussian blur (useful for loading states)
*/
getBlurredUrl(alias: string, blur = 5): string {
if (!alias) {
return ''
}
// Extract file ID if alias is a full URL
let fileId = this.extractFileId(alias)
// Use pict-rs blur processing with webp format
return `${this.baseUrl}/image/process.webp?src=${fileId}&blur=${blur}`
}
/**
* Get cropped URL for an image using pict-rs center crop (maintains aspect ratio)
*/
getCroppedUrl(alias: string, width: number, height: number): string {
if (!alias) {
return ''
}
// Extract file ID if alias is a full URL
let fileId = this.extractFileId(alias)
// Use pict-rs crop processing with webp format
return `${this.baseUrl}/image/process.webp?src=${fileId}&crop=${width}x${height}`
}
/**
* Extract file ID from alias, handling both file IDs and full URLs
*/
private extractFileId(alias: string): string {
if (!alias) {
return ''
}
// If it's already a file ID (not a full URL), return as-is
if (!alias.startsWith('http://') && !alias.startsWith('https://')) {
return alias
}
// Extract file ID from full pict-rs URL
if (alias.includes('/image/original/')) {
const parts = alias.split('/image/original/')
if (parts.length > 1 && parts[1]) {
return parts[1]
}
}
// If we can't extract file ID, return the original alias
return alias
}
/**
* Validate file before upload
*/
private validateFile(file: File, options: ImageUploadOptions = {}): void {
const maxSize = (options.maxSizeMB || this.maxFileSizeMB) * 1024 * 1024
const acceptedTypes = options.acceptedTypes || this.acceptedTypes
// Check file type
if (!acceptedTypes.includes(file.type)) {
throw new Error(`Invalid file type: ${file.type}. Accepted types: ${acceptedTypes.join(', ')}`)
}
// Check file size
if (file.size > maxSize) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(2)
const maxMB = (maxSize / (1024 * 1024)).toFixed(0)
throw new Error(`File size (${sizeMB}MB) exceeds maximum allowed size (${maxMB}MB)`)
}
}
/**
* Convert File to base64 for preview (client-side only)
*/
async fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result as string)
reader.onerror = error => reject(error)
})
}
/**
* Check if pict-rs server is available
*/
async checkHealth(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/healthz`, {
method: 'GET',
signal: AbortSignal.timeout(5000)
})
return response.ok
} catch (error) {
this.debug('pict-rs health check failed:', error)
return false
}
}
/**
* Get service status information
*/
getStatus(): {
baseUrl: string
maxFileSizeMB: number
acceptedTypes: string[]
isConfigured: boolean
} {
return {
baseUrl: this.baseUrl,
maxFileSizeMB: this.maxFileSizeMB,
acceptedTypes: this.acceptedTypes,
isConfigured: !!this.baseUrl && this.baseUrl !== 'https://img.mydomain.com'
}
}
}