From f7405bc26e9a0c18ba721e1f5799bff74e6bf021 Mon Sep 17 00:00:00 2001 From: padreug Date: Sun, 28 Sep 2025 04:05:01 +0200 Subject: [PATCH] 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. --- .env.example | 3 + src/app.config.ts | 5 + src/core/di-container.ts | 3 + src/modules/base/components/ImageDisplay.vue | 238 +++++++++ src/modules/base/components/ImageUpload.vue | 476 ++++++++++++++++++ src/modules/base/components/README.md | 229 +++++++++ src/modules/base/index.ts | 29 +- .../base/services/ImageUploadService.ts | 358 +++++++++++++ 8 files changed, 1336 insertions(+), 5 deletions(-) create mode 100644 src/modules/base/components/ImageDisplay.vue create mode 100644 src/modules/base/components/ImageUpload.vue create mode 100644 src/modules/base/components/README.md create mode 100644 src/modules/base/services/ImageUploadService.ts diff --git a/.env.example b/.env.example index 20347dc..d56e717 100644 --- a/.env.example +++ b/.env.example @@ -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"] diff --git a/src/app.config.ts b/src/app.config.ts index d49b876..a60693e 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -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'] } } }, diff --git a/src/core/di-container.ts b/src/core/di-container.ts index 83525c1..ac2f573 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -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 diff --git a/src/modules/base/components/ImageDisplay.vue b/src/modules/base/components/ImageDisplay.vue new file mode 100644 index 0000000..0e18969 --- /dev/null +++ b/src/modules/base/components/ImageDisplay.vue @@ -0,0 +1,238 @@ + + + \ No newline at end of file diff --git a/src/modules/base/components/ImageUpload.vue b/src/modules/base/components/ImageUpload.vue new file mode 100644 index 0000000..d85817f --- /dev/null +++ b/src/modules/base/components/ImageUpload.vue @@ -0,0 +1,476 @@ + + + \ No newline at end of file diff --git a/src/modules/base/components/README.md b/src/modules/base/components/README.md new file mode 100644 index 0000000..0b04b5e --- /dev/null +++ b/src/modules/base/components/README.md @@ -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(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 + + + +``` + +## Using ImageDisplay Component + +```vue + + + +``` + +## Module Integration Example - Market Module + +```vue + + + + +``` + +## 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 \ No newline at end of file diff --git a/src/modules/base/index.ts b/src/modules/base/index.ts index f518528..db6df6b 100644 --- a/src/modules/base/index.ts +++ b/src/modules/base/index.ts @@ -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 \ No newline at end of file diff --git a/src/modules/base/services/ImageUploadService.ts b/src/modules/base/services/ImageUploadService.ts new file mode 100644 index 0000000..cabe234 --- /dev/null +++ b/src/modules/base/services/ImageUploadService.ts @@ -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 { + // 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 { + 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 { + 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 { + 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 { + 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' + } + } +} \ No newline at end of file