class UploadManager { constructor() { //Load dependencies this.queue = window.jvbQueue; this.a11y = window.jvbA11y; this.error = window.jvbError; //Load Datastore this.fieldStore = new window.jvbStore({ name: 'upload_fields', storeName: 'fieldStates', keyPath: 'id', version: 2, indexes: [ { name: 'fieldId', keyPath: 'fieldId' }, { name: 'timestamp', keyPath: 'timestamp' }, { name: 'content', keyPath: 'content' }, { name: 'itemId', keyPath: 'itemId' }, { name: 'status', keyPath: 'status' } ], stripDOMReferences: true, TTL: 86400000*7 // 24 hours -> 1 week }); this.uploadStore = new window.jvbStore({ name: 'uploads', storeName: 'uploads', keyPath: 'id', storeBlobs: true, indexes: [ { name: 'fieldId', keyPath: 'fieldId' }, { name: 'status', keyPath: 'status' }, { name: 'groupId', keyPath: 'groupId' }, { name: 'attachmentId', keyPath: 'attachmentId' } ], }); // Subscribe to store events this.fieldStore.subscribe(this.handleFieldStoreEvent.bind(this)); this.uploadStore.subscribe(this.handleUploadStoreEvent.bind(this)); //Load Worker this.initWorker(); // Core data structures this.fields = new Map(); this.uploads = new Map(); this.uploadBlobs = new Map(); this.groups = new Map(); this.selected = new Map(); this.selectionHandlers = new Map(); this.previewUrls = new Set(); //Notification and Subscribers this.subscribers = new Set(); // Controllers (will be initialized based on features) this.dragController = null; // Selectors this.selectors = { field: { field: '[data-upload-field]', input: 'input[type="file"]', hiddenValue: 'input[type="hidden"]', dropZone: '.file-upload-container', preview: '.item-grid.preview', progress: '.image-progress' }, groups: { container: '.upload-group', grid: '.item-grid.group', header: '.group-header', selectAll: '[name="select-all-group"]', actions: '.group-actions', count: '.selection-controls .info' }, items: { item: '[data-upload-id]', checkbox: '[name*="select-item"]', featured: '[name="featured"]', details: 'details' } }; this.statusMapping = { 'received': 'Image Received', 'local_processing': 'Processing Image...', 'queued': 'Waiting to upload...', 'uploading': 'Uploading to Server', 'pending': 'Successfully sent to server. In line for further processing.', 'processing': 'Processing on server...', 'completed': 'Upload complete!', 'failed': 'Upload failed (will retry)', 'failed_permanent': 'Upload failed permanently' }; this.init(); } async init() { // Load existing data await this.loadFields(); await this.loadUploads(); // Initialize fields this.initializeFields(); // Set up core listeners this.initListeners(); this.queue.subscribe((event, operation) => { if (operation.endpoint !== 'uploads' && operation.endpoint !== 'uploads/meta') { return; } const fieldId = operation.data instanceof FormData ? operation.data.get('fieldId') : operation.data.fieldId; switch(event) { case 'cancel-operation': if (fieldId) { this.clearField(fieldId); } break; case 'operation-status': if (fieldId) { this.updateFieldStatus(fieldId, operation.status); } break; case 'operation-complete': const results = operation.result?.data || []; results.forEach(result => { const upload = this.uploads.get(result.upload_id); if (upload) { upload.attachmentId = result.attachment_id; upload.status = 'completed'; this.uploads.set(upload.id, upload); } }); if (fieldId) { this.cleanField(fieldId); } break; } }); window.addEventListener('beforeunload', () => { this.cleanupAllPreviewUrls(); }); } initWorker() { this.worker = { worker: null, timeout: null, tasks: new Map(), restart: { count: 0, max: 3, }, settings: { timeout: 10000, //10 seconds per image batchSize: 1, maxConcurrent: 3, restartAfterTimeout: true } }; } /** * Initialize all upload fields on the page */ initializeFields() { const fields = document.querySelectorAll(this.selectors.field.field); fields.forEach(uploader => { this.registerUploader(uploader); }); } scanFields(container) { const fields = container.querySelectorAll(this.selectors.field.field); fields.forEach(uploader => { this.registerUploader(uploader); }); } registerUploader(uploader) { const fieldId = this.determineFieldId(uploader); const config = this.extractFieldConfig(uploader); // Create field data structure const field = { id: fieldId, config: config, element: uploader, ui: this.buildFieldUI(uploader), uploads: new Set(), groups: new Set(), state: 'ready', }; this.fields.set(fieldId, field); uploader.dataset.uploader = fieldId; this.addFieldSelectionHandler(fieldId); if (config.destination === 'post_group' && !this.dragController) { this.initGroupFeatures(); } return fieldId; } /** * Extract configuration from field element */ extractFieldConfig(fieldElement) { return { destination: fieldElement.dataset.destination || 'meta', content: fieldElement.dataset.content || null, mode: fieldElement.dataset.mode || 'direct', type: fieldElement.dataset.type || 'single', name: fieldElement.dataset.field, // Field name for meta itemID: fieldElement.dataset.itemId || 0, // Post/term/user ID maxFiles: parseInt(fieldElement.dataset.maxFiles) || 999, subtype: fieldElement.dataset.subtype || 'image' }; } /** * Build UI element references for a field */ buildFieldUI(fieldElement) { let UI = { field: fieldElement, input: fieldElement.querySelector(this.selectors.field.input), dropZone: fieldElement.querySelector(this.selectors.field.dropZone), preview: fieldElement.querySelector(this.selectors.field.preview), progress: { progress: fieldElement.querySelector(this.selectors.field.progress), bar: fieldElement.querySelector('.bar'), fill: fieldElement.querySelector('.fill'), details: fieldElement.querySelector('.details'), text: fieldElement.querySelector('.details .text'), count: fieldElement.querySelector('.details .count') } }; let display = fieldElement.querySelector('.group-display'); if (display) { UI.groups = { display: display, container: fieldElement.querySelector('.item-grid.groups'), empty: fieldElement.querySelector('.empty-group'), groups: new Map() }; } return UI; } /** * Set up core event listeners */ initListeners() { this.clickHandler = this.handleClick.bind(this); this.changeHandler = this.handleChange.bind(this); document.addEventListener('click', this.clickHandler); document.addEventListener('change', this.changeHandler); // External file drops this.dragEnterHandler = this.handleExternalDragEnter.bind(this); this.dragLeaveHandler = this.handleExternalDragLeave.bind(this); this.dragOverHandler = this.handleExternalDragOver.bind(this); this.dropHandler = this.handleExternalDrop.bind(this); document.addEventListener('dragenter', this.dragEnterHandler); document.addEventListener('dragleave', this.dragLeaveHandler); document.addEventListener('dragover', this.dragOverHandler); document.addEventListener('drop', this.dropHandler); } /** * Initialize group-specific features (drag & drop for rearranging) */ initGroupFeatures() { // Initialize drag controller for rearranging items this.dragController = new window.jvbDragHandler({ // What can be dragged draggableSelector: this.selectors.items.item, // Where items can be dropped dropTargetSelector: `${this.selectors.field.preview}, ${this.selectors.groups.grid}, .empty-group`, // Don't start drag on interactive elements ignoreSelector: 'input:not(.upload-select), button, select, textarea, details, summary, a', previewElement: 'img, video, .icon', // Extract upload ID from element getItemId: (element) => { return element.dataset.uploadId; }, // Get selected items for multi-drag getSelectedItems: (element) => { const fieldId = this.getFieldIdFromElement(element); const uploadId = element.dataset.uploadId; const selected = this.getCurrentSelection(fieldId); if (selected && selected.includes(uploadId)) { return selected; } return [uploadId]; }, // Validate drop location validateDrop: (itemIds, targetElement) => { const targetFieldId = this.getFieldIdFromElement(targetElement); const itemElement = document.querySelector(`[data-upload-id="${itemIds[0]}"]`); const itemFieldId = this.getFieldIdFromElement(itemElement); return targetFieldId === itemFieldId; }, // Handle successful drop onDrop: (itemIds, targetElement) => { this.handleItemDrop(itemIds, targetElement); targetElement.scrollIntoView({behavior:'smooth', block:'center'}); }, // Optional callbacks onDragStart: (itemIds) => { }, onDragEnd: (itemIds, success) => { if (success) { // Clear selection after successful move const itemElement = document.querySelector(`[data-upload-id="${itemIds[0]}"]`); const fieldId = this.getFieldIdFromElement(itemElement); const handler = this.selectionHandlers.get(fieldId); handler?.clearSelection(); } }, // Preview options previewOptions: { multiOffset: { x: -60, y: -80 }, singleOffset: { x: -50, y: -60 }, showCount: true } }); } /******************************************************************************* * EXTERNAL FILE DROP HANDLERS (for new uploads from desktop) *******************************************************************************/ handleExternalDragLeave(e) { const dropZone = e.target.closest(this.selectors.field.dropZone); if (dropZone && !dropZone.contains(e.relatedTarget)) { dropZone.classList.remove('dragover'); } } handleExternalDragEnter(e) { if (!e.dataTransfer.types.includes('Files')) { return; } const dropZone = e.target.closest(this.selectors.field.dropZone); if (dropZone) { e.preventDefault(); dropZone.classList.add('dragover'); } } handleExternalDragOver(e) { if (!e.dataTransfer.types.includes('Files')) return; const dropZone = e.target.closest(this.selectors.field.dropZone); if (dropZone) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; } } handleExternalDrop(e) { const dropZone = e.target.closest(this.selectors.field.dropZone); if (!dropZone) return; e.preventDefault(); dropZone.classList.remove('dragover'); const files = Array.from(e.dataTransfer.files); if (files.length === 0) return; const fieldId = this.getFieldIdFromElement(dropZone); if (fieldId) { this.processFiles(fieldId, files); this.a11y.announce(`${files.length} file(s) dropped for upload`); } else { console.error('No field ID found for drop zone'); } } /******************************************************************************* * ITEM DROP HANDLER (for rearranging existing uploads) *******************************************************************************/ /** * Handle items being dropped (called by DragController) */ handleItemDrop(itemIds, targetElement) { const isPreviewDrop = targetElement.classList.contains('preview'); let actualTarget = targetElement; // Handle drop on empty group placeholder if (targetElement.classList.contains('empty-group')) { const fieldId = this.getFieldIdFromElement(targetElement); const group = this.createGroup(fieldId); if (!group) { console.error('Failed to create group'); return; } actualTarget = group.grid; } // Move each item to target itemIds.forEach(uploadId => { if (isPreviewDrop) { // Moving back to preview (ungrouping) this.removeFromGroup(uploadId); } else { // Moving to a group this.addToGroup(uploadId, actualTarget); } }); // Persist state const fieldId = this.getFieldIdFromElement(targetElement); this.schedulePersistance(fieldId); // Announce for accessibility const message = itemIds.length > 1 ? `Moved ${itemIds.length} items` : 'Moved item'; this.a11y.announce(message); } /******************************************************************************* * CLICK HANDLERS *******************************************************************************/ handleClick(e) { // File input triggers if (e.target.matches(this.selectors.field.dropZone) || e.target.closest(this.selectors.field.dropZone)) { const dropZone = e.target.closest(this.selectors.field.dropZone); if (dropZone && !e.target.matches('input, button, a')) { const input = dropZone.querySelector(this.selectors.field.input); input?.click(); } } // Group actions const actionButton = e.target.closest('[data-action]'); if (actionButton) { this.handleAction(actionButton); } } handleChange(e) { const fieldId = this.getFieldIdFromElement(e.target); // File input change if (e.target.matches(this.selectors.field.input)) { const fieldId = this.getFieldIdFromElement(e.target); const files = Array.from(e.target.files); if (files.length > 0 && fieldId) { this.processFiles(fieldId, files); } } // Meta field changes if (fieldId) { if (this.fields.get(fieldId).config.destination === 'post_group') { this.handleGroupMetaChange(e.target); } else { this.queueUploadMeta(e); } } } /******************************************************************************** UTILITY ********************************************************************************/ getCurrentSelection(fieldId) { let selected = []; for (let [key, handler] of this.selectionHandlers) { if ((fieldId === key || key.includes(fieldId)) && handler.selectedItems.size > 0) { selected = selected.concat([... handler.selectedItems]); } } return selected; } getSubtypeFromMime(mimeType) { if (mimeType.startsWith('image/')) return 'image'; if (mimeType.startsWith('video/')) return 'video'; return 'document'; } getStatusText(status) { return this.statusMapping[status] || status; } getStatusIcon(status) { return window.getIcon(this.queue.icons[status]); } getStatusProgress(status) { switch (status) { case 'local_processing': return 28; case 'queued': return 50; case 'uploading': return 66; case 'pending': return 75; case 'processing': return 89; case 'completed': return 100; default: return 0; } } getModalType(field) { // Return cached value if available if (field._cachedModalType !== undefined) { return field._cachedModalType; } // Safety check for field.element if (!field || !field.element) { field._cachedModalType = null; return null; } const dialog = field.element.closest('dialog'); if (!dialog) { field._cachedModalType = null; return null; } let modalType = null; if (dialog.classList.contains('edit')) modalType = 'edit'; else if (dialog.classList.contains('create')) modalType = 'create'; else if (dialog.classList.contains('bulkEdit')) modalType = 'bulkEdit'; else modalType = dialog.className; // Cache the result field._cachedModalType = modalType; return modalType; } /******************************************************************************* * GROUP ACTIONS *******************************************************************************/ handleAction(button) { const action = button.dataset.action; const fieldId = this.getFieldIdFromElement(button); switch(action) { case 'add-to-group': this.handleAddToGroup(button); break; case 'delete-group': this.handleDeleteGroup(button); break; case 'delete-upload': case 'remove-from-group': this.handleRemoveItem(button); break; case 'upload': //upload groups let field = this.fields.get(fieldId); field.element.closest('details').open = false; document.body.classList.add('uploading'); this.submitUploads(fieldId); break; case 'restore': this.handleRestoreUploads().then(()=>{}); break; case 'clear-cache': if (!confirm(`Save these uploads for later?`)) { this.cleanupStoredUploads(); } this.cleanupRestore(); break; } } handleAddToGroup(button) { const fieldElement = button.closest(this.selectors.field.field); const fieldId = fieldElement?.dataset.uploader; if (!fieldId) return; const selected = this.selected.get(fieldId); if (!selected || selected.size === 0) { // Create empty group this.createGroup(fieldId); } else { // Create group with selected items const group = this.createGroup(fieldId); if (!group) return; selected.forEach(uploadId => { this.addToGroup(uploadId, group.grid); }); // Clear selection const handler = this.selectionHandlers.get(fieldId); handler?.clearSelection(); this.a11y.announce(`Created group with ${selected.size} items`); } this.schedulePersistance(fieldId); } handleDeleteGroup(button) { const group = button.closest(this.selectors.groups.container); if (!group) return; const groupId = group.dataset.groupId; const fieldId = this.getFieldIdFromElement(group); if (!confirm('Delete this group? Items will be moved back to the upload area.')) { return; } // Move items back to preview const items = group.querySelectorAll(this.selectors.items.item); items.forEach(item => { const uploadId = item.dataset.uploadId; this.removeFromGroup(uploadId); }); // Remove group this.deleteGroup(groupId); this.a11y.announce('Group deleted, items returned to upload area'); this.schedulePersistance(fieldId); } handleRemoveItem(button) { const item = button.closest(this.selectors.items.item); if (!item) return; const uploadId = item.dataset.uploadId; const fieldId = this.getFieldIdFromElement(item); if (!confirm('Remove this item?')) { return; } this.removeUpload(fieldId, uploadId); this.a11y.announce('Item removed'); this.schedulePersistance(fieldId); } /******************************************************************************* * SELECTION MANAGEMENT *******************************************************************************/ /** * Add selection handler for a field */ addFieldSelectionHandler(fieldId) { if (this.selectionHandlers.has(fieldId)) { return this.selectionHandlers.get(fieldId); } const field = this.fields.get(fieldId); if (!field) return; const container = field.ui.field; if (!container) return; const handler = new window.jvbHandleSelection({ container: container, ui: { selectAll: container.querySelector('[name="select-all-uploads"]'), bulkControls: container.querySelector('.selection-actions'), count: container.querySelector('.selection-count') }, itemSelector: '[data-upload-id]', checkboxSelector: '[name*="select-item"]' }); // Subscribe to selection changes handler.subscribe((event, data) => { switch(event) { case 'item-selected': case 'item-deselected': case 'range-selected': this.selected.set(fieldId, data.selectedItems); break; case 'select-all': this.handleSelectAll(data.container, data.selected); break; } }); this.selectionHandlers.set(fieldId, handler); return handler; } /** * Add selection handler for a group */ addGroupSelectionHandler(fieldId, groupId) { const handlerKey = `${fieldId}_${groupId}`; if (this.selectionHandlers.has(handlerKey)) { return this.selectionHandlers.get(handlerKey); } const group = this.groups.get(groupId); if (!group) return; const handler = new window.jvbHandleSelection({ container: group.element, ui: { selectAll: group.element.querySelector(this.selectors.groups.selectAll), bulkControls: group.element.querySelector(this.selectors.groups.actions), count: group.element.querySelector(this.selectors.groups.count) }, itemSelector: '[data-upload-id]', checkboxSelector: '[name*="select-item"]' }); handler.subscribe((event, data) => { switch(event) { case 'item-selected': case 'item-deselected': case 'range-selected': this.selected.set(fieldId, data.selectedItems); break; case 'select-all': this.handleSelectAll(data.container, data.selected); break; } }); this.selectionHandlers.set(handlerKey, handler); return handler; } handleSelectAll(container, selected) { } /******************************************************************************* * HELPER METHODS *******************************************************************************/ determineFieldId(fieldElement) { const content = fieldElement.dataset.content || fieldElement.closest('dialog')?.dataset.content || fieldElement.closest('form')?.dataset.save || ''; const itemID = fieldElement.dataset.itemId || fieldElement.closest('dialog')?.dataset.itemId || ''; const field = fieldElement.dataset.field || ''; return `${content}_${itemID}_${field}`; } getFromElement(element, type) { const map = { 'field': { selector: this.selectors.field.field, key: 'uploader', store: this.fields }, 'upload': { selector: this.selectors.items.item, key: 'uploadId', store: this.uploads }, 'group': { selector: this.selectors.groups.container, key: 'groupId', store: this.groups } }; const config = map[type]; if (!config) return null; const el = element.closest(config.selector); if (!el) return null; const id = el.dataset[config.key]; return config.store.get(id); } getFieldFromElement(el) { return this.getFromElement(el, 'field'); } getUploadFromElement(el) { return this.getFromElement(el, 'upload'); } getGroupFromElement(el) { return this.getFromElement(el, 'group'); } getFieldIdFromElement(el) { return this.getFromElement(el, 'field')?.id ?? null}; getUploadIdFromElement(el) {return this.getFromElement(el, 'upload')?.id ?? null}; getGroupIdFromElement(el) {return this.getFromElement(el, 'group')?.id ?? null}; /******************************************************************************* * FILE PROCESSING *******************************************************************************/ async processFiles(fieldId, files) { const field = this.fields.get(fieldId); if (!field) return; // Hide upload container, show group display if (field.ui.dropZone) { field.ui.dropZone.hidden = true; } if (field.ui.groups.display) { field.ui.groups.display.hidden = false; } const totalFiles = files.length; let processedCount = 0; // Show initial progress this.updateUploadProgress(fieldId, 0, totalFiles, 'Processing files...'); // Initialize field uploads set if needed if (!field.uploads) { field.uploads = new Set(); } // Process files const processPromises = Array.from(files).map(async (file, index) => { try { // Create upload ID const uploadId = `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // Create upload data const uploadData = { id: uploadId, attachment_id: null, fieldId: fieldId, originalFile: file, processedFile: null, preview: null, status: 'local_processing', element: null, location: null, meta: { originalName: file.name, size: file.size, type: file.type } }; // Create preview URL uploadData.preview = this.createPreviewUrl(file); // Process the file (resize if image) if (file.type.startsWith('image/')) { uploadData.processedFile = await this.processImage(file, field.subtype); } else { uploadData.processedFile = file; } // Store blob data separately in IndexedDB await this.uploadStore.saveBlob(uploadId, uploadData.processedFile || file); // Create DOM element const subtype = this.getSubtypeFromMime(file.type); uploadData.element = this.createUploadElement({ ...uploadData, subtype: subtype }, field.config.destination === 'post_group'); // Show progress on the item this.showUploadProgress(uploadId, true); this.updateUploadItemProgress(uploadId, 50, 'local_processing'); // Add to preview grid if (field.ui.preview) { field.ui.preview.appendChild(uploadData.element); uploadData.location = field.ui.preview; } // Store upload this.uploads.set(uploadId, uploadData); field.uploads.add(uploadId); // Update progress processedCount++; this.updateUploadProgress(fieldId, processedCount, totalFiles, 'Processing files...'); this.updateUploadItemProgress(uploadId, 100, 'processed'); uploadData.status = 'processed'; // Fade out item progress after a moment setTimeout(() => { this.showUploadProgress(uploadId, false); }, 1000); return uploadId; } catch (error) { console.error('Error processing file:', file.name, error); processedCount++; this.updateUploadProgress(fieldId, processedCount, totalFiles, 'Processing files...'); return null; } }); // Wait for all files to process await Promise.all(processPromises); this.updateFieldState(fieldId); // Cache the state (now without DOM references) await this.schedulePersistance(fieldId); // Queue for upload if in direct mode if (field.config.destination !== 'post_group') { await this.queueUpload(fieldId); // Lock uploads if max reached this.maybeLockUploads(fieldId); } } updateFieldState(fieldId) { const field = this.fields.get(fieldId); if (!field || !field.ui.field) return; const container = field.ui.field; const uploadCount = field.uploads?.size || 0; const hasGroups = field.ui.groups?.container?.querySelectorAll('.upload-group').length > 0; // Set data attributes for CSS targeting container.dataset.hasUploads = uploadCount > 0 ? 'true' : 'false'; container.dataset.uploadCount = uploadCount.toString(); container.dataset.hasGroups = hasGroups ? 'true' : 'false'; // Update ARIA labels for accessibility if (field.ui.preview) { field.ui.preview.setAttribute('aria-label', `Upload preview area with ${uploadCount} item${uploadCount !== 1 ? 's' : ''}` ); } } updateUploadProgress(fieldId, current, total, message) { const field = this.fields.get(fieldId); if (!field?.ui?.progress?.progress) return; const progress = field.ui.progress; const percent = total > 0 ? (current / total) * 100 : 0; if (progress.fill) { progress.fill.style.width = `${percent}%`; } if (progress.text) { progress.text.textContent = message; } if (progress.count) { progress.count.textContent = `${current}/${total}`; } progress.progress.hidden = (current === total); } updateFieldStatus(fieldId, status) { const field = this.fields.get(fieldId); if (!field) return; field.state = status; // Update UI based on status } updateUploadStatus(uploadId, status) { const upload = this.uploads.get(uploadId); if (!upload) return; upload.status = status; this.updateUploadUI(uploadId); } updateUploadUI(uploadId) { const upload = this.uploads.get(uploadId); if (!upload?.element) return; // Update status classes upload.element.className = upload.element.className.replace(/status-[\w-]+/g, ''); upload.element.classList.add(`status-${upload.status}`); // Update progress if showing const progress = upload.element.querySelector('.progress'); if (progress) { this.updateUploadItemProgress(uploadId, this.getStatusProgress(upload.status), upload.status ); } } /** * Show/hide progress indicator on individual upload items */ showUploadProgress(uploadId, show = true) { const upload = this.uploads.get(uploadId); if (!upload || !upload.element) return; const progressEl = upload.element.querySelector('.progress'); if (progressEl) { if (show) { progressEl.style.removeProperty('animation'); progressEl.hidden = false; } else { progressEl.style.animation = 'fadeOut var(--transition-base)'; setTimeout(() => { progressEl.hidden = true; }, 300); } } } /** * Update individual upload progress bar */ updateUploadItemProgress(uploadId, percent, status = null) { const upload = this.uploads.get(uploadId); if (!upload || !upload.element) return; const progressEl = upload.element.querySelector('.progress'); if (!progressEl) return; const fill = progressEl.querySelector('.fill'); const details = progressEl.querySelector('.details'); const icon = progressEl.querySelector('.icon'); if (fill) { fill.style.width = `${percent}%`; } if (status && details) { details.textContent = this.getStatusText(status); } if (status && icon) { icon.innerHTML = this.getStatusIcon(status).outerHTML; } } checkFieldLimits(fieldId, additionalFiles) { const field = this.fields.get(fieldId); if (!field) return false; const currentCount = field.uploads?.size || 0; const totalCount = currentCount + additionalFiles; return totalCount <= field.maxFiles; } validateFile(file, field) { // Type validation if (!this.settings.allowedTypes.includes(file.type)) { this.notify(`Invalid file type: ${file.type}`, 'error'); return false; } // Size validation if (file.size > this.settings.maxFileSize) { this.notify(`File too large: ${this.formatBytes(file.size)}`, 'error'); return false; } return true; } formatBytes(bytes, decimals = 2) { if (bytes === 0) return '0 Bytes'; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } shouldProcessClientSide(file, subtype) { // Only process images client-side if (subtype === 'image' && file.type.startsWith('image/')) { return true; } // Videos and documents go straight to server return false; } async processImage(file, uploadId) { const timeout = this.worker.settings.timeout; return new Promise((resolve, reject) => { let timeoutId; let taskCompleted = false; // Set timeout timeoutId = setTimeout(() => { if (!taskCompleted) { taskCompleted = true; // Remove from active tasks this.worker.tasks.delete(uploadId); // Maybe restart worker if configured if (this.worker.settings.restartAfterTimeout) { this.restartCompressionWorker(); } reject(new Error(`Processing timeout for ${file.name}`)); } }, timeout); // Track this task this.worker.tasks.set(uploadId, { file, timeoutId }); // Process image this.handleProcess(file, uploadId) .then(result => { if (!taskCompleted) { taskCompleted = true; clearTimeout(timeoutId); this.worker.tasks.delete(uploadId); resolve(result); } }) .catch(error => { if (!taskCompleted) { taskCompleted = true; clearTimeout(timeoutId); this.worker.tasks.delete(uploadId); reject(error); } }); }); } async handleProcess(file, uploadId) { // Skip non-images if (!file.type.startsWith('image/')) { return file; } const maxDimension = this.getMaxDimension(); const quality = 0.85; // Try worker first if available if (this.shouldUseWorker(file)) { try { // Ensure worker is initialized if (!this.worker.worker) { this.initCompressionWorker(); } if (this.worker.worker) { return await this.processWithWorker(file, uploadId, maxDimension, quality); } } catch (error) { console.warn('Worker processing failed, falling back to main thread:', error); } } // Fallback to main thread return await this.processOnMainThread(file, maxDimension, quality); } /** * Process image on main thread with better error handling */ async processOnMainThread(file, maxDimension, quality) { return new Promise((resolve, reject) => { const img = new Image(); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); let objectUrl = null; const cleanup = () => { img.onload = null; img.onerror = null; if (objectUrl) { URL.revokeObjectURL(objectUrl); objectUrl = null; } // Explicitly clean up canvas canvas.width = 1; canvas.height = 1; ctx.clearRect(0, 0, 1, 1); }; img.onload = () => { try { const { width, height } = this.calculateOptimalDimensions(img, maxDimension); canvas.width = width; canvas.height = height; // Enhanced image smoothing ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(img, 0, 0, width, height); const outputFormat = this.getOptimalFormat(file); const outputQuality = this.getOptimalQuality(file, quality); canvas.toBlob( (blob) => { cleanup(); if (blob) { const processedFile = new File( [blob], this.getProcessedFileName(file, outputFormat), { type: outputFormat, lastModified: Date.now() } ); resolve(processedFile); } else { reject(new Error('Canvas toBlob failed')); } }, outputFormat, outputQuality ); } catch (error) { cleanup(); reject(new Error(`Canvas processing failed: ${error.message}`)); } }; img.onerror = () => { cleanup(); reject(new Error(`Failed to load image: ${file.name}`)); }; try { objectUrl = this.createPreviewUrl(file); img.src = objectUrl; } catch (error) { cleanup(); reject(new Error(`Failed to create object URL: ${error.message}`)); } }); } /** * Get optimal output format */ getOptimalFormat(file) { // Keep original format for certain types if (file.type === 'image/gif' || file.type === 'image/svg+xml') { return file.type; } // Use WebP if supported, otherwise JPEG return this.supportsWebP() ? 'image/webp' : 'image/jpeg'; } /** * Get optimal quality setting */ getOptimalQuality(file, requestedQuality) { // Higher quality for smaller files if (file.size < 500 * 1024) return Math.max(requestedQuality, 0.9); if (file.size < 2 * 1024 * 1024) return requestedQuality; // Lower quality for very large files return Math.min(requestedQuality, 0.8); } /** * Generate processed file name */ getProcessedFileName(originalFile, outputFormat) { const baseName = originalFile.name.replace(/\.[^/.]+$/, ''); const extensions = { 'image/webp': '.webp', 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif' }; return baseName + (extensions[outputFormat] || '.jpg'); } /** * Get maximum dimension based on device capabilities */ getMaxDimension() { const screenWidth = window.screen.width; const devicePixelRatio = window.devicePixelRatio || 1; // Scale based on device capabilities if (screenWidth * devicePixelRatio > 2560) return 2400; if (screenWidth * devicePixelRatio > 1920) return 1920; return 1200; } /** * Determine if we should use Web Worker */ shouldUseWorker(file) { // Use worker for large files or when available return this.worker.worker && file.size > 1024 * 1024 && // > 1MB typeof OffscreenCanvas !== 'undefined'; } async processWithWorker(file, uploadId, maxDimension, quality) { return new Promise((resolve, reject) => { if (!this.worker.worker) { reject(new Error('Worker not available')); return; } // Create unique message ID for this task const messageId = `${uploadId}_${Date.now()}`; // Handler for this specific message const messageHandler = (e) => { if (e.data.messageId !== messageId) return; // Remove handler this.worker.worker.removeEventListener('message', messageHandler); this.worker.worker.removeEventListener('error', errorHandler); if (e.data.success) { const processedFile = new File( [e.data.blob], this.getProcessedFileName(file, e.data.format || 'image/webp'), { type: e.data.format || 'image/webp', lastModified: Date.now() } ); resolve(processedFile); } else { reject(new Error(e.data.error || 'Worker processing failed')); } }; const errorHandler = (error) => { this.worker.worker.removeEventListener('message', messageHandler); this.worker.worker.removeEventListener('error', errorHandler); reject(new Error(`Worker error: ${error.message}`)); }; // Add handlers this.worker.worker.addEventListener('message', messageHandler); this.worker.worker.addEventListener('error', errorHandler); // Send message to worker this.worker.worker.postMessage({ messageId, file, maxDimension, quality, outputFormat: this.getOptimalFormat(file) }); }); } /** * Restart compression worker */ restartCompressionWorker() { // Terminate existing worker if (this.worker.worker) { this.worker.worker.terminate(); this.worker.worker = null; } // Clear active tasks this.worker.tasks.clear(); // Check restart limit if (this.worker.restart.count >= this.worker.restart.max) { console.error('Max worker restarts reached, disabling worker'); return; } this.worker.restart.count++; // Reinitialize this.initCompressionWorker(); } /** * Initialize Web Worker for image compression */ initCompressionWorker() { if (this.worker.worker || typeof Worker === 'undefined') return; try { const workerScript = ` self.onmessage = async function(e) { const { messageId, file, maxDimension, quality, outputFormat } = e.data; try { // Create ImageBitmap from file const bitmap = await createImageBitmap(file); // Calculate dimensions const scale = Math.min(maxDimension / bitmap.width, maxDimension / bitmap.height, 1); const width = Math.round(bitmap.width * scale); const height = Math.round(bitmap.height * scale); // Create OffscreenCanvas const canvas = new OffscreenCanvas(width, height); const ctx = canvas.getContext('2d'); // Draw and resize ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(bitmap, 0, 0, width, height); // Clean up bitmap bitmap.close(); // Convert to blob const blob = await canvas.convertToBlob({ type: outputFormat, quality: quality }); self.postMessage({ messageId, success: true, blob: blob, format: outputFormat }); } catch (error) { self.postMessage({ messageId, success: false, error: error.message }); } }; `; const blob = new Blob([workerScript], { type: 'application/javascript' }); this.worker.worker = new Worker(this.createPreviewUrl(blob)); } catch (error) { console.warn('Failed to initialize compression worker:', error); this.worker.worker = null; } } /** * Calculate optimal dimensions with aspect ratio preservation */ calculateOptimalDimensions(img, maxDimension) { let { width, height } = img; // Don't upscale if (width <= maxDimension && height <= maxDimension) { return { width, height }; } // Calculate scale factor const scale = Math.min(maxDimension / width, maxDimension / height); return { width: Math.round(width * scale), height: Math.round(height * scale) }; } /** * Check WebP support */ supportsWebP() { const canvas = document.createElement('canvas'); return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0; } createPreviewUrl(file) { const url = URL.createObjectURL(file); // Track for cleanup if (!this.previewUrls) this.previewUrls = new Set(); this.previewUrls.add(url); return url; } revokePreviewUrl(url) { if (url?.startsWith('blob:')) { URL.revokeObjectURL(url); this.previewUrls?.delete(url); } } maybeLockUploads(fieldId) { const field = this.fields.get(fieldId); if (!field?.ui?.dropZone) return; if (field.config.destination === 'post_group') { return; } const uploadCount = field.uploads?.size || 0; const maxFiles = field.config?.maxFiles || 999; // Hide dropzone if at max files field.ui.dropZone.hidden = uploadCount >= maxFiles; // Update field state field.element.classList.toggle('at-max-uploads', uploadCount >= maxFiles); } createUploadElement(upload, draggable = false) { let image = window.getTemplate('uploadItem'); if (!image) { console.error('Image template not found'); return; } image.dataset.uploadId = upload.id; if (upload.originalFile) { image.dataset.subtype = this.getSubtypeFromMime(upload.originalFile.type); } image.querySelector('[name="featured"]').value = upload.id; let [ featured, img, video, preview, details ] = [ image.querySelector('[name="featured"]'), image.querySelector('img'), image.querySelector('video'), image.querySelector('label > span'), image.querySelector('details') ]; [ featured.value, img.src, img.alt ] = [ upload.id, upload.preview, upload.originalFile?.name ?? upload.meta?.originalName ?? '', ]; switch (image.dataset.subtype) { case 'image': [ img.src, img.alt ] = [ upload.preview, upload.originalFile?.name ?? upload.meta?.originalName?? '' ]; video.remove(); preview.remove(); break; case 'video': video.src = upload.preview; img.remove(); preview.remove(); break; case 'document': const fileName = upload.originalFile?.name ?? upload.meta?.originalName ?? ''; const extension = fileName.split('.').pop()?.toLowerCase() ?? ''; const iconMap = { 'pdf': 'file-pdf', 'csv': 'file-csv', 'doc': 'file-doc', 'docx': 'file-doc', 'txt': 'file-txt', 'xls': 'file-xls', 'xlsx': 'file-xls' }; const icon = window.getIcon(iconMap[extension] || 'file'); preview.innerText = upload.originalFile.name; preview.prepend(icon); img.remove(); video.remove(); break; } if (details) { let template = window.getTemplate('uploadMeta'); if (template){ details.append(template); } } image.draggable = draggable; // Update input IDs safely image.querySelectorAll('input').forEach(input => { let id = input.id; if (id) { let newId = id + upload.id; let label = input.parentNode.querySelector(`label[for="${id}"]`); input.id = newId; if (label) { label.htmlFor = newId; } } }); return image; } /******************************************************************************* * QUEUE INTEGRATION *******************************************************************************/ async submitUploads(fieldId) { const field = this.fields.get(fieldId); if (!field?.uploads || field.uploads.size === 0) { return; } let uploads = Array.from(field.uploads); if (uploads.length === 0) { this.error.log('No uploads to upload', { component: 'UploadManager', action: 'submitGroupedUploads', fieldId: fieldId }); return; } const fieldGroups = this.getFieldGroups(fieldId); if (fieldGroups.length === 0) { this.error.log('No groups created for post_group upload', { component: 'UploadManager', action: 'submitGroupedUploads', fieldId: fieldId }); return; } // Build posts array from groups const posts = []; const formData = new FormData(); let uploadMap = []; uploads = uploads.map((upload) => { return this.uploads.get(upload); }); fieldGroups.forEach((group, groupIndex) => { const post = { images: [], fields: {} }; for (let [name, value] of Object.entries(group.changes)) { post.fields[name] = value; } let groupUploads = uploads.filter((upload) => { return upload['groupId'] === group.id; }); groupUploads.forEach((upload) => { if (upload) { const fileToUpload = upload.processedFile || upload.originalFile; if (fileToUpload) { formData.append('files[]', fileToUpload); const imageData = { upload_id: upload.id, index: uploadMap.length }; post.images.push(imageData); uploadMap.push(upload.id); } } }); // Add images for this group // group.uploads.forEach(uploadId => { // const upload = this.uploads.get(uploadId); // if (upload) { // const fileToUpload = upload.processedFile || upload.originalFile; // if (fileToUpload) { // formData.append('files[]', fileToUpload); // // const imageData = { // upload_id: upload.id, // index: uploadMap.length // }; // // // Check if this is the featured image // const radioInput = upload.element?.querySelector('[name="featured"]'); // if (radioInput?.checked) { // post.fields.featured = upload.id; // } // // post.images.push(imageData); // uploadMap.push(upload.id); // } // } // }); posts.push(post); }); //Each remaining upload (without a groupId) becomes its own post let remainingUploads = uploads.filter((upload) => { return !Object.hasOwn(upload, 'groupId'); }); remainingUploads.forEach((upload) => { if (upload) { const post = { images: [], fields: {} }; const fileToUpload = upload.processedFile || upload.originalFile; if (fileToUpload) { formData.append('files[]', fileToUpload); const imageData = { upload_id: upload.id, index: uploadMap.length }; post.images.push(imageData); uploadMap.push(upload.id); } posts.push(post); } }); // Add metadata to FormData formData.append('content', field.config.content); formData.append('user', field.config.itemID); // Assuming itemID is user ID formData.append('posts', JSON.stringify(posts)); formData.append('upload_ids', JSON.stringify(uploadMap)); const operation = { endpoint: 'uploads/groups', method: 'POST', data: formData, title: `Creating ${posts.length} ${field.config.content}${posts.length > 1 ? 's' : ''} from uploads...`, popup: `Creating ${posts.length} post${posts.length > 1 ? 's' : ''}...`, canMerge: false, headers: { 'action_nonce': jvbSettings.dash }, append: '_upload', }; try { const operationId = await this.queue.addToQueue(operation); uploads.forEach(uploadId => { let upload = this.uploads.get(uploadId); if (upload) { upload.operationId = operationId; this.updateUploadStatus(uploadId, 'queued'); } }); field.operationId = operationId; this.a11y.announce(`Creating ${posts.length} post${posts.length > 1 ? 's' : ''} from your uploads`); return operationId; } catch (error) { this.error.log(error, { component: 'UploadManager', action: 'submitGroupedUploads', fieldId: fieldId }); throw error; } finally { this.schedulePersistance(field.id); } } async queueUpload(fieldId) { const field = this.fields.get(fieldId); if (!field?.uploads) return; const uploads = Array.from(field.uploads); if (uploads.length === 0) { return; } const data = this.prepareUploadData(field, uploads); this.a11y.announce('Queuing for upload'); let img = (uploads.length === 1) ? 'file' : 'files'; const operation = { endpoint: 'uploads', method: 'POST', data: data, title: `Uploading ${uploads.length} ${img} to server...`, popup: `Uploading ${uploads.length} ${img}...`, canMerge: false, headers: { 'action_nonce': jvbSettings.dash }, append: '_upload' } try { const operationId = await this.queue.addToQueue(operation); uploads.forEach(uploadId => { let upload = this.uploads.get(uploadId); if (!upload) { return; } upload.operationId = operationId; this.updateUploadStatus(uploadId, 'queued'); }); field.operationId = operationId; return operationId; } catch (error) { throw error; } finally { this.schedulePersistance(field.id); } } prepareUploadData(field, uploads) { const formData = new FormData(); formData.append('content', field.config.content); formData.append('mode', field.config.mode); formData.append('field_name', field.config.name); formData.append('fieldId', field.id); formData.append('field_type', field.config.type); formData.append('subtype', field.config.subtype); formData.append('item_id', field.config.itemID); //post, term, or user id formData.append('destination', field.config.destination || 'meta'); //meta, post, post_group let uploadMap = []; const fieldGroups = this.getFieldGroups(field.id); if (field.config.destination === 'post_group' && fieldGroups.length > 0) { // User has created groups let groups = []; let titles = []; let featuredImages = []; fieldGroups.forEach(group => { let groupUploadIndices = []; let featuredIndex = null; group.uploads.forEach(uploadId => { let upload = this.uploads.get(uploadId); if (upload) { const fileToUpload = upload.processedFile || upload.originalFile; if (fileToUpload) { formData.append('files[]', fileToUpload); const fileIndex = uploadMap.length; uploadMap.push(upload.id); groupUploadIndices.push(upload.id); // Check if this is the featured image const radioInput = upload.element?.querySelector('[name="featured"]'); if (radioInput?.checked) { featuredIndex = upload.id; } } } }); groups.push(groupUploadIndices); titles.push(group.title || ''); featuredImages.push(featuredIndex); }); formData.append('groups', JSON.stringify(groups)); formData.append('group_titles', JSON.stringify(titles)); formData.append('featured_images', JSON.stringify(featuredImages)); } else { // No groups - just append all files uploads.forEach(uploadId => { let upload = this.uploads.get(uploadId); if (upload) { const fileToUpload = upload.processedFile || upload.originalFile; if (fileToUpload) { formData.append('files[]', fileToUpload); uploadMap.push(upload.id); } } }); } formData.append('upload_ids', JSON.stringify(uploadMap)); // console.log('Final FormData:'); // for (let pair of formData.entries()) { // console.log(pair[0], pair[1]); // } return formData; } getFieldGroups(fieldId) { const groups = []; this.groups.forEach((groupData, groupId) => { if (groupData.fieldId === fieldId) { const field = this.fields.get(fieldId); const groupElement = field?.ui?.groups?.groups?.get(groupId); groups.push({ id: groupId, uploads: Array.from(groupData.uploads || new Set()), changes: groupData.changes || {}, element: groupElement || null }); } }); return groups; } async queueUploadMeta(e) { const upload = this.getUploadFromElement(e.target); if (!upload) return; const field = this.fields.get(upload.fieldId); if (!field) return; const container = e.target.closest('.upload-meta'); if (!container) return; let data = {}; data[e.target.name] = e.target.value; upload.meta = { ...upload.meta, ... data }; let queueData = {}; //If there is an attachment ID, use that: else, use our generated upload id queueData[upload.attachmentId??upload.id] = upload.meta; const operation = { endpoint: 'uploads/meta', method: 'POST', data: queueData, title: `Updating meta`, canMerge: true, headers: { 'action_nonce': jvbSettings.dash } }; try { await this.queue.addToQueue(operation); } catch (error) { this.error.log(error, { component: 'UploadManager', action: 'sendMetaUpdate', uploadId: upload.id }); } } /******************************************************************************* * GROUP MANAGEMENT *******************************************************************************/ createGroup(fieldKey, groupId = null) { const field = this.fields.get(fieldKey); if (!field) { console.error('Field not found:', fieldKey); return null; } if (!groupId) { groupId = `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } const groupElement = this.createGroupElement(groupId, fieldKey); if (!groupElement) { console.error('Failed to create group element'); return null; } // Store in field UI Map if (!field.ui.groups) { field.ui.groups = { groups: new Map(), container: null, empty: null, display: null }; } field.ui.groups.groups.set(groupId, groupElement); // Insert into DOM if (field.ui.groups.container && field.ui.groups.empty) { field.ui.groups.container.insertBefore(groupElement, field.ui.groups.empty); } else if (field.ui.groups.container) { field.ui.groups.container.appendChild(groupElement); } // Create group object const group = { id: groupId, fieldId: fieldKey, element: groupElement, grid: groupElement.querySelector('.item-grid.group'), uploads: new Set(), changes: {} }; // Store group this.groups.set(groupId, group); // Initialize selection handler for this group this.addGroupSelectionHandler(fieldKey, groupId); // Persist state this.schedulePersistance(fieldKey); return group; } createGroupElement(groupId, fieldId) { let groupElement = window.getTemplate('imageGroup'); if (!groupElement) return; groupElement.dataset.groupId = groupId; groupElement.dataset.fieldId = fieldId; let fields = window.getTemplate('groupMetadata'); const fieldsContainer = groupElement.querySelector('.fields'); if (fieldsContainer && fields) { fieldsContainer.append(fields); // Set unique IDs and names for form fields const titleInput = fieldsContainer.querySelector('[name="post_title"]'); const excerptInput = fieldsContainer.querySelector('[name="post_excerpt"]'); if (titleInput) { titleInput.id = `${groupId}_title`; titleInput.name = `${groupId}[post_title]`; } if (excerptInput) { excerptInput.id = `${groupId}_excerpt`; excerptInput.name = `${groupId}[post_excerpt]`; } let field = this.fields.get(fieldId); if (field.config.content !== '') { let summary = groupElement.querySelector('summary'); summary.textContent = field.config.content + ' Fields'; } } else { groupElement.querySelector('details').remove(); } const gridContainer = groupElement.querySelector('.item-grid.group'); if (gridContainer) { gridContainer.dataset.groupId = groupId; } return groupElement; } deleteGroup(groupId, confirm = true) { let group = this.groups.get(groupId); if (!group) { return; } let keepUploads = true; if (confirm && group.uploads && group.uploads.size > 0) { keepUploads = !window.confirm('Delete uploads in group?'); } if (confirm && keepUploads) { // Move any remaining uploads back to preview if (group.uploads && group.uploads.size > 0) { Array.from(group.uploads).forEach(uploadId => { this.addImageToGroup(uploadId, null, false); }); } } // Remove from groups Map this.groups.delete(groupId); // Remove DOM element let groupElement = group.element; if (groupElement) { groupElement.remove(); this.a11y.announce('Group removed'); } this.schedulePersistance(group.fieldId); } addToGroup(uploadId, target = null, persist = true) { let upload = this.uploads.get(uploadId); if(!upload) { return; } let field = this.fields.get(upload.fieldId); if (!field) { return; } //Already in the Preview Grid, or already in the group we're moving to if ((!target && upload.location === field.ui.preview) || target === upload.location) { return; } // Remove from previous location if (upload.location) { let groupId = upload.location.dataset.groupId; if (groupId) { let group = this.groups.get(groupId); if (group && group.uploads) { group.uploads.delete(uploadId); if (group.uploads.size === 0) { this.deleteGroup(groupId); } } } } const checkbox = upload.element.querySelector('[name*="select-item"]'); if (checkbox) { checkbox.checked = false; } let featured = upload.element.querySelector('[name="featured"]'); featured.hidden = !target; //If no target, it's going to the preview grid if (!target) { target = field.ui.preview; upload.groupId = null; } else if (!target.classList.contains('item-grid') || !target.classList.contains('preview')) { // It's a group target let groupId = target.dataset.groupId; featured.name = groupId+'_'+featured.name; let group = this.groups.get(groupId); if (!group) { group = this.createGroup(upload.fieldId); target = group.grid; groupId = group.id; } if (group) { group.uploads.add(uploadId); upload.groupId = groupId; } } upload.location = target; target.append(upload.element); if (persist) { this.schedulePersistance(field.id); } } removeFromGroup(uploadId) { const upload = this.uploads.get(uploadId); if (!upload) return; const field = this.fields.get(upload.fieldId); if (!field) return; // Remove from current group if in one if (upload.groupId) { const group = this.groups.get(upload.groupId); if (group?.uploads) { group.uploads.delete(uploadId); // Delete empty group if (group.uploads.size === 0) { this.deleteGroup(upload.groupId, false); } } upload.groupId = null; } // Move back to preview if (field.ui?.preview) { field.ui.preview.appendChild(upload.element); upload.location = field.ui.preview; } // Hide featured radio const featured = upload.element.querySelector('[name="featured"]'); if (featured) { featured.hidden = true; featured.checked = false; } } removeUpload(fieldId, uploadId) { const field = this.fields.get(fieldId); const upload = this.uploads.get(uploadId); if (!field || !upload) return; // Remove from field field.uploads?.delete(uploadId); // Remove from group if grouped if (upload.groupId) { const group = this.groups.get(upload.groupId); if (group && group.uploads) { group.uploads.delete(uploadId); if (group.uploads.size === 0) { this.removeGroup(upload.groupId); } } } // Clean up element upload.element?.remove(); // Clean up memory this.clearUpload(uploadId); // Update field state after removal this.updateFieldState(fieldId); // Update UI this.maybeLockUploads(fieldId); const handler = this.selectionHandlers.get(field.id); if (handler) { handler.deselect(uploadId); } this.a11y.announce('Upload removed'); } /******************************************************************************* * STATE MANAGEMENT *******************************************************************************/ schedulePersistance(fieldId) { const key = `persist_${fieldId}`; window.debouncer.schedule( key, () => this.persistFieldState(fieldId), 1000 ); } async persistFieldState(fieldId) { const field = this.fields.get(fieldId); if (!field) return; // Convert Sets to Arrays for storage const fieldData = { ...field, id: fieldId, // Use as primary key fieldId: fieldId, uploads: Array.from(field.uploads || []).map(uploadId => { return this.uploads.get(uploadId);; }), groups: Array.from(this.groups.entries()) .filter(([id, data]) => data.fieldId === fieldId && data.uploads && data.uploads.size > 0) .map(([id, data]) => ({ id: data.id, uploads: Array.from(data.uploads), changes: data.changes || {} })), // Context for restoration context: { url: this.normalizeUrl(window.location.href), fullUrl: window.location.href, modalType: this.getModalType(field), formId: field.formId, fieldSelector: `.field.upload[data-field="${field.config.name}"]` }, timestamp: Date.now() }; // Save to store await this.fieldStore.save(fieldData); } normalizeUrl(url) { try { const urlObj = new URL(url); // Return just the origin + pathname (no query string or hash) return urlObj.origin + urlObj.pathname; } catch (e) { return url; } } /** * Get uploads for a field, optionally cleaned for storage * @param {string} fieldId * @param {boolean} clean - Remove DOM references for IndexedDB storage * @returns {Array} */ getFieldUploads(fieldId, clean = false) { const field = this.fields.get(fieldId); if (!field || !field.uploads) return []; return Array.from(field.uploads) .map(uploadId => { const upload = this.uploads.get(uploadId); if (!upload) return null; if (clean) { // Return cleaned version without DOM references or blob URLs return { id: upload.id, fieldId: upload.fieldId, status: upload.status, // DON'T include preview (blob URL) // DON'T include originalFile or processedFile (in blob storage) attachmentId: upload.attachmentId, operationId: upload.operationId, groupId: upload.groupId || null, changes: upload.changes || {}, // ← ADD: Include changes meta: { originalName: upload.meta?.originalName || upload.originalFile?.name, size: upload.meta?.size || upload.originalFile?.size, type: upload.meta?.type || upload.originalFile?.type, title: upload.meta?.title, alt: upload.meta?.alt, caption: upload.meta?.caption } }; } // Return full upload object return upload; }) .filter(Boolean); } async checkForStoredUploads() { if (!this.db) return; const tx = this.db.transaction(['fieldStates'], 'readonly'); const fieldStore = tx.objectStore('fieldStates'); const allFieldStates = await new Promise(resolve => { const request = fieldStore.getAll(); request.onsuccess = () => resolve(request.result); }); // // allFieldStates.forEach(field => { // console.log(`Field ${field.fieldId} has ${field.uploads.length} uploads:`); // field.uploads.forEach((upload, idx) => { // console.log(` Upload ${idx}:`, { // id: upload.id, // status: upload.status, // operationId: upload.operationId, // hasOperationId: !!upload.operationId // }); // }); // }); // Filter for pending uploads (not yet sent to server) const pendingFields = allFieldStates.filter(field => field.uploads.some(upload => // If no operationId, it hasn't been sent to server yet !upload.operationId && // And it's been processed locally (upload.status === 'completed' || upload.status === 'processed' || upload.status === 'local_processing' || upload.status === 'processed-original') ) ); if (pendingFields.length === 0) return; // Show recovery notification this.showRecoveryNotification(pendingFields); } async handleRestoreUploads() { let notification = document.querySelector('dialog.restore-uploads'); if (!notification) { return; } const selectedUploads = this.getSelectedRestorationUploads(notification); if (selectedUploads.length === 0) { return; } await this.restoreSelectedUploads(selectedUploads); this.cleanupRestore(); } getSelectedRestorationUploads(notificationEl) { let selected = []; const checkboxes = notificationEl.querySelectorAll('[type=checkbox]:checked'); checkboxes.forEach(checkbox => { const item = checkbox.closest('.item'); if (item) { selected.push({ uploadId: item.dataset.uploadId, fieldId: item.dataset.fieldId }); } }); return selected; } handleGroupMetaChange(input) { let group = this.getGroupFromElement(input); if (!group) { return; } if (!Object.hasOwn(group, 'changes')) { group.changes = {}; } let name = input.name; if (name.includes('group')) { let replace = group.id+'_'; let replace2 = group.id+'['; name = name.replace(replace, '').replace(replace2,'').replace(']', ''); } group.changes[`${name}`] = input.value; this.groups.set(group.id, group); this.schedulePersistance(group.fieldId); } /******************************************************************************* * RESTORING UPLOADS *******************************************************************************/ async showRecoveryNotification(pendingFields) { const totalUploads = pendingFields.reduce((sum, field) => sum + field.uploads.length, 0); const totalGroups = pendingFields.reduce((sum, field) => sum + (field.groups?.length || 0), 0); let notification = window.getTemplate('restoreNotification'); if (!notification) { console.error('Restore notification template not found'); return; } // Build appropriate message let message; if (totalGroups > 0) { let group = totalGroups > 1 ? 'groups' : 'group'; let upload = totalUploads > 1 ? 'uploads' : 'upload'; message = `${totalGroups} ${group} with ${totalUploads} ${upload} can be restored.`; } else { message = `${totalUploads} upload(s) from ${pendingFields.length} field(s) can be recovered.`; } const detailsEl = notification.querySelector('.restore-details'); if (detailsEl) { detailsEl.textContent = message; } // Build the restoration preview for (const field of pendingFields) { let fieldTemplate = window.getTemplate('restoreField'); if (!fieldTemplate) continue; // Set field name/title const titleEl = fieldTemplate.querySelector('h3'); if (titleEl) { titleEl.textContent = field.config.name || 'Unnamed Field'; } const itemGrid = fieldTemplate.querySelector('.item-grid.restore'); // Process each upload for (const upload of field.uploads) { let uploadItem = window.getTemplate('uploadItem'); if (!uploadItem) continue; // // const imgEl = uploadItem.querySelector('img'); // const placeholderEl = uploadItem.querySelector('.image-placeholder'); // const blobData = await this.uploadStore.getBlob(upload.id); if (blobData) { try { // Create new blob URL from stored data const blob = new Blob([blobData.data], { type: blobData.type }); const previewUrl = this.createPreviewUrl(blob); let [ featured, img, video, preview, details ] = [ uploadItem.querySelector('[name="featured"]'), uploadItem.querySelector('img'), uploadItem.querySelector('video'), uploadItem.querySelector('label > span'), uploadItem.querySelector('details') ]; uploadItem.dataset.uploadId = upload.id; uploadItem.dataset.fieldId = field.id; let subtype = this.getSubtypeFromMime(blobData.type); uploadItem.dataset.subtype = subtype; switch (subtype) { case 'image': [ img.src, img.alt ] = [ previewUrl, upload.originalFile?.name ?? upload.meta?.originalName?? '' ]; video.remove(); preview.remove(); break; case 'video': video.src = previewUrl; img.remove(); preview.remove(); break; case 'document': let extension = ''; let icon; switch (extension) { case 'pdf': icon = window.getIcon('file-pdf'); break; case 'csv': icon = window.getIcon('file-csv'); break; case 'doc': icon = window.getIcon('file-doc'); break; case 'txt': icon = window.getIcon('file-txt'); break; case 'xls': icon = window.getIcon('file-xls'); break; default: icon = window.getIcon('file'); break; } preview.innerText = upload.originalFile.name; preview.prepend(icon); img.remove(); video.remove(); break; } // Store URL for cleanup later uploadItem.dataset.previewUrl = previewUrl; } catch (error) { console.warn('Failed to create preview for upload:', upload.id, error); } } // Set upload metadata const nameEl = uploadItem.querySelector('summary span'); if (nameEl) { nameEl.textContent = upload.meta?.originalName || 'Unknown file'; } const metaEl = uploadItem.querySelector('details'); if (metaEl && upload.meta) { metaEl.textContent = `${this.formatBytes(upload.meta.size)} • ${upload.meta.type}`; } // Update input IDs safely uploadItem.querySelectorAll('input').forEach(input => { let id = input.id; if (id) { let newId = id + upload.id; let label = input.parentNode.querySelector(`label[for="${id}"]`); input.id = newId; if (label) { label.htmlFor = newId; } } }); if (itemGrid) { itemGrid.appendChild(uploadItem); } } notification.querySelector('.wrap').appendChild(itemGrid); } document.querySelector('.field.upload').appendChild(notification); notification = document.querySelector('dialog.restore-uploads'); this.restoreModal = new window.jvbModal(notification); this.restoreSelection = new window.jvbHandleSelection({ container: notification, ui: { selectAll: notification.querySelector('#select-all-restore'), count: notification.querySelector('.selection-count'), }, }); this.restoreModal.handleOpen(); } async restoreSelectedUploads(selectedUploads) { // Group by field const byField = new Map(); selectedUploads.forEach(item => { if (!byField.has(item.fieldId)) { byField.set(item.fieldId, []); } byField.get(item.fieldId).push(item.uploadId); }); // Get full field states from IndexedDB if (!this.db) { // this.notifications.add('Cannot restore: Database not available', 'error'); return; } const tx = this.db.transaction(['fieldStates'], 'readonly'); const store = tx.objectStore('fieldStates'); for (const [fieldId, uploadIds] of byField.entries()) { const request = store.get(fieldId); const fieldState = await new Promise(resolve => { request.onsuccess = () => resolve(request.result); request.onerror = () => resolve(null); }); if (fieldState) { // Filter to only selected uploads fieldState.uploads = fieldState.uploads.filter(u => uploadIds.includes(u.id)); await this.restoreField(fieldState); } } // this.notifications.add(`Restored ${selectedUploads.length} upload(s)`, 'success'); } async restoreField(fieldState) { const { config, context, uploads, groups, id } = fieldState; // ← Use 'id' // If in a modal, open it first if (context.modalType) { await this.openModalForRestore(context); } // Find field element let fieldElement = document.querySelector(`.field.upload[data-field="${config.name}"]`); if (!fieldElement) { const uploaderKey = `${config.content}_${config.itemID}_${config.name}`; fieldElement = document.querySelector(`.field.upload[data-uploader="${uploaderKey}"]`); } if (!fieldElement) { console.warn(`Field ${config.name} not found for restoration`, config); return; } // Register the field if not already registered let fieldKey = fieldElement.dataset.uploader; if (!fieldKey || !this.fields.has(fieldKey)) { fieldKey = this.registerUploader(fieldElement, config); } const field = this.fields.get(fieldKey); if (!field) { console.error('Failed to register field for restoration'); return; } // Merge saved state back into field field.state = fieldState.state || 'ready'; // Rebuild UI references field.ui = this.buildFieldUI(fieldElement); if (field.ui.groups?.display) { field.ui.groups.display.hidden = false; } // Restore groups if (groups && groups.length > 0) { await this.restoreGroups(fieldKey, groups); } // Restore uploads for (const uploadData of uploads) { await this.restoreUpload(field, uploadData); } // Update UI this.updateFieldState(fieldKey); this.maybeLockUploads(fieldKey); // Queue for upload if needed if (config.mode === 'direct' && config.destination !== 'post_group') { await this.queueUpload(fieldKey); } } async restoreUpload(field, uploadData) { // Try to get blob data from IndexedDB const blobData = await this.uploadStore.getBlob(uploadData.id); if (blobData) { const file = blobData.data instanceof File ? blobData.data : new File( [blobData.data], blobData.name, { type: blobData.type, lastModified: blobData.lastModified } ); uploadData.originalFile = file; uploadData.processedFile = file; uploadData.preview = this.createPreviewUrl(file); } else { console.warn('Blob data not found for upload:', uploadData.id); return; // Skip this upload if we can't restore the file } // Add to field if (!field.uploads) field.uploads = new Set(); field.uploads.add(uploadData.id); // Recreate DOM element const subtype = this.getSubtypeFromMime(uploadData.originalFile.type); uploadData.element = this.createUploadElement({ ...uploadData, subtype: subtype }, field.config.destination === 'post_group'); // Restore to correct location let location; if (uploadData.groupId && field.ui.groups.groups.has(uploadData.groupId)) { location = field.ui.groups.groups.get(uploadData.groupId).querySelector('.item-grid'); } else { location = field.ui.preview; } if (location) { location.appendChild(uploadData.element); uploadData.location = location; } // Store in memory this.uploads.set(uploadData.id, uploadData); if (uploadData.groupId) { const group = this.groups.get(uploadData.groupId); if (group && group.uploads) { group.uploads.add(uploadData.id); } } } async restoreGroups(fieldKey, groups) { for (const groupData of groups) { // Use createGroup which properly initializes EVERYTHING including selection handlers const group = this.createGroup(fieldKey, groupData.id); if (group) { // Update the group metadata from saved state if (groupData.meta) { group.meta = { ...groupData.meta }; } if (groupData.changes) { group.changes = { ...groupData.changes }; } // If you saved group titles, restore them if (groupData.title) { const titleInput = group.element.querySelector('[name*="post_title"]'); if (titleInput) { titleInput.value = groupData.title; } } } } } async openModalForRestore(context) { const { modalType, formId } = context; // Find and click the appropriate button to open the modal let trigger = null; switch(modalType) { case 'create': trigger = document.querySelector('[data-action="create"]'); break; case 'edit': // Need to find the specific edit button trigger = document.querySelector(`[data-action="edit"][data-id="${context.itemId}"]`); break; case 'bulkEdit': trigger = document.querySelector('[data-action="bulk-edit"]'); break; } if (trigger) { trigger.click(); // Wait for modal to open await new Promise(resolve => setTimeout(resolve, 300)); } } /******************************************************************************* INDEXEDDB CACHE FUNCTIONALITY *******************************************************************************/ handleFieldStoreEvent(event, data) { switch(event) { case 'data-loaded': break; case 'item-saved': console.log(`Field state saved: ${data.key}`); break; } } handleUploadStoreEvent(event, data) { switch(event) { case 'data-loaded': this.checkForStoredUploads(); break; case 'item-saved': this.showSaveIndicator(data.key); break; } } async saveUpload(upload) { // Handle blob data separately if (upload.file instanceof File || upload.file instanceof Blob) { await this.uploadStore.saveBlob(upload.id, upload.file); // Don't store the file in the main store const { file, originalFile, ...cleanUpload } = upload; await this.uploadStore.save(cleanUpload); } else { await this.uploadStore.save(upload); } } async loadFields() { // Load all field states from the store const fields = await this.fieldStore.getAll(); fields.forEach(field => { // Reconstruct upload sets if (field.uploads && Array.isArray(field.uploads)) { field.uploads = new Set(field.uploads.map(u => u.id)); } this.fields.set(field.fieldId, field); }); } async loadUploads() { const uploads = await this.uploadStore.getAll(); uploads.forEach(upload => { this.uploads.set(upload.id, upload); }); } /************************************************************************** SUBSCRIBERS **************************************************************************/ /** * Event system */ subscribe(callback) { this.subscribers.add(callback); return () => this.subscribers.delete(callback); } notify(event, data) { this.subscribers.forEach(cb => cb(event, data)); } /******************************************************************************* * CLEANUP *******************************************************************************/ destroy() { // Remove core listeners document.removeEventListener('click', this.clickHandler); document.removeEventListener('change', this.changeHandler); document.removeEventListener('dragenter', this.dragEnterHandler); document.removeEventListener('dragleave', this.dragLeaveHandler); document.removeEventListener('dragover', this.dragOverHandler); document.removeEventListener('drop', this.dropHandler); // Destroy drag controller if (this.dragController) { this.dragController.destroy(); } // Destroy selection handlers this.selectionHandlers.forEach(handler => handler.destroy()); this.selectionHandlers.clear(); this.cleanupAllPreviewUrls(); // Clear data this.fields.clear(); this.uploads.clear(); this.groups.clear(); this.selected.clear(); this.subscribers.clear(); } cleanupRestore() { this.restoreModal.handleClose(); this.restoreSelection.destroy(); this.restoreSelection = null; this.restoreModal.destroy(); this.restoreModal.modal.remove(); this.restoreModal = null; } async cleanupStoredUploads() { this.fieldStore.clear(); this.uploadStore.clear(); } /** * Clear all uploads for a field and cleanup resources */ async clearField(fieldId) { // Clear from stores await this.fieldStore.delete(fieldId); // Clear related uploads const field = this.fields.get(fieldId); if (field?.uploads) { for (const uploadId of field.uploads) { await this.uploadStore.delete(uploadId); } } // Clear from memory this.fields.delete(fieldId); } async clearUpload(uploadId, persist = true) { const upload = this.uploads.get(uploadId); if (!upload) return; // Clean up preview URL using helper this.revokePreviewUrl(upload.preview); // Clean up element preview URL if (upload.element) { const previewUrl = upload.element.dataset.previewUrl; this.revokePreviewUrl(previewUrl); delete upload.element.dataset.previewUrl; } if (persist) { await this.schedulePersistance(upload.fieldId); } // Remove from memory this.uploads.delete(uploadId); // Remove from IndexedDB this.uploadStore.delete(uploadId); this.uploadStore.delete(uploadId, 'blobs'); } cleanupAllPreviewUrls() { if (this.previewUrls) { this.previewUrls.forEach(url => { try { URL.revokeObjectURL(url); } catch (e) { // Ignore errors during cleanup } }); this.previewUrls.clear(); } } } // Initialize when DOM is ready document.addEventListener('DOMContentLoaded', () => { window.jvbUploads = new UploadManager(); });