/** * UploadManager - Refactored for clarity * * Architecture: * - DataStores (fieldStore, uploadStore) = Recovery cache only, cleared after successful upload * - Maps (uploadElements, fieldElements) = Runtime DOM references * - Upload data flows: File → Process → Queue → Server → Clean up stores */ class UploadManager { constructor() { // Load dependencies this.queue = window.jvbQueue; this.a11y = window.jvbA11y; this.error = window.jvbError; this.fieldStoreReady = false; this.uploadStoreReady = false; this.hasCheckedForUploads = false; const {fields, uploads} = window.jvbStore.register( 'uploads', [ { storeName: 'fields', keyPath: 'id', indexes: [ { name: 'fieldId', keyPath: 'fieldId' }, { name: 'timestamp', keyPath: 'timestamp' }, { name: 'content', keyPath: 'content' }, { name: 'itemId', keyPath: 'itemId' }, { name: 'status', keyPath: 'status' } ], TTL: 7 * 24 * 60 * 60 * 1000, // 1 week delayFetch: true }, { storeName: 'uploads', keyPath: 'id', storeBlobs: true, indexes: [ { name: 'fieldId', keyPath: 'fieldId' }, { name: 'status', keyPath: 'status' }, { name: 'groupId', keyPath: 'groupId' }, { name: 'attachmentId', keyPath: 'attachmentId' } ], delayFetch: true } ] ); this.fieldStore = fields; this.uploadStore = uploads; window.jvbUploadBlobs = this.uploadStore; // Subscribe to store events this.fieldStore.subscribe(this.handleFieldStoreEvent.bind(this)); this.uploadStore.subscribe(this.handleUploadStoreEvent.bind(this)); // RUNTIME DATA - DOM references and ephemeral state this.uploadElements = new Map(); // uploadId → { element, preview, location } this.fieldElements = new Map(); // fieldId → { element, ui, config } this.groupElements = new Map(); // groupId → { element, grid, fieldId } // Selection and UI state this.selected = new Map(); this.selectionHandlers = new Map(); this.previewUrls = new Set(); this.sortableInstances = new Map(); // Worker for image processing this.initWorker(); // Notification subscribers this.subscribers = new Set(); // Selectors this.selectors = { field: { field: '[data-upload-field]', input: 'input[type="file"]', 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() { // this.initializeFields(); this.initListeners(); // Queue integration - handle completion/failure this.queue.subscribe((event, operation) => { if (!['uploads', 'uploads/meta', 'uploads/groups'].includes(operation.endpoint)) { return; } const fieldId = operation.data instanceof FormData ? operation.data.get('fieldId') : operation.data?.fieldId; switch(event) { case 'cancel-operation': if (fieldId) this.handleOperationCancelled(fieldId); break; case 'operation-status': if (fieldId) this.updateFieldStatus(fieldId, operation.status); break; case 'operation-complete': this.handleOperationComplete(operation, fieldId); break; case 'operation-failed': case 'operation-failed-permanent': this.handleOperationFailed(operation, 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, batchSize: 1, maxConcurrent: 3, restartAfterTimeout: true } }; } /******************************************************************************* * FIELD MANAGEMENT *******************************************************************************/ scanFields(container, autoUpload) { console.log(autoUpload, 'autoUpload'); const fields = container.querySelectorAll(this.selectors.field.field); fields.forEach(uploader => this.registerUploader(uploader, autoUpload)); } registerUploader(uploader, autoUpload) { const fieldId = this.determineFieldId(uploader); const config = this.extractFieldConfig(uploader, autoUpload); const ui = this.buildFieldUI(uploader); console.log(config, 'registering with config'); // Store field data with Sets for runtime const fieldData = { id: fieldId, config: config, uploads: new Set(), groups: [], state: 'ready', timestamp: Date.now() }; // Save to store (will convert Sets to Arrays automatically) this.fieldStore.save(fieldData); // Store DOM references separately this.fieldElements.set(fieldId, { element: uploader, ui, config }); uploader.dataset.uploader = fieldId; this.addFieldSelectionHandler(fieldId); if (config.type !== 'single') { this.initSortable(fieldId); } return fieldId; } extractFieldConfig(fieldElement, autoUpload) { return { autoUpload: autoUpload, destination: fieldElement.dataset.destination || 'meta', content: fieldElement.dataset.content || null, mode: fieldElement.dataset.mode || 'direct', type: fieldElement.dataset.type || 'single', name: fieldElement.dataset.field, itemID: fieldElement.dataset.itemId || 0, maxFiles: parseInt(fieldElement.dataset.maxFiles) || 999, subtype: fieldElement.dataset.subtype || 'image' }; } 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; } /******************************************************************************* * SORTABLE INITIALIZATION *******************************************************************************/ initSortable(fieldId) { if (!window.Sortable) return; // Mount MultiDrag plugin once if (!Sortable._multiDragMounted && Sortable.MultiDrag) { Sortable.mount(new Sortable.MultiDrag()); Sortable._multiDragMounted = true; } const fieldEl = this.fieldElements.get(fieldId); if (!fieldEl) return; // Initialize sortable on all existing grids const grids = fieldEl.element.querySelectorAll('.item-grid.preview, .item-grid.group'); grids.forEach(grid => { const groupId = grid.classList.contains('group') ? grid.closest('.upload-group')?.dataset.groupId : null; this.createSortableForGrid(grid, fieldId, groupId); }); // Special handler for empty-group const emptyGroup = fieldEl.element.querySelector('.empty-group'); if (emptyGroup && !emptyGroup.sortableInstance) { emptyGroup.sortableInstance = new Sortable(emptyGroup, { animation: 150, draggable: '.item', multiDrag: true, selectedClass: 'selected-for-drag', avoidImplicitDeselect: true, group: { name: fieldId, pull: false, put: true }, ghostClass: 'sortable-ghost', chosenClass: 'sortable-chosen', dragClass: 'sortable-drag', onEnd: (evt) => this.handleDrop(evt, fieldId) }); } } syncSortableSelection(fieldId, selectedItems) { // Update Sortable's selection state to match checkboxes this.sortableInstances.forEach((instance, key) => { if (key.startsWith(fieldId)) { const grid = instance.el; const items = grid.querySelectorAll('.item'); items.forEach(item => { const uploadId = item.dataset.uploadId; const shouldBeSelected = selectedItems.has(uploadId); if (shouldBeSelected) { Sortable.utils.select(item); } else { Sortable.utils.deselect(item); } }); } }); } handleDrop(evt, fieldId) { const dropTarget = evt.to; const sourceTarget = evt.from; const items = evt.items?.length > 0 ? evt.items : [evt.item]; const uploadIds = items.map(item => item.dataset.uploadId); // Determine drop target type const targetType = this.getDropTargetType(dropTarget); switch (targetType) { case 'empty-group': this.handleDropToEmptyGroup(items, uploadIds, fieldId); break; case 'preview': this.handleDropToPreview(items, uploadIds, fieldId); break; case 'group': this.handleDropToGroup(items, uploadIds, dropTarget, sourceTarget, fieldId); break; default: // Fallback: return to preview this.handleDropToPreview(items, uploadIds, fieldId); break; } // Update UI this.updateSortableState(dropTarget); if (sourceTarget !== dropTarget) { this.updateSortableState(sourceTarget); } } /** * Determine what type of drop target this is */ getDropTargetType(target) { if (target.classList.contains('empty-group')) { return 'empty-group'; } if (target.classList.contains('preview')) { return 'preview'; } if (target.classList.contains('group')) { return 'group'; } return 'unknown'; } /** * Handle drop to group: add to existing group */ handleDropToGroup(items, uploadIds, dropTarget, sourceTarget, fieldId) { try { // If same container, it's just a reorder if (dropTarget === sourceTarget) { this.handleReorder({ to: dropTarget, items: items }); return; } // Moving to different group uploadIds.forEach(uploadId => { this.addToGroup(uploadId, dropTarget, false); }); this.schedulePersistance(fieldId); const message = items.length > 1 ? `Moved ${items.length} items to group` : 'Moved item to group'; this.a11y.announce(message); // Clear selection const handler = this.selectionHandlers.get(fieldId); handler?.clearSelection(); } catch (error) { this.handleDropError(items, fieldId, error); } } /** * Handle drop to preview: remove from groups */ handleDropToPreview(items, uploadIds, fieldId) { try { uploadIds.forEach(uploadId => { this.removeFromGroup(uploadId); }); this.schedulePersistance(fieldId); const message = items.length > 1 ? `Moved ${items.length} items to preview` : 'Moved item to preview'; this.a11y.announce(message); // Clear selection const handler = this.selectionHandlers.get(fieldId); handler?.clearSelection(); } catch (error) { this.handleDropError(items, fieldId, error); } } /** * Handle drop errors consistently */ handleDropError(items, fieldId, error, message = 'An error occurred') { console.error('Drop error:', error); // Return items to preview as fallback const fieldEl = this.fieldElements.get(fieldId); if (fieldEl?.ui?.preview) { items.forEach(item => fieldEl.ui.preview.appendChild(item)); } this.a11y.announce(`${message}. Items returned to preview.`); } /** * Handle drop to group: add to existing group */ handleDropToEmptyGroup(items, uploadIds, fieldId) { try { const group = this.createGroup(fieldId); if (!group) { this.handleDropError(items, fieldId, new Error('Group creation failed'), 'Failed to create group'); return; } // Move items to new group items.forEach((item, index) => { group.grid.appendChild(item); this.addToGroup(uploadIds[index], group.grid, false); }); this.schedulePersistance(fieldId); const message = items.length > 1 ? `Created group with ${items.length} items` : 'Created group with item'; this.a11y.announce(message); // Clear selection after move const handler = this.selectionHandlers.get(fieldId); handler?.clearSelection(); } catch (error) { this.handleDropError(items, fieldId, error); } } /** * Update sortable enabled/disabled state based on item count */ updateSortableState(grid) { const sortable = grid?.sortableInstance; if (!sortable) return; // const hasItems = grid.querySelectorAll('.item').length > 0; sortable.option('disabled', false); } /** * Refresh sortable for a field (call after adding/removing items dynamically) */ refreshSortable(fieldId) { const fieldEl = this.fieldElements.get(fieldId); if (!fieldEl) return; const grids = fieldEl.element.querySelectorAll('.item-grid.preview, .item-grid.group'); grids.forEach(grid => this.updateSortableState(grid)); } handleReorder(evt) { const grid = evt.to; const fieldWrapper = grid.closest('.field, .upload'); if (!fieldWrapper) return; // Get current order from DOM let items = Array.from(grid.querySelectorAll('.item:not(.sortable-ghost):not(.sortable-clone)')) .map(upload => upload.dataset.uploadId) .filter(id => id); // Update hidden input (for form submission) let hiddenInput = fieldWrapper.querySelector('input[type="hidden"]'); if (hiddenInput && items.length > 0) { hiddenInput.value = items.join(','); } // Update fieldState with new order const fieldId = this.getFieldIdFromElement(grid); if (fieldId) { const fieldData = this.getFieldData(fieldId); // If reordering within a group, update that group's uploads array if (grid.classList.contains('group')) { const groupId = grid.dataset.groupId; const group = fieldData?.groups?.find(g => g.id === groupId); if (group) { group.uploads = items; // Update order } } // If reordering in preview, the order is implicit by DOM position // (we don't store preview order separately) this.schedulePersistance(fieldId); } this.a11y.announce('Item reordered'); fieldWrapper.dispatchEvent(new CustomEvent('jvb-items-reordered', { detail: { from: evt.from, to: evt.to, oldIndex: evt.oldIndex, newIndex: evt.newIndex, items: items }, bubbles: true })); } /******************************************************************************* * FILE DROP HANDLERS *******************************************************************************/ initListeners() { this.clickHandler = this.handleClick.bind(this); this.changeHandler = this.handleChange.bind(this); document.addEventListener('click', this.clickHandler); document.addEventListener('change', this.changeHandler); 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); } 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`); } } /******************************************************************************* * CLICK & CHANGE HANDLERS *******************************************************************************/ handleClick(e) { // Trigger file input 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 files = Array.from(e.target.files); if (files.length > 0 && fieldId) { this.processFiles(fieldId, files); } } // Meta field changes if (fieldId) { const fieldData = this.getFieldData(fieldId); if (!fieldData.config.autoUpload) { return; } if (fieldData?.config.destination === 'post_group') { this.handleGroupMetaChange(e.target); } else { this.queueUploadMeta(e); } } } /******************************************************************************* * FILE PROCESSING *******************************************************************************/ async processFiles(fieldId, files) { const fieldData = this.getFieldData(fieldId); const fieldEl = this.fieldElements.get(fieldId); if (!fieldData || !fieldEl) return; // Show group display, hide upload zone if (fieldEl.ui.dropZone) { fieldEl.ui.dropZone.hidden = true; } if (fieldEl.ui.groups?.display) { fieldEl.ui.groups.display.hidden = false; } const totalFiles = files.length; let processedCount = 0; this.updateUploadProgress(fieldId, 0, totalFiles, 'Processing files...'); const processPromises = Array.from(files).map(async (file) => { try { const uploadId = `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // Create initial upload data const uploadData = { id: uploadId, attachmentId: null, fieldId: fieldId, status: 'local_processing', groupId: null, meta: { originalName: file.name, size: file.size, type: file.type } }; // Save initial data await this.uploadStore.save(uploadData); // Process file const preview = this.createPreviewUrl(file); const processedFile = file.type.startsWith('image/') ? await this.processImage(file, fieldData.config.subtype) : file; // Show progress this.showUploadProgress(uploadId, true); this.updateUploadItemProgress(uploadId, 50, 'local_processing'); // Store blob data (this updates the existing uploadData) await this.saveBlobData(uploadId, processedFile || file); // Create DOM element const subtype = this.getSubtypeFromMime(file.type); const element = this.createUploadElement({ id: uploadId, preview: preview, meta: uploadData.meta, subtype: subtype }, fieldData.config.destination === 'post_group'); // Add to preview grid if (fieldEl.ui.preview) { fieldEl.ui.preview.appendChild(element); // Store runtime element data this.uploadElements.set(uploadId, { element: element, preview: preview, location: fieldEl.ui.preview }); } // Update status (gets existing data with blobData intact) const storedUpload = this.uploadStore.get(uploadId); if (storedUpload) { storedUpload.status = 'processed'; await this.uploadStore.save(storedUpload); } // Add to field fieldData.uploads.add(uploadId); await this.saveFieldData(fieldData); // Update progress processedCount++; this.updateUploadProgress(fieldId, processedCount, totalFiles, 'Processing files...'); this.updateUploadItemProgress(uploadId, 100, 'processed'); // Fade out progress 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; } }); await Promise.all(processPromises); this.updateFieldState(fieldId); this.refreshSortable(fieldId); // Queue for upload if in direct mode if (fieldData.config.autoUpload && fieldData.config.destination !== 'post_group') { await this.queueUpload(fieldId); this.maybeLockUploads(fieldId); } } /******************************************************************************* * IMAGE PROCESSING *******************************************************************************/ async processImage(file, uploadId) { const timeout = this.worker.settings.timeout; return new Promise((resolve, reject) => { let timeoutId; let taskCompleted = false; timeoutId = setTimeout(() => { if (!taskCompleted) { taskCompleted = true; this.worker.tasks.delete(uploadId); if (this.worker.settings.restartAfterTimeout) { this.restartCompressionWorker(); } reject(new Error(`Processing timeout for ${file.name}`)); } }, timeout); this.worker.tasks.set(uploadId, { file, timeoutId }); 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) { if (!file.type.startsWith('image/')) { return file; } const maxDimension = this.getMaxDimension(); const quality = 0.85; if (this.shouldUseWorker(file)) { try { 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); } } return await this.processOnMainThread(file, maxDimension, quality); } 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; } 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; 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}`)); } }); } getOptimalFormat(file) { if (file.type === 'image/gif' || file.type === 'image/svg+xml') { return file.type; } return this.supportsWebP() ? 'image/webp' : 'image/jpeg'; } getOptimalQuality(file, requestedQuality) { if (file.size < 500 * 1024) return Math.max(requestedQuality, 0.9); if (file.size < 2 * 1024 * 1024) return requestedQuality; return Math.min(requestedQuality, 0.8); } 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'); } getMaxDimension() { const screenWidth = window.screen.width; const devicePixelRatio = window.devicePixelRatio || 1; if (screenWidth * devicePixelRatio > 2560) return 2400; if (screenWidth * devicePixelRatio > 1920) return 1920; return 1200; } shouldUseWorker(file) { return this.worker.worker && file.size > 1024 * 1024 && 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; } const messageId = `${uploadId}_${Date.now()}`; const messageHandler = (e) => { if (e.data.messageId !== messageId) return; 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}`)); }; this.worker.worker.addEventListener('message', messageHandler); this.worker.worker.addEventListener('error', errorHandler); this.worker.worker.postMessage({ messageId, file, maxDimension, quality, outputFormat: this.getOptimalFormat(file) }); }); } restartCompressionWorker() { if (this.worker.worker) { this.worker.worker.terminate(); this.worker.worker = null; } this.worker.tasks.clear(); if (this.worker.restart.count >= this.worker.restart.max) { console.error('Max worker restarts reached, disabling worker'); return; } this.worker.restart.count++; this.initCompressionWorker(); } 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 { const bitmap = await createImageBitmap(file); 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); const canvas = new OffscreenCanvas(width, height); const ctx = canvas.getContext('2d'); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(bitmap, 0, 0, width, height); bitmap.close(); 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; } } calculateOptimalDimensions(img, maxDimension) { let { width, height } = img; if (width <= maxDimension && height <= maxDimension) { return { width, height }; } const scale = Math.min(maxDimension / width, maxDimension / height); return { width: Math.round(width * scale), height: Math.round(height * scale) }; } supportsWebP() { const canvas = document.createElement('canvas'); return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0; } createPreviewUrl(file) { const url = URL.createObjectURL(file); 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); } } /******************************************************************************* * QUEUE INTEGRATION *******************************************************************************/ async submitUploads(fieldId) { const fieldData = this.getFieldData(fieldId); const fieldEl = this.fieldElements.get(fieldId); if (!fieldData?.uploads || fieldData.uploads.size === 0) { return; } let uploadIds = Array.from(fieldData.uploads); if (uploadIds.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 = []; // Process each group for (const group of fieldGroups) { const post = { images: [], fields: {} }; // Add group metadata for (let [name, value] of Object.entries(group.changes)) { post.fields[name] = value; } // Get uploads for this group const groupUploadIds = uploadIds.filter(uploadId => { const upload = this.uploadStore.get(uploadId); return upload?.groupId === group.id; }); // Add files for this group for (const uploadId of groupUploadIds) { const file = await this.getBlobData(uploadId); if (file) { formData.append('files[]', file); const imageData = { upload_id: uploadId, index: uploadMap.length }; // Check if featured const uploadEl = this.uploadElements.get(uploadId); const radioInput = uploadEl?.element?.querySelector('[name="featured"]'); if (radioInput?.checked) { post.fields.featured = uploadId; } post.images.push(imageData); uploadMap.push(uploadId); } } posts.push(post); } // Handle remaining uploads (without groupId) - each becomes its own post const remainingUploadIds = uploadIds.filter(uploadId => { const upload = this.uploadStore.get(uploadId); return !upload?.groupId; }); for (const uploadId of remainingUploadIds) { const post = { images: [], fields: {} }; const file = await this.getBlobData(uploadId); if (file) { formData.append('files[]', file); const imageData = { upload_id: uploadId, index: uploadMap.length }; post.images.push(imageData); uploadMap.push(uploadId); } posts.push(post); } // Add metadata to FormData formData.append('content', fieldData.config.content); formData.append('user', fieldData.config.itemID); 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} ${fieldData.config.content}${posts.length > 1 ? 's' : ''} from uploads...`, popup: `Creating ${posts.length} post${posts.length > 1 ? 's' : ''}...`, canMerge: false, headers: { 'action_nonce': window.auth.getNonce('dash') }, append: '_upload', }; try { const operationId = await this.queue.addToQueue(operation); // Update upload statuses uploadIds.forEach(uploadId => { const upload = this.uploadStore.get(uploadId); if (upload) { upload.operationId = operationId; upload.status = 'queued'; this.uploadStore.save(upload); this.updateUploadStatus(uploadId, 'queued'); } }); fieldData.operationId = operationId; await this.saveFieldData(fieldData); 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; } } async queueUpload(fieldId) { const fieldData = this.getFieldData(fieldId); if (!fieldData?.uploads || fieldData.uploads.size === 0) return; const uploads = Array.from(fieldData.uploads); const data = this.prepareUploadData(fieldData, uploads); this.a11y.announce('Queuing for upload'); const operation = { endpoint: 'uploads', method: 'POST', data: data, title: `Uploading ${uploads.length} file${uploads.length > 1 ? 's' : ''} to server...`, popup: `Uploading ${uploads.length} file${uploads.length > 1 ? 's' : ''}...`, canMerge: false, headers: { 'action_nonce': window.auth.getNonce('dash') }, append: '_upload' }; try { const operationId = await this.queue.addToQueue(operation); // Update upload statuses uploads.forEach(uploadId => { const upload = this.uploadStore.get(uploadId); if (upload) { upload.operationId = operationId; upload.status = 'queued'; this.uploadStore.save(upload); this.updateUploadStatus(uploadId, 'queued'); } }); fieldData.operationId = operationId; await this.saveFieldData(fieldData); return operationId; } catch (error) { throw error; } } async prepareUploadData(fieldData, uploads) { const formData = new FormData(); formData.append('content', fieldData.config.content); formData.append('mode', fieldData.config.mode); formData.append('field_name', fieldData.config.name); formData.append('fieldId', fieldData.id); formData.append('field_type', fieldData.config.type); formData.append('subtype', fieldData.config.subtype); formData.append('item_id', fieldData.config.itemID); formData.append('destination', fieldData.config.destination || 'meta'); let uploadMap = []; const blobPromises = uploads.map(async (uploadId) => { const upload = this.uploadStore.get(uploadId); if (!upload) return; const file = await this.getBlobData(uploadId); if (file) { formData.append('files[]', file); uploadMap.push(upload.id); } }); await Promise.all(blobPromises); formData.append('upload_ids', JSON.stringify(uploadMap)); return formData; } async queueUploadMeta(e) { const uploadId = this.getUploadIdFromElement(e.target); const upload = this.uploadStore.get(uploadId); if (!upload) return; const fieldData = this.getFieldData(upload.fieldId); if (!fieldData) return; let data = {}; data[e.target.name] = e.target.value; upload.meta = { ...upload.meta, ...data }; await this.uploadStore.save(upload); let queueData = {}; queueData[upload.attachmentId ?? upload.id] = upload.meta; const operation = { endpoint: 'uploads/meta', method: 'POST', data: queueData, title: 'Updating meta', canMerge: true, headers: { 'action_nonce': window.auth.getNonce('dash') } }; try { await this.queue.addToQueue(operation); } catch (error) { this.error.log(error, { component: 'UploadManager', action: 'sendMetaUpdate', uploadId: upload.id }); } } /******************************************************************************* * QUEUE EVENT HANDLERS - CLEANUP AFTER SUCCESS *******************************************************************************/ /** * Handle successful operation completion - CLEAR STORES */ async handleOperationComplete(operation, fieldId) { const results = operation.result?.data || operation.serverData?.data || []; // Update upload statuses with attachment IDs results.forEach(result => { const upload = this.uploadStore.get(result.upload_id); if (upload) { upload.attachmentId = result.attachment_id; upload.status = 'completed'; this.uploadStore.save(upload); this.updateUploadStatus(result.upload_id, 'completed'); } }); if (!fieldId) return; const fieldData = this.getFieldData(fieldId); if (!fieldData) return; // Clean up completed uploads from stores const completedUploads = Array.from(fieldData.uploads).filter(uploadId => { const upload = this.uploadStore.get(uploadId); return upload?.status === 'completed'; }); for (const uploadId of completedUploads) { await this.clearUpload(uploadId, false); fieldData.uploads.delete(uploadId); } // If all uploads complete, clear entire field from stores if (fieldData.uploads.size === 0) { await this.clearFieldFromStores(fieldId); this.a11y.announce('All uploads completed successfully'); } else { // Otherwise just update field state await this.saveFieldData(fieldData); } this.updateFieldState(fieldId); } /** * Handle operation failure */ handleOperationFailed(operation, fieldId) { const uploadIds = operation.data instanceof FormData ? JSON.parse(operation.data.get('upload_ids') || '[]') : operation.data.upload_ids || []; uploadIds.forEach(uploadId => { const upload = this.uploadStore.get(uploadId); if (upload) { upload.status = operation.status === 'operation-failed-permanent' ? 'failed_permanent' : 'failed'; this.uploadStore.save(upload); this.updateUploadStatus(uploadId, upload.status); } }); if (fieldId) { this.updateFieldState(fieldId); } } /** * Handle operation cancellation */ async handleOperationCancelled(fieldId) { const fieldData = this.getFieldData(fieldId); if (!fieldData) return; const uploadsArray = fieldData.uploads instanceof Set ? Array.from(fieldData.uploads) : fieldData.uploads; for (const uploadId of uploadsArray) { await this.clearUpload(uploadId, false); } await this.clearFieldFromStores(fieldId); this.updateFieldState(fieldId); this.a11y.announce('Upload cancelled'); } getFieldGroups(fieldId) { const fieldData = this.getFieldData(fieldId); if (!fieldData?.groups) return []; return fieldData.groups.map(group => ({ id: group.id, uploads: group.uploads || [], changes: group.changes || {} })); } 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; } async restoreSelectedUploads(selectedUploads) { const byField = new Map(); selectedUploads.forEach(item => { if (!byField.has(item.fieldId)) { byField.set(item.fieldId, []); } byField.get(item.fieldId).push(item.uploadId); }); for (const [fieldId, uploadIds] of byField.entries()) { const fieldState = this.fieldStore.get(fieldId); if (fieldState) { fieldState.uploads = uploadIds; await this.restoreField(fieldState); } } } async restoreField(fieldState) { const { config, context, uploads, groups, id } = fieldState; // 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.fieldElements.has(fieldKey)) { fieldKey = this.registerUploader(fieldElement); } const fieldEl = this.fieldElements.get(fieldKey); const fieldData = this.getFieldData(fieldKey); if (!fieldEl || !fieldData) { console.error('Failed to register field for restoration'); return; } // Merge saved state back into field fieldData.state = fieldState.state || 'ready'; // Rebuild UI references if needed if (!fieldEl.ui) { fieldEl.ui = this.buildFieldUI(fieldElement); } if (fieldEl.ui.groups?.display) { fieldEl.ui.groups.display.hidden = false; } if (fieldEl.ui.dropZone) { fieldEl.ui.dropZone.hidden = true; } // Restore groups first if (groups && groups.length > 0) { await this.restoreGroups(fieldKey, groups); } // Handle both Array and Set for uploads const uploadsArray = uploads instanceof Set ? Array.from(uploads) : Array.isArray(uploads) ? uploads : []; // Restore uploads for (const uploadId of uploadsArray) { // Get upload data from store const uploadData = this.uploadStore.get(uploadId); if (uploadData) { await this.restoreUpload(fieldKey, uploadData); } } // Update field state await this.saveFieldData(fieldData); this.updateFieldState(fieldKey); this.maybeLockUploads(fieldKey); this.refreshSortable(fieldKey); // Queue for upload if needed console.log(config); if (config.autoUpload && config.mode === 'direct' && config.destination !== 'post_group') { await this.queueUpload(fieldKey); } } async restoreUpload(fieldId, uploadData) { const fieldEl = this.fieldElements.get(fieldId); const fieldData = this.getFieldData(fieldId); if (!fieldEl || !fieldData) { console.error('Field not found for upload restoration:', fieldId); return; } // Get reconstructed File from blob data const file = await this.getBlobData(uploadData.id); if (!file) { console.warn('Blob data not found for upload:', uploadData.id); return; } // Create preview URL const previewUrl = this.createPreviewUrl(file); // Recreate DOM element const subtype = this.getSubtypeFromMime(file.type); const element = this.createUploadElement({ id: uploadData.id, preview: previewUrl, meta: uploadData.meta || { originalName: file.name, size: file.size, type: file.type }, subtype: subtype }, fieldData.config.destination === 'post_group'); // Determine correct location let location; if (uploadData.groupId) { // Check if group exists const groupEl = this.groupElements.get(uploadData.groupId); if (groupEl?.grid) { location = groupEl.grid; // Add to group's upload list const group = fieldData.groups?.find(g => g.id === uploadData.groupId); if (group) { if (!group.uploads) group.uploads = []; if (!group.uploads.includes(uploadData.id)) { group.uploads.push(uploadData.id); } } } else { // Group doesn't exist, add to preview location = fieldEl.ui.preview; uploadData.groupId = null; } } else { // No group, add to preview location = fieldEl.ui.preview; } // Add element to DOM if (location) { location.appendChild(element); } else if (fieldEl.ui.preview) { fieldEl.ui.preview.appendChild(element); location = fieldEl.ui.preview; } // Store runtime element data this.uploadElements.set(uploadData.id, { element: element, preview: previewUrl, location: location }); // Add to field uploads if (!fieldData.uploads) fieldData.uploads = new Set(); fieldData.uploads.add(uploadData.id); // Update upload data in store uploadData.status = 'processed'; await this.uploadStore.save(uploadData); // Update sortable state for the grid if (location) { this.updateSortableState(location); } } async restoreGroups(fieldId, groups) { const fieldEl = this.fieldElements.get(fieldId); const fieldData = this.getFieldData(fieldId); if (!fieldEl || !fieldData) { console.error('Field not found for group restoration:', fieldId); return; } for (const groupData of groups) { const group = this.createGroup(fieldId, groupData.id); if (!group) { console.warn('Failed to create group:', groupData.id); continue; } const storedGroup = fieldData.groups?.find(g => g.id === groupData.id); if (storedGroup) { // Restore metadata if (groupData.changes) { storedGroup.changes = { ...groupData.changes }; } // Preserve upload order if (groupData.uploads) { storedGroup.uploads = [...groupData.uploads]; } // Restore form field values if (groupData.changes) { const titleInput = group.element.querySelector('[name*="post_title"]'); const excerptInput = group.element.querySelector('[name*="post_excerpt"]'); if (titleInput && groupData.changes.post_title) { titleInput.value = groupData.changes.post_title; } if (excerptInput && groupData.changes.post_excerpt) { excerptInput.value = groupData.changes.post_excerpt; } } } } await this.saveFieldData(fieldData); } async openModalForRestore(context) { if (!context) return; const { modalType, itemId } = 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 if (itemId) { trigger = document.querySelector(`[data-action="edit"][data-id="${itemId}"]`); } break; case 'bulkEdit': trigger = document.querySelector('[data-action="bulk-edit"]'); break; } if (trigger) { trigger.click(); // Wait for modal to open and render await new Promise(resolve => setTimeout(resolve, 300)); } else { console.warn('Modal trigger not found for restoration:', context); } } 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]; } /******************************************************************************* * CLEANUP METHODS - AGGRESSIVE CLEANUP AFTER SUCCESS *******************************************************************************/ /** * Clear individual upload from stores (called after successful upload) */ async clearUpload(uploadId, persist = true) { const uploadEl = this.uploadElements.get(uploadId); if (uploadEl) { this.revokePreviewUrl(uploadEl.preview); if (uploadEl.element) { const previewUrl = uploadEl.element.dataset.previewUrl; this.revokePreviewUrl(previewUrl); delete uploadEl.element.dataset.previewUrl; } } // Remove from runtime memory this.uploadElements.delete(uploadId); // Remove from store (no separate blob store - it's part of the upload object) await this.uploadStore.delete(uploadId); // Update field if needed if (persist) { const upload = this.uploadStore.get(uploadId); if (upload?.fieldId) { await this.schedulePersistance(upload.fieldId); } } } /** * Clear entire field from stores (called when all uploads complete) */ async clearFieldFromStores(fieldId) { const fieldData = this.getFieldData(fieldId); // Clear all related uploads if (fieldData?.uploads) { const uploadsArray = fieldData.uploads instanceof Set ? Array.from(fieldData.uploads) : fieldData.uploads; for (const uploadId of uploadsArray) { await this.uploadStore.delete(uploadId); } } // Clear field from store await this.fieldStore.delete(fieldId); // Keep runtime references (fieldElements, etc) intact for reuse } cleanupAllPreviewUrls() { if (this.previewUrls) { this.previewUrls.forEach(url => { try { URL.revokeObjectURL(url); } catch (e) { // Ignore errors during cleanup } }); this.previewUrls.clear(); } } /******************************************************************************* * UI UPDATE METHODS *******************************************************************************/ updateFieldState(fieldId) { const fieldEl = this.fieldElements.get(fieldId); const fieldData = this.getFieldData(fieldId); if (!fieldEl || !fieldData) return; const container = fieldEl.element; const uploadCount = fieldData.uploads?.size || 0; const hasGroups = fieldEl.ui.groups?.container?.querySelectorAll('.upload-group').length > 0; container.dataset.hasUploads = uploadCount > 0 ? 'true' : 'false'; container.dataset.uploadCount = uploadCount.toString(); container.dataset.hasGroups = hasGroups ? 'true' : 'false'; if (fieldEl.ui.preview) { fieldEl.ui.preview.setAttribute('aria-label', `Upload preview area with ${uploadCount} item${uploadCount !== 1 ? 's' : ''}` ); } } updateUploadProgress(fieldId, current, total, message) { const fieldEl = this.fieldElements.get(fieldId); if (!fieldEl?.ui?.progress?.progress) return; const progress = fieldEl.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 fieldData = this.getFieldData(fieldId); if (!fieldData) return; fieldData.state = status; this.saveFieldData(fieldData); } updateUploadStatus(uploadId, status) { const upload = this.uploadStore.get(uploadId); if (!upload) return; upload.status = status; this.uploadStore.save(upload); this.updateUploadUI(uploadId); } updateUploadUI(uploadId) { const uploadEl = this.uploadElements.get(uploadId); const upload = this.uploadStore.get(uploadId); if (!upload || !uploadEl?.element) return; uploadEl.element.className = uploadEl.element.className.replace(/status-[\w-]+/g, ''); uploadEl.element.classList.add(`status-${upload.status}`); const progress = uploadEl.element.querySelector('.progress'); if (progress) { this.updateUploadItemProgress(uploadId, this.getStatusProgress(upload.status), upload.status ); } } showUploadProgress(uploadId, show = true) { const uploadEl = this.uploadElements.get(uploadId); if (!uploadEl?.element) return; const progressEl = uploadEl.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); } } } updateUploadItemProgress(uploadId, percent, status = null) { const uploadEl = this.uploadElements.get(uploadId); if (!uploadEl?.element) return; const progressEl = uploadEl.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; } maybeLockUploads(fieldId) { const fieldEl = this.fieldElements.get(fieldId); const fieldData = this.getFieldData(fieldId); if (!fieldEl?.ui?.dropZone || !fieldData) return; const uploadCount = fieldData.uploads?.size || 0; // For groupable uploads, set max to 20 const maxFiles = fieldData.config.destination === 'post_group' ? 20 : (fieldData.config?.maxFiles || 999); fieldEl.ui.dropZone.hidden = uploadCount >= maxFiles; fieldEl.element.classList.toggle('at-max-uploads', uploadCount >= maxFiles); // Show helpful message for groupable uploads if (fieldData.config.destination === 'post_group' && uploadCount >= maxFiles) { this.a11y.announce('Maximum of 20 uploads reached. Please submit current uploads before adding more.'); } } /******************************************************************************* * GROUP MANAGEMENT *******************************************************************************/ /** * Create sortable instance for a grid */ createSortableForGrid(grid, fieldId, groupId = null) { if (!grid || grid.sortableInstance) return; const sortableInstance = new Sortable(grid, { animation: 150, draggable: '.item', multiDrag: true, selectedClass: 'selected-for-drag', avoidImplicitDeselect: true, group: { name: fieldId, pull: true, put: true }, ghostClass: 'sortable-ghost', chosenClass: 'sortable-chosen', dragClass: 'sortable-drag', // Centralized drop handler onEnd: (evt) => this.handleDrop(evt, fieldId), // Selection sync onSelect: (evt) => { const checkbox = evt.item.querySelector('[name*="select-item"]'); if (checkbox && !checkbox.checked) { checkbox.checked = true; checkbox.dispatchEvent(new Event('change', { bubbles: true })); } }, onDeselect: (evt) => { const checkbox = evt.item.querySelector('[name*="select-item"]'); if (checkbox && checkbox.checked) { checkbox.checked = false; checkbox.dispatchEvent(new Event('change', { bubbles: true })); } }, onAdd: (evt) => this.updateSortableState(evt.to), onRemove: (evt) => this.updateSortableState(evt.from) }); grid.sortableInstance = sortableInstance; const gridId = groupId ? `${fieldId}-group-${groupId}` : `${fieldId}-preview`; this.sortableInstances.set(gridId, sortableInstance); return sortableInstance; } createGroup(fieldId, groupId = null) { const fieldData = this.getFieldData(fieldId); const fieldEl = this.fieldElements.get(fieldId); if (!fieldData || !fieldEl) return null; if (!groupId) { groupId = `group_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; } const groupElement = this.createGroupElement(groupId, fieldId); if (!groupElement) return null; // Store in field UI Map if (!fieldEl.ui.groups) { fieldEl.ui.groups = { groups: new Map(), container: null, empty: null, display: null }; } fieldEl.ui.groups.groups.set(groupId, groupElement); // Insert into DOM if (fieldEl.ui.groups.container && fieldEl.ui.groups.empty) { fieldEl.ui.groups.container.insertBefore(groupElement, fieldEl.ui.groups.empty); } else if (fieldEl.ui.groups.container) { fieldEl.ui.groups.container.appendChild(groupElement); } // Store group element reference const grid = groupElement.querySelector('.item-grid.group'); this.groupElements.set(groupId, { element: groupElement, grid: grid, fieldId: fieldId }); // Add to field groups if (!fieldData.groups) fieldData.groups = []; const existingGroup = fieldData.groups.find(g => g.id === groupId); if (!existingGroup) { fieldData.groups.push({ id: groupId, uploads: [], changes: {} }); this.saveFieldData(fieldData); } // Initialize selection handler this.addGroupSelectionHandler(fieldId, groupId); if (grid) { this.createSortableForGrid(grid, fieldId, groupId); } return { id: groupId, element: groupElement, grid: grid }; } 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); 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]`; } const fieldData = this.getFieldData(fieldId); if (fieldData && fieldData.config.content !== '') { let summary = groupElement.querySelector('summary'); if (summary) summary.textContent = fieldData.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) { const groupEl = this.groupElements.get(groupId); if (!groupEl) return; const fieldData = this.getFieldData(groupEl.fieldId); if (!fieldData) return; const group = fieldData.groups?.find(g => g.id === groupId); let keepUploads = true; if (confirm && group?.uploads?.length > 0) { keepUploads = !window.confirm('Delete uploads in group?'); } if (confirm && keepUploads && group?.uploads) { // Move uploads back to preview group.uploads.forEach(uploadId => { this.removeFromGroup(uploadId); }); } // Remove from field groups if (fieldData.groups) { fieldData.groups = fieldData.groups.filter(g => g.id !== groupId); this.saveFieldData(fieldData); } // Remove DOM element if (groupEl.element) { groupEl.element.remove(); this.a11y.announce('Group removed'); } // Remove from maps this.groupElements.delete(groupId); // Clean up sortable const sortableKey = `${groupEl.fieldId}-group-${groupId}`; const sortable = this.sortableInstances.get(sortableKey); if (sortable?.destroy) { sortable.destroy(); } this.sortableInstances.delete(sortableKey); this.schedulePersistance(groupEl.fieldId); } addToGroup(uploadId, target = null, persist = true) { const upload = this.uploadStore.get(uploadId); const uploadEl = this.uploadElements.get(uploadId); if (!upload || !uploadEl) return; const fieldData = this.getFieldData(upload.fieldId); const fieldEl = this.fieldElements.get(upload.fieldId); if (!fieldData || !fieldEl) return; // Already in correct location if ((!target && uploadEl.location === fieldEl.ui.preview) || target === uploadEl.location) { return; } // Remove from previous group if (upload.groupId) { const group = fieldData.groups?.find(g => g.id === upload.groupId); if (group) { group.uploads = group.uploads.filter(id => id !== uploadId); if (group.uploads.length === 0) { this.deleteGroup(upload.groupId); } } } // Clear selection checkbox const checkbox = uploadEl.element.querySelector('[name*="select-item"]'); if (checkbox) checkbox.checked = false; let featured = uploadEl.element.querySelector('[name="featured"]'); if (featured) featured.hidden = !target; // Moving to preview or to group if (!target || target.classList.contains('preview')) { target = fieldEl.ui.preview; upload.groupId = null; } else { // Moving to group const groupId = target.dataset.groupId; if (featured) featured.name = groupId + '_' + featured.name; const group = fieldData.groups?.find(g => g.id === groupId); if (group) { if (!group.uploads) group.uploads = []; group.uploads.push(uploadId); upload.groupId = groupId; } } // Update location uploadEl.location = target; target.append(uploadEl.element); // Update stores this.uploadStore.save(upload); if (persist) { this.saveFieldData(fieldData); } // Update sortable state this.updateSortableState(target); if (uploadEl.location && uploadEl.location !== target) { this.updateSortableState(uploadEl.location); } } removeFromGroup(uploadId) { const upload = this.uploadStore.get(uploadId); const uploadEl = this.uploadElements.get(uploadId); if (!upload || !uploadEl) return; const fieldData = this.getFieldData(upload.fieldId); const fieldEl = this.fieldElements.get(upload.fieldId); if (!fieldData || !fieldEl) return; // Remove from current group if (upload.groupId) { const group = fieldData.groups?.find(g => g.id === upload.groupId); if (group) { group.uploads = group.uploads.filter(id => id !== uploadId); if (group.uploads.length === 0) { this.deleteGroup(upload.groupId, false); } } upload.groupId = null; } // Move back to preview if (fieldEl.ui?.preview) { fieldEl.ui.preview.appendChild(uploadEl.element); uploadEl.location = fieldEl.ui.preview; } // Hide featured radio const featured = uploadEl.element.querySelector('[name="featured"]'); if (featured) { featured.hidden = true; featured.checked = false; } this.uploadStore.save(upload); this.updateSortableState(fieldEl.ui.preview); } removeUpload(fieldId, uploadId) { const fieldData = this.getFieldData(fieldId); const upload = this.uploadStore.get(uploadId); const uploadEl = this.uploadElements.get(uploadId); if (!fieldData || !upload) return; // Remove from field fieldData.uploads?.delete(uploadId); // Remove from group if grouped if (upload.groupId) { const group = fieldData.groups?.find(g => g.id === upload.groupId); if (group) { group.uploads = group.uploads.filter(id => id !== uploadId); if (group.uploads.length === 0) { this.deleteGroup(upload.groupId); } } } // Clean up element uploadEl?.element?.remove(); // Clean up memory this.clearUpload(uploadId); // Update field state this.saveFieldData(fieldData); this.updateFieldState(fieldId); this.maybeLockUploads(fieldId); const handler = this.selectionHandlers.get(fieldId); if (handler) { handler.deselect(uploadId); } this.a11y.announce('Upload removed'); } handleGroupMetaChange(input) { const groupEl = this.getGroupFromElement(input); if (!groupEl) return; const fieldData = this.getFieldData(groupEl.fieldId); const group = fieldData?.groups?.find(g => g.id === groupEl.element.dataset.groupId); if (!group) return; if (!group.changes) group.changes = {}; let name = input.name; if (name.includes('group')) { name = name.replace(`${group.id}_`, '').replace(`${group.id}[`, '').replace(']', ''); } group.changes[name] = input.value; this.saveFieldData(fieldData); this.schedulePersistance(groupEl.fieldId); } /******************************************************************************* * ACTION HANDLERS *******************************************************************************/ 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': const fieldEl = this.fieldElements.get(fieldId); if (fieldEl) { fieldEl.element.closest('details').open = false; document.body.classList.add('uploading'); this.submitUploads(fieldId); } break; case 'restore': this.handleRestoreUploads().then(() => {}); break; case 'restore-all': this.handleRestoreAll().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) { this.createGroup(fieldId); } else { const group = this.createGroup(fieldId); if (!group) return; selected.forEach(uploadId => { this.addToGroup(uploadId, group.grid); }); 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; } const items = group.querySelectorAll(this.selectors.items.item); items.forEach(item => { const uploadId = item.dataset.uploadId; this.removeFromGroup(uploadId); }); 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 *******************************************************************************/ addFieldSelectionHandler(fieldId) { if (this.selectionHandlers.has(fieldId)) { return this.selectionHandlers.get(fieldId); } const fieldEl = this.fieldElements.get(fieldId); if (!fieldEl?.element) return; const handler = new window.jvbHandleSelection({ container: fieldEl.element, ui: { selectAll: fieldEl.element.querySelector('[name="select-all-uploads"]'), bulkControls: fieldEl.element.querySelector('.selection-actions'), count: fieldEl.element.querySelector('.selection-count') }, itemSelector: '[data-upload-id]', checkboxSelector: '[name*="select-item"]' }); handler.subscribe((event, data) => { switch(event) { case 'item-selected': // Sync with Sortable this.syncSortableSelection(fieldId, data.selectedItems); this.selected.set(fieldId, data.selectedItems); break; case 'item-deselected': this.syncSortableSelection(fieldId, data.selectedItems); this.selected.set(fieldId, data.selectedItems); break; case 'range-selected': this.syncSortableSelection(fieldId, data.selectedItems); this.selected.set(fieldId, data.selectedItems); break; case 'select-all': this.handleSelectAll(data.container, data.selected); break; } }); this.selectionHandlers.set(fieldId, handler); return handler; } addGroupSelectionHandler(fieldId, groupId) { const handlerKey = `${fieldId}_${groupId}`; if (this.selectionHandlers.has(handlerKey)) { return this.selectionHandlers.get(handlerKey); } const groupEl = this.groupElements.get(groupId); if (!groupEl?.element) return; const handler = new window.jvbHandleSelection({ container: groupEl.element, ui: { selectAll: groupEl.element.querySelector(this.selectors.groups.selectAll), bulkControls: groupEl.element.querySelector(this.selectors.groups.actions), count: groupEl.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) { // Can add custom logic here if needed } /******************************************************************************* * HELPER METHODS *******************************************************************************/ /** * Get field data from store and normalize it * Always use this instead of directly accessing fieldStore.get() */ getFieldData(fieldId) { const fieldData = this.fieldStore.get(fieldId); if (!fieldData) return null; // Only convert uploads back to Set (DataStore returns Arrays) if (Array.isArray(fieldData.uploads)) { fieldData.uploads = new Set(fieldData.uploads); } else if (!fieldData.uploads) { fieldData.uploads = new Set(); } // Ensure groups is an array if (!Array.isArray(fieldData.groups)) { fieldData.groups = []; } return fieldData; } /** * Save field data to store, converting Sets to Arrays */ async saveFieldData(fieldData) { await this.fieldStore.save({ ...fieldData, timestamp: Date.now() }); } 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', getRuntimeData: (id) => this.fieldElements.get(id), getStoreData: (id) => this.getFieldData(id) }, 'upload': { selector: this.selectors.items.item, key: 'uploadId', getRuntimeData: (id) => this.uploadElements.get(id), getStoreData: (id) => this.uploadStore.get(id) }, 'group': { selector: this.selectors.groups.container, key: 'groupId', getRuntimeData: (id) => this.groupElements.get(id), getStoreData: (id) => { // Groups are stored in field.groups array const groupEl = this.groupElements.get(id); if (!groupEl) return null; const fieldData = this.getFieldData(groupEl.fieldId); return fieldData?.groups?.find(g => g.id === id); } } }; 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 combined runtime + store data for convenience const runtime = config.getRuntimeData(id); const store = config.getStoreData(id); return { ...runtime, ...store }; } getFieldFromElement(el) { return this.getFromElement(el, 'field'); } getUploadFromElement(el) { return this.getFromElement(el, 'upload'); } getGroupFromElement(el) { return this.getFromElement(el, 'group'); } getFieldIdFromElement(el) { const field = this.getFromElement(el, 'field'); return field?.id ?? null; } getUploadIdFromElement(el) { const upload = this.getFromElement(el, 'upload'); return upload?.id ?? null; } getGroupIdFromElement(el) { const group = this.getFromElement(el, 'group'); return group?.id ?? null; } 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) { const progress = { 'local_processing': 28, 'queued': 50, 'uploading': 66, 'pending': 75, 'processing': 89, 'completed': 100 }; return progress[status] || 0; } createUploadElement(upload, draggable = false) { let image = window.getTemplate('uploadItem'); if (!image) return; image.dataset.uploadId = upload.id; image.dataset.subtype = upload.subtype || 'image'; let [featured, img, video, preview, details] = [ image.querySelector('[name="featured"]'), image.querySelector('img'), image.querySelector('video'), image.querySelector('label > span'), image.querySelector('details') ]; if (featured) featured.value = upload.id; switch (upload.subtype) { case 'image': if (img) { img.src = upload.preview; img.alt = upload.meta?.originalName || ''; } video?.remove(); preview?.remove(); break; case 'video': if (video) video.src = upload.preview; img?.remove(); preview?.remove(); break; case 'document': const fileName = 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'); if (preview) { preview.innerText = fileName; 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 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; } /******************************************************************************* * PERSISTENCE *******************************************************************************/ schedulePersistance(fieldId) { const key = `persist_${fieldId}`; window.debouncer.schedule( key, () => this.persistFieldState(fieldId), 250 ); } async persistFieldState(fieldId) { const fieldData = this.getFieldData(fieldId); if (!fieldData) return; // Save with updated timestamp await this.saveFieldData(fieldData); } // In UploadManager, add blob conversion helpers async saveBlobData(uploadId, file) { const arrayBuffer = await file.arrayBuffer(); const uploadData = this.uploadStore.get(uploadId) || { id: uploadId }; // Store blob data as ArrayBuffer with metadata uploadData.blobData = { buffer: arrayBuffer, name: file.name, type: file.type, size: file.size, lastModified: file.lastModified || Date.now() }; await this.uploadStore.save(uploadData); } async getBlobData(uploadId) { const upload = this.uploadStore.get(uploadId); if (!upload?.blobData) return null; // Reconstruct File from ArrayBuffer const blob = new Blob([upload.blobData.buffer], { type: upload.blobData.type }); return new File([blob], upload.blobData.name, { type: upload.blobData.type, lastModified: upload.blobData.lastModified }); } /******************************************************************************* HELPER to GET UPLOADED FILES *******************************************************************************/ /** * Get all files for a form (searches all upload fields within form) */ async getFilesForForm(formElement) { const uploadFields = formElement.querySelectorAll('[data-upload-field]'); const allFiles = []; for (const field of uploadFields) { const fieldId = this.determineFieldId(field); const files = await this.getFilesForField(fieldId); allFiles.push(...files); } return allFiles; } /** * Get all files for a specific field */ async getFilesForField(fieldId) { const fieldData = this.getFieldData(fieldId); if (!fieldData?.uploads) return []; const files = []; const uploadsArray = fieldData.uploads instanceof Set ? Array.from(fieldData.uploads) : fieldData.uploads; for (const uploadId of uploadsArray) { const upload = this.uploadStore.get(uploadId); if (!upload) continue; // Get the actual File object from blob data const file = await this.getBlobData(uploadId); if (file) { files.push({ file: file, uploadId: uploadId, fieldName: fieldData.config.name, meta: upload.meta || {} }); } } return files; } /******************************************************************************* * RECOVERY & RESTORATION *******************************************************************************/ handleFieldStoreEvent(event, data) { switch(event) { case 'data-loaded': this.fieldStoreReady = true; this.checkIfBothStoresReady(); break; } } handleUploadStoreEvent(event, data) { switch(event) { case 'data-loaded': this.uploadStoreReady = true; this.checkIfBothStoresReady(); break; case 'item-saved': this.showSaveIndicator(data.key); break; } } checkIfBothStoresReady() { if (this.fieldStoreReady && this.uploadStoreReady && !this.hasCheckedForUploads) { this.hasCheckedForUploads = true; this.checkForStoredUploads(); } } async checkForStoredUploads() { const allFieldStates = this.fieldStore.getAll(); const pendingFields = allFieldStates.filter(field => { if (!field.uploads) return false; // Handle both Set and Array (from IndexedDB) const uploadsArray = field.uploads instanceof Set ? Array.from(field.uploads) : Array.isArray(field.uploads) ? field.uploads : []; return uploadsArray.some(uploadId => { const upload = this.uploadStore.get(uploadId); return upload && !upload.operationId && ['completed', 'processed', 'local_processing', 'processed-original'].includes(upload.status); }); }); if (pendingFields.length === 0) return; await this.showRecoveryNotification(pendingFields); } 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 (let uploadId of field.uploads) { const upload = this.uploadStore.get(uploadId); let uploadItem = window.getTemplate('uploadItem'); if (!uploadItem) continue; // // const imgEl = uploadItem.querySelector('img'); // const placeholderEl = uploadItem.querySelector('.image-placeholder'); // const file = await this.getBlobData(upload.id); if (file) { try { // Create new blob URL from stored data const previewUrl = this.createPreviewUrl(file); 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(file.type); uploadItem.dataset.subtype = subtype; switch (subtype) { case 'image': [ img.src, img.alt ] = [ previewUrl, file.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 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(); } async handleRestoreAll() { let notification = document.querySelector('dialog.restore-uploads'); if (!notification) { return; } // Gets ALL uploads from notification without checking selection const allUploads = []; notification.querySelectorAll('.item.upload').forEach(item => { let uploadId = item.dataset.uploadId; let fieldId = item.dataset.fieldId; allUploads.push({ uploadId, fieldId }); }); await this.restoreSelectedUploads(allUploads); this.cleanupRestore(); } showSaveIndicator(key) { // Optional: show user that state is being saved } cleanupRestore() { this.restoreModal.handleClose(); this.restoreSelection.destroy(); this.restoreSelection = null; this.restoreModal.destroy(); this.restoreModal.modal.remove(); this.restoreModal = null; } async cleanupStoredUploads() { await this.fieldStore.clear(); await this.uploadStore.clear(); } /******************************************************************************* * EVENT SYSTEM *******************************************************************************/ subscribe(callback) { this.subscribers.add(callback); return () => this.subscribers.delete(callback); } notify(event, data = {}) { this.subscribers.forEach(cb => { try { cb(event, data); } catch (error) { console.error('Subscriber error:', error); } }); } /******************************************************************************* * DESTROY & CLEANUP *******************************************************************************/ destroy() { 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); if (this.dragController) { this.dragController.destroy(); } this.selectionHandlers.forEach(handler => handler.destroy()); this.selectionHandlers.clear(); this.cleanupAllPreviewUrls(); this.sortableInstances.forEach(instance => { if (instance?.destroy) instance.destroy(); }); this.sortableInstances.clear(); this.uploadElements.clear(); this.fieldElements.clear(); this.groupElements.clear(); this.selected.clear(); this.subscribers.clear(); } } // Initialize when DOM is ready document.addEventListener('DOMContentLoaded', async function () { window.auth.subscribe((event) => { if (event === 'auth-loaded') { window.jvbUploads = new UploadManager(); } }); });