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:
parent
5a59f7ce89
commit
f7405bc26e
8 changed files with 1336 additions and 5 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
238
src/modules/base/components/ImageDisplay.vue
Normal file
238
src/modules/base/components/ImageDisplay.vue
Normal 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>
|
||||
476
src/modules/base/components/ImageUpload.vue
Normal file
476
src/modules/base/components/ImageUpload.vue
Normal 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>
|
||||
229
src/modules/base/components/README.md
Normal file
229
src/modules/base/components/README.md
Normal 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
|
||||
|
|
@ -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
|
||||
358
src/modules/base/services/ImageUploadService.ts
Normal file
358
src/modules/base/services/ImageUploadService.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue