/** * ComponentFileUploader - File Upload Module for LiveComponents * * Features: * - Drag & Drop support * - Multi-file uploads with queue management * - Progress tracking (per-file and overall) * - Image/document previews * - Client-side validation * - CSRF protection * - Integration with LiveComponent state management * * @package Framework\LiveComponents */ /** * File Upload Progress Tracker */ export class UploadProgress { constructor(file, fileId) { this.file = file; this.fileId = fileId; this.uploadedBytes = 0; this.totalBytes = file.size; this.status = 'pending'; // pending, uploading, processing, complete, error this.error = null; this.xhr = null; this.startTime = null; this.endTime = null; } get percentage() { if (this.totalBytes === 0) return 100; return Math.round((this.uploadedBytes / this.totalBytes) * 100); } get isComplete() { return this.status === 'complete'; } get hasError() { return this.status === 'error'; } get isUploading() { return this.status === 'uploading'; } get uploadSpeed() { if (!this.startTime || !this.isUploading) return 0; const elapsed = (Date.now() - this.startTime) / 1000; // seconds return elapsed > 0 ? this.uploadedBytes / elapsed : 0; // bytes per second } get remainingTime() { const speed = this.uploadSpeed; if (speed === 0) return 0; const remaining = this.totalBytes - this.uploadedBytes; return remaining / speed; // seconds } updateProgress(loaded, total) { this.uploadedBytes = loaded; this.totalBytes = total; } setStatus(status, error = null) { this.status = status; this.error = error; if (status === 'uploading' && !this.startTime) { this.startTime = Date.now(); } if (status === 'complete' || status === 'error') { this.endTime = Date.now(); } } abort() { if (this.xhr) { this.xhr.abort(); this.setStatus('error', 'Upload cancelled'); } } toObject() { return { fileId: this.fileId, fileName: this.file.name, fileSize: this.totalBytes, uploadedBytes: this.uploadedBytes, percentage: this.percentage, status: this.status, error: this.error, uploadSpeed: this.uploadSpeed, remainingTime: this.remainingTime }; } } /** * File Validator - Client-side validation */ export class FileValidator { constructor(options = {}) { this.maxFileSize = options.maxFileSize || 10 * 1024 * 1024; // 10MB default this.allowedMimeTypes = options.allowedMimeTypes || []; this.allowedExtensions = options.allowedExtensions || []; this.minFileSize = options.minFileSize || 1; // 1 byte minimum } validate(file) { const errors = []; // File size validation if (file.size > this.maxFileSize) { errors.push(`File size (${this.formatBytes(file.size)}) exceeds maximum allowed size (${this.formatBytes(this.maxFileSize)})`); } if (file.size < this.minFileSize) { errors.push(`File size is too small (minimum: ${this.formatBytes(this.minFileSize)})`); } // MIME type validation if (this.allowedMimeTypes.length > 0 && !this.allowedMimeTypes.includes(file.type)) { errors.push(`File type "${file.type}" is not allowed. Allowed types: ${this.allowedMimeTypes.join(', ')}`); } // Extension validation if (this.allowedExtensions.length > 0) { const extension = file.name.split('.').pop().toLowerCase(); if (!this.allowedExtensions.includes(extension)) { errors.push(`File extension ".${extension}" is not allowed. Allowed extensions: ${this.allowedExtensions.join(', ')}`); } } // File name validation if (file.name.length > 255) { errors.push('File name is too long (maximum 255 characters)'); } return errors; } isValid(file) { return this.validate(file).length === 0; } formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; } } /** * Drag & Drop Zone Manager */ export class DragDropZone { constructor(element, callbacks = {}) { this.element = element; this.onFilesDropped = callbacks.onFilesDropped || (() => {}); this.onDragEnter = callbacks.onDragEnter || (() => {}); this.onDragLeave = callbacks.onDragLeave || (() => {}); this.dragCounter = 0; // Track nested drag events this.isActive = false; this.bindEvents(); } bindEvents() { // Prevent default browser behavior for drag events ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { this.element.addEventListener(eventName, (e) => { e.preventDefault(); e.stopPropagation(); }); }); // Handle drag enter this.element.addEventListener('dragenter', (e) => { this.dragCounter++; if (this.dragCounter === 1) { this.isActive = true; this.element.classList.add('drag-over'); this.onDragEnter(e); } }); // Handle drag leave this.element.addEventListener('dragleave', (e) => { this.dragCounter--; if (this.dragCounter === 0) { this.isActive = false; this.element.classList.remove('drag-over'); this.onDragLeave(e); } }); // Handle drag over this.element.addEventListener('dragover', (e) => { // Required to allow drop }); // Handle drop this.element.addEventListener('drop', (e) => { this.dragCounter = 0; this.isActive = false; this.element.classList.remove('drag-over'); const files = Array.from(e.dataTransfer?.files || []); if (files.length > 0) { this.onFilesDropped(files); } }); } destroy() { this.element.classList.remove('drag-over'); // Event listeners are automatically removed when element is removed } } /** * Main ComponentFileUploader Class */ export class ComponentFileUploader { constructor(componentElement, options = {}) { this.componentElement = componentElement; this.componentId = componentElement.dataset.liveId; // Options this.maxFileSize = options.maxFileSize || 10 * 1024 * 1024; // 10MB this.allowedMimeTypes = options.allowedMimeTypes || []; this.allowedExtensions = options.allowedExtensions || []; this.maxFiles = options.maxFiles || 10; this.autoUpload = options.autoUpload !== false; // default: true this.multiple = options.multiple !== false; // default: true this.endpoint = options.endpoint || `/live-component/${this.componentId}/upload`; // Callbacks this.onFileAdded = options.onFileAdded || (() => {}); this.onFileRemoved = options.onFileRemoved || (() => {}); this.onUploadStart = options.onUploadStart || (() => {}); this.onUploadProgress = options.onUploadProgress || (() => {}); this.onUploadComplete = options.onUploadComplete || (() => {}); this.onUploadError = options.onUploadError || (() => {}); this.onAllUploadsComplete = options.onAllUploadsComplete || (() => {}); // State this.files = new Map(); // Map this.uploadQueue = []; this.activeUploads = 0; this.maxConcurrentUploads = options.maxConcurrentUploads || 2; // Validator this.validator = new FileValidator({ maxFileSize: this.maxFileSize, allowedMimeTypes: this.allowedMimeTypes, allowedExtensions: this.allowedExtensions }); // UI Elements (optional) this.dropZoneElement = options.dropZone; this.fileInputElement = options.fileInput; this.initialize(); } initialize() { // Setup drag & drop if dropZone provided if (this.dropZoneElement) { this.dragDropZone = new DragDropZone(this.dropZoneElement, { onFilesDropped: (files) => this.addFiles(files), onDragEnter: () => this.dropZoneElement.classList.add('drag-active'), onDragLeave: () => this.dropZoneElement.classList.remove('drag-active') }); } // Setup file input if provided if (this.fileInputElement) { this.fileInputElement.addEventListener('change', (e) => { const files = Array.from(e.target.files || []); this.addFiles(files); e.target.value = ''; // Reset input }); } } /** * Add files to upload queue */ addFiles(files) { const filesToAdd = Array.isArray(files) ? files : [files]; // Check max files limit if (this.files.size + filesToAdd.length > this.maxFiles) { const error = `Cannot add files. Maximum ${this.maxFiles} files allowed.`; this.onUploadError({ error, fileCount: filesToAdd.length }); return; } for (const file of filesToAdd) { // Generate unique file ID const fileId = this.generateFileId(file); // Validate file const validationErrors = this.validator.validate(file); const progress = new UploadProgress(file, fileId); if (validationErrors.length > 0) { progress.setStatus('error', validationErrors.join(', ')); this.files.set(fileId, progress); this.onUploadError({ fileId, file, errors: validationErrors }); continue; } // Add to files map this.files.set(fileId, progress); this.uploadQueue.push(fileId); // Callback this.onFileAdded({ fileId, file, progress: progress.toObject() }); } // Auto-upload if enabled if (this.autoUpload) { this.processQueue(); } } /** * Remove file from queue */ removeFile(fileId) { const progress = this.files.get(fileId); if (!progress) return; // Abort if uploading if (progress.isUploading) { progress.abort(); } // Remove from queue const queueIndex = this.uploadQueue.indexOf(fileId); if (queueIndex !== -1) { this.uploadQueue.splice(queueIndex, 1); } // Remove from files map this.files.delete(fileId); // Callback this.onFileRemoved({ fileId, file: progress.file }); } /** * Start uploading all queued files */ uploadAll() { this.processQueue(); } /** * Process upload queue */ async processQueue() { while (this.uploadQueue.length > 0 && this.activeUploads < this.maxConcurrentUploads) { const fileId = this.uploadQueue.shift(); const progress = this.files.get(fileId); if (!progress || progress.status !== 'pending') continue; this.activeUploads++; this.uploadFile(fileId).finally(() => { this.activeUploads--; this.processQueue(); // Process next file // Check if all uploads are complete if (this.activeUploads === 0 && this.uploadQueue.length === 0) { this.onAllUploadsComplete({ totalFiles: this.files.size, successCount: Array.from(this.files.values()).filter(p => p.isComplete).length, errorCount: Array.from(this.files.values()).filter(p => p.hasError).length }); } }); } } /** * Upload a single file */ async uploadFile(fileId) { const progress = this.files.get(fileId); if (!progress) return; try { // Get current component state const componentState = this.getComponentState(); // Get CSRF tokens const csrfTokens = await this.getCsrfTokens(); // Create FormData const formData = new FormData(); formData.append('file', progress.file); formData.append('state', JSON.stringify(componentState)); formData.append('params', JSON.stringify({ fileId })); // Create XHR request const xhr = new XMLHttpRequest(); progress.xhr = xhr; // Setup progress tracking xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { progress.updateProgress(e.loaded, e.total); this.onUploadProgress({ fileId, ...progress.toObject() }); } }); // Setup completion handler const uploadPromise = new Promise((resolve, reject) => { xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { try { const response = JSON.parse(xhr.responseText); if (response.success) { progress.setStatus('complete'); // Update component state if provided if (response.state) { this.updateComponentState(response.state); } // Update component HTML if provided if (response.html) { this.updateComponentHtml(response.html); } this.onUploadComplete({ fileId, file: progress.file, response }); resolve(response); } else { throw new Error(response.error || 'Upload failed'); } } catch (e) { reject(e); } } else { reject(new Error(`Upload failed with status ${xhr.status}`)); } }); xhr.addEventListener('error', () => { reject(new Error('Network error during upload')); }); xhr.addEventListener('abort', () => { reject(new Error('Upload cancelled')); }); }); // Set status to uploading progress.setStatus('uploading'); this.onUploadStart({ fileId, file: progress.file }); // Open and send request xhr.open('POST', this.endpoint); // Set CSRF headers xhr.setRequestHeader('X-CSRF-Form-ID', csrfTokens.form_id); xhr.setRequestHeader('X-CSRF-Token', csrfTokens.token); xhr.setRequestHeader('Accept', 'application/json'); xhr.setRequestHeader('User-Agent', navigator.userAgent); xhr.send(formData); await uploadPromise; } catch (error) { progress.setStatus('error', error.message); this.onUploadError({ fileId, file: progress.file, error: error.message }); } } /** * Cancel all uploads */ cancelAll() { this.uploadQueue = []; this.files.forEach(progress => { if (progress.isUploading) { progress.abort(); } }); } /** * Clear all files (completed and pending) */ clearAll() { this.cancelAll(); this.files.clear(); } /** * Get overall upload progress */ getOverallProgress() { if (this.files.size === 0) return 100; const totalBytes = Array.from(this.files.values()).reduce((sum, p) => sum + p.totalBytes, 0); const uploadedBytes = Array.from(this.files.values()).reduce((sum, p) => sum + p.uploadedBytes, 0); return totalBytes > 0 ? Math.round((uploadedBytes / totalBytes) * 100) : 0; } /** * Get upload statistics */ getStats() { const files = Array.from(this.files.values()); return { total: files.length, pending: files.filter(p => p.status === 'pending').length, uploading: files.filter(p => p.status === 'uploading').length, complete: files.filter(p => p.status === 'complete').length, error: files.filter(p => p.status === 'error').length, overallProgress: this.getOverallProgress() }; } /** * Helper: Generate unique file ID */ generateFileId(file) { return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${file.name}`; } /** * Helper: Get CSRF tokens */ async getCsrfTokens() { try { const response = await fetch(`/api/csrf/token?action=${encodeURIComponent(this.endpoint)}&method=post`, { headers: { 'Accept': 'application/json', 'User-Agent': navigator.userAgent } }); if (!response.ok) { throw new Error(`CSRF token request failed: ${response.status}`); } return await response.json(); } catch (error) { console.error('Failed to get CSRF tokens:', error); throw error; } } /** * Helper: Get component state from DOM */ getComponentState() { const stateElement = this.componentElement.querySelector('[data-live-state]'); if (stateElement) { try { return JSON.parse(stateElement.textContent || '{}'); } catch (e) { console.warn('Failed to parse component state:', e); } } return {}; } /** * Helper: Update component state in DOM */ updateComponentState(newState) { const stateElement = this.componentElement.querySelector('[data-live-state]'); if (stateElement) { stateElement.textContent = JSON.stringify(newState); } } /** * Helper: Update component HTML */ updateComponentHtml(html) { // Find the component content container (usually the component itself) const contentElement = this.componentElement.querySelector('[data-live-content]') || this.componentElement; if (contentElement) { contentElement.innerHTML = html; } } /** * Cleanup */ destroy() { this.cancelAll(); this.clearAll(); if (this.dragDropZone) { this.dragDropZone.destroy(); } } } export default ComponentFileUploader;