import { Logger } from '../../core/logger.js'; import { EventEmitter } from './EventEmitter.js'; export class ImageUploader extends EventEmitter { constructor(config, state) { super(); this.config = config; this.state = state; this.uploadAreas = new Map(); this.activeUploads = new Map(); } async init() { const elements = document.querySelectorAll('[data-image-upload]'); for (const element of elements) { await this.initializeUploadArea(element); } Logger.info(`[ImageUploader] Initialized ${elements.length} upload areas`); } async initializeUploadArea(element) { const config = this.parseElementConfig(element); const uploadArea = new UploadArea(element, config, this); this.uploadAreas.set(element, uploadArea); await uploadArea.init(); } parseElementConfig(element) { return { multiple: element.hasAttribute('data-multiple'), maxFiles: parseInt(element.dataset.maxFiles) || 10, accept: element.dataset.accept || this.config.allowedTypes.join(','), maxSize: parseInt(element.dataset.maxSize) || this.config.maxFileSize, preview: element.hasAttribute('data-preview'), ...this.config }; } async uploadFile(file, config, progressCallback) { const uploadId = this.generateUploadId(); try { // Validate file this.validateFile(file, config); // Create form data const formData = new FormData(); formData.append('image', file); // Add CSRF token if available const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || document.querySelector('input[name="_token"]')?.value; if (csrfToken) { formData.append('_token', csrfToken); } // Create XMLHttpRequest for progress tracking const xhr = new XMLHttpRequest(); // Track active upload this.activeUploads.set(uploadId, { xhr, file }); // Setup progress tracking xhr.upload.addEventListener('progress', (event) => { if (event.lengthComputable) { const progress = Math.round((event.loaded / event.total) * 100); progressCallback?.(progress); this.emit('upload:progress', { uploadId, file, progress }); } }); // Create upload promise const uploadPromise = new Promise((resolve, reject) => { xhr.onload = () => { this.activeUploads.delete(uploadId); if (xhr.status >= 200 && xhr.status < 300) { try { const response = JSON.parse(xhr.responseText); resolve(response); } catch (e) { reject(new Error('Invalid JSON response')); } } else { try { const errorResponse = JSON.parse(xhr.responseText); reject(new Error(errorResponse.message || `Upload failed: ${xhr.status}`)); } catch (e) { reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`)); } } }; xhr.onerror = () => { this.activeUploads.delete(uploadId); reject(new Error('Network error during upload')); }; xhr.onabort = () => { this.activeUploads.delete(uploadId); reject(new Error('Upload cancelled')); }; }); // Start upload xhr.open('POST', config.uploadEndpoint); xhr.send(formData); const result = await uploadPromise; this.emit('upload:success', result); return result; } catch (error) { this.activeUploads.delete(uploadId); this.emit('upload:error', error); throw error; } } validateFile(file, config) { // Check file type if (!config.allowedTypes.includes(file.type)) { throw new Error(`File type ${file.type} is not allowed`); } // Check file size if (file.size > config.maxSize) { const maxSizeMB = Math.round(config.maxSize / (1024 * 1024)); throw new Error(`File size exceeds ${maxSizeMB}MB limit`); } // Check if it's actually an image if (!file.type.startsWith('image/')) { throw new Error('File is not an image'); } } cancelUpload(uploadId) { const upload = this.activeUploads.get(uploadId); if (upload) { upload.xhr.abort(); this.activeUploads.delete(uploadId); this.emit('upload:cancelled', { uploadId, file: upload.file }); } } generateUploadId() { return 'upload_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); } destroy() { // Cancel all active uploads for (const [uploadId] of this.activeUploads) { this.cancelUpload(uploadId); } // Destroy all upload areas for (const [element, uploadArea] of this.uploadAreas) { uploadArea.destroy(); } this.uploadAreas.clear(); this.removeAllListeners(); } } class UploadArea { constructor(element, config, uploader) { this.element = element; this.config = config; this.uploader = uploader; this.fileInput = null; this.previewContainer = null; this.uploadQueue = []; this.isDragOver = false; } async init() { this.createHTML(); this.setupEventListeners(); this.setupDragAndDrop(); } createHTML() { this.element.classList.add('image-upload-area'); this.element.innerHTML = `

Drop images here or click to browse

Supports ${this.config.allowedTypes.map(type => type.split('/')[1].toUpperCase()).join(', ')} up to ${Math.round(this.config.maxSize / (1024 * 1024))}MB

${this.config.preview ? '
' : ''} `; this.fileInput = this.element.querySelector('.upload-area__input'); if (this.config.preview) { this.previewContainer = this.element.querySelector('.upload-area__preview'); } } setupEventListeners() { const button = this.element.querySelector('.upload-area__button'); const content = this.element.querySelector('.upload-area__content'); // Button and area click handlers button.addEventListener('click', () => this.fileInput.click()); content.addEventListener('click', (e) => { if (e.target === content || e.target.closest('.upload-area__text')) { this.fileInput.click(); } }); // File input change this.fileInput.addEventListener('change', (e) => { this.handleFiles(Array.from(e.target.files)); }); // Keyboard accessibility this.element.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.fileInput.click(); } }); // Make area focusable this.element.setAttribute('tabindex', '0'); this.element.setAttribute('role', 'button'); this.element.setAttribute('aria-label', 'Upload images'); } setupDragAndDrop() { const events = ['dragenter', 'dragover', 'dragleave', 'drop']; events.forEach(eventName => { this.element.addEventListener(eventName, (e) => { e.preventDefault(); e.stopPropagation(); }); }); this.element.addEventListener('dragenter', () => { this.isDragOver = true; this.element.classList.add('upload-area--drag-over'); }); this.element.addEventListener('dragleave', (e) => { // Only remove drag styles if leaving the entire upload area if (!this.element.contains(e.relatedTarget)) { this.isDragOver = false; this.element.classList.remove('upload-area--drag-over'); } }); this.element.addEventListener('drop', (e) => { this.isDragOver = false; this.element.classList.remove('upload-area--drag-over'); const files = Array.from(e.dataTransfer.files).filter(file => file.type.startsWith('image/') ); if (files.length > 0) { this.handleFiles(files); } }); } async handleFiles(files) { // Validate file count if (!this.config.multiple && files.length > 1) { files = [files[0]]; } if (files.length > this.config.maxFiles) { this.uploader.emit('upload:error', new Error(`Maximum ${this.config.maxFiles} files allowed`) ); return; } // Show previews if enabled if (this.config.preview) { this.showPreviews(files); } // Upload files for (const file of files) { await this.uploadFile(file); } } showPreviews(files) { if (!this.previewContainer) return; this.previewContainer.innerHTML = ''; files.forEach(file => { const previewItem = document.createElement('div'); previewItem.className = 'preview-item'; const img = document.createElement('img'); img.className = 'preview-item__image'; img.alt = file.name; const info = document.createElement('div'); info.className = 'preview-item__info'; info.innerHTML = `
${file.name}
${this.formatFileSize(file.size)}
`; previewItem.appendChild(img); previewItem.appendChild(info); this.previewContainer.appendChild(previewItem); // Create image preview const reader = new FileReader(); reader.onload = (e) => { img.src = e.target.result; }; reader.readAsDataURL(file); }); } async uploadFile(file) { const progressBar = this.element.querySelector('.upload-area__progress'); const progressFill = this.element.querySelector('.progress-bar__fill'); const progressText = this.element.querySelector('.progress-text'); try { // Show progress progressBar.style.display = 'block'; progressText.textContent = `Uploading ${file.name}...`; await this.uploader.uploadFile(file, this.config, (progress) => { progressFill.style.width = `${progress}%`; progressText.textContent = `Uploading ${file.name}... ${progress}%`; }); // Hide progress on success progressBar.style.display = 'none'; } catch (error) { // Hide progress on error progressBar.style.display = 'none'; throw error; } } formatFileSize(bytes) { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } destroy() { // Remove event listeners and clean up this.element.innerHTML = ''; this.element.classList.remove('image-upload-area', 'upload-area--drag-over'); } }