/** * UploadManager - Refactored with simplified store architecture * * Architecture: * - uploadStore: Individual uploads with blob data, keyed by uploadId * - groupStore: Group metadata + upload references, keyed by groupId * - Runtime Maps: DOM references only (fieldElements, uploadElements, groupElements) * * Flow: File → Process → Store → Queue → Server → Clear stores * Recovery: Check stores on load → Show notification → Restore to DOM */ class UploadManager { constructor() { this.queue = window.jvbQueue; this.a11y = window.jvbA11y; this.error = window.jvbError; // Store initialization flags this.storesReady = false; this.hasCheckedForRecovery = false; // Register stores const { uploads, groups } = window.jvbStore.register( 'uploads', [ { storeName: 'uploads', keyPath: 'id', storeBlobs: true, indexes: [ { name: 'fieldId', keyPath: 'fieldId' }, { name: 'status', keyPath: 'status' }, { name: 'groupId', keyPath: 'groupId' }, { name: 'pageUrl', keyPath: 'pageUrl' } ], TTL: 7 * 24 * 60 * 60 * 1000, // 1 week delayFetch: true }, { storeName: 'groups', keyPath: 'id', indexes: [ { name: 'fieldId', keyPath: 'fieldId' }, { name: 'pageUrl', keyPath: 'pageUrl' } ], TTL: 7 * 24 * 60 * 60 * 1000, delayFetch: true } ] ); this.uploadStore = uploads; this.groupStore = groups; // Subscribe to store events this.uploadStore.subscribe(this.handleStoreEvent.bind(this, 'uploads')); this.groupStore.subscribe(this.handleStoreEvent.bind(this, 'groups')); // RUNTIME DATA - DOM references only, not persisted this.fieldElements = new Map(); // fieldId → { element, ui, config } this.uploadElements = new Map(); // uploadId → { element, preview, location } this.groupElements = new Map(); // groupId → { element, grid, fieldId } // Selection state this.selected = new Map(); // fieldId -> { } this.selectionHandlers = new Map(); this.sortableInstances = new Map(); // Preview URL tracking for cleanup this.previewUrls = new Set(); // Worker for image processing this.worker = this.createWorkerConfig(); // 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', 'processing': 'Processing Image...', 'queued': 'Waiting to upload...', 'uploading': 'Uploading to Server', 'pending': 'Sent to server, awaiting processing.', 'server_processing': 'Processing on server...', 'completed': 'Upload complete!', 'failed': 'Upload failed (will retry)', 'failed_permanent': 'Upload failed permanently' }; this.init(); } /******************************************************************************* * INITIALIZATION *******************************************************************************/ async init() { this.initListeners(); this.initQueueSubscription(); window.addEventListener('beforeunload', () => this.cleanupAllPreviewUrls()); } createWorkerConfig() { return { worker: null, tasks: new Map(), restart: { count: 0, max: 3 }, settings: { timeout: 10000, maxConcurrent: 3, restartAfterTimeout: true } }; } initQueueSubscription() { 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; } }); } /******************************************************************************* * STORE EVENT HANDLING & RECOVERY *******************************************************************************/ handleStoreEvent(storeName, event, data) { if (event === 'data-loaded') { this.checkStoresReady(); } } checkStoresReady() { // Both stores need to be ready before checking for recovery const uploadsReady = this.uploadStore.getStore()._initialized; const groupsReady = this.groupStore.getStore()._initialized; if (uploadsReady && groupsReady && !this.hasCheckedForRecovery) { this.hasCheckedForRecovery = true; this.storesReady = true; this.checkForRecoverableUploads(); } } async checkForRecoverableUploads() { const allUploads = this.uploadStore.getAll(); // Find uploads that weren't completed and don't have an active operation const recoverableUploads = allUploads.filter(upload => !upload.operationId && ['processed', 'processing', 'queued'].includes(upload.status) ); if (recoverableUploads.length === 0) return; // Group by fieldId for display const byField = this.groupUploadsByField(recoverableUploads); await this.showRecoveryNotification(byField); } groupUploadsByField(uploads) { const byField = new Map(); uploads.forEach(upload => { if (!byField.has(upload.fieldId)) { byField.set(upload.fieldId, { fieldId: upload.fieldId, pageUrl: upload.pageUrl, uploads: [], groups: new Map() }); } const fieldData = byField.get(upload.fieldId); fieldData.uploads.push(upload); // Track groups if (upload.groupId) { const group = this.groupStore.get(upload.groupId); if (group) { fieldData.groups.set(upload.groupId, group); } } }); return byField; } /******************************************************************************* * FIELD MANAGEMENT - Runtime only, no persistence *******************************************************************************/ scanFields(container, autoUpload = false) { const fields = container.querySelectorAll(this.selectors.field.field); fields.forEach(uploader => this.registerUploader(uploader, autoUpload)); } registerUploader(uploader, autoUpload = false) { const fieldId = this.determineFieldId(uploader); const config = this.extractFieldConfig(uploader, autoUpload); const ui = this.buildFieldUI(uploader); // Store DOM references only - not persisted 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, 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) { const 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: { container: 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') } }; const display = fieldElement.querySelector('.group-display'); if (display) { UI.groups = { display, container: fieldElement.querySelector('.item-grid.groups'), empty: fieldElement.querySelector('.empty-group'), groups: new Map() }; } return UI; } /** * Get uploads for a field - derived from store, not cached */ getFieldUploads(fieldId) { return this.uploadStore.getAll().filter(u => u.fieldId === fieldId); } /** * Get groups for a field - derived from store */ getFieldGroups(fieldId) { return this.groupStore.getAll().filter(g => g.fieldId === fieldId); } /** * Get upload count for a field */ getFieldUploadCount(fieldId) { return this.getFieldUploads(fieldId).length; } /******************************************************************************* * FILE PROCESSING *******************************************************************************/ async processFiles(fieldId, files) { const fieldEl = this.fieldElements.get(fieldId); if (!fieldEl) return; const { config, ui } = fieldEl; // Show group display, hide upload zone if (ui.dropZone) ui.dropZone.hidden = true; if (ui.groups?.display) ui.groups.display.hidden = false; const totalFiles = files.length; let processedCount = 0; this.updateFieldProgress(fieldId, 0, totalFiles, 'Processing files...'); const processPromises = Array.from(files).map(async (file) => { try { const uploadId = this.generateId('upload'); // Create upload data const uploadData = { id: uploadId, fieldId, pageUrl: window.location.href, status: 'processing', groupId: null, attachmentId: null, meta: { originalName: file.name, size: file.size, type: file.type } }; // Save initial state await this.uploadStore.save(uploadData); // Process file const preview = this.createPreviewUrl(file); const processedFile = file.type.startsWith('image/') ? await this.processImage(file, uploadId) : file; // Show progress this.showUploadProgress(uploadId, true); this.updateUploadItemProgress(uploadId, 50, 'processing'); // Store blob data await this.saveBlobData(uploadId, processedFile || file); // Create DOM element const subtype = this.getSubtypeFromMime(file.type); const element = this.createUploadElement({ id: uploadId, preview, meta: uploadData.meta, subtype }, config.destination === 'post_group'); // Add to preview grid if (ui.preview) { ui.preview.appendChild(element); this.uploadElements.set(uploadId, { element, preview, location: ui.preview }); } // Update status await this.updateUploadStatus(uploadId, 'processed'); // Update progress processedCount++; this.updateFieldProgress(fieldId, processedCount, totalFiles, 'Processing files...'); this.updateUploadItemProgress(uploadId, 100, 'processed'); setTimeout(() => this.showUploadProgress(uploadId, false), 1000); return uploadId; } catch (error) { console.error('Error processing file:', file.name, error); processedCount++; this.updateFieldProgress(fieldId, processedCount, totalFiles, 'Processing files...'); return null; } }); await Promise.all(processPromises); this.updateFieldState(fieldId); this.refreshSortable(fieldId); // Queue for upload if auto-upload enabled if (config.autoUpload && config.destination !== 'post_group') { await this.queueUpload(fieldId); this.maybeLockUploads(fieldId); } } /******************************************************************************* * IMAGE PROCESSING (unchanged logic, just cleaner structure) *******************************************************************************/ async processImage(file, uploadId) { const timeout = this.worker.settings.timeout; return new Promise((resolve, reject) => { let timeoutId; let completed = false; timeoutId = setTimeout(() => { if (!completed) { completed = true; this.worker.tasks.delete(uploadId); if (this.worker.settings.restartAfterTimeout) { this.restartWorker(); } reject(new Error(`Processing timeout for ${file.name}`)); } }, timeout); this.worker.tasks.set(uploadId, { file, timeoutId }); this.handleImageProcess(file, uploadId) .then(result => { if (!completed) { completed = true; clearTimeout(timeoutId); this.worker.tasks.delete(uploadId); resolve(result); } }) .catch(error => { if (!completed) { completed = true; clearTimeout(timeoutId); this.worker.tasks.delete(uploadId); reject(error); } }); }); } async handleImageProcess(file, uploadId) { if (!file.type.startsWith('image/')) return file; const maxDimension = this.getMaxDimension(); const quality = 0.95; if (this.shouldUseWorker(file)) { try { if (!this.worker.worker) this.initWorker(); if (this.worker.worker) { return await this.processWithWorker(file, uploadId, maxDimension, quality); } } catch (error) { console.warn('Worker failed, using main thread:', error); } } return 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 = img.onerror = null; if (objectUrl) { URL.revokeObjectURL(objectUrl); objectUrl = null; } canvas.width = canvas.height = 1; ctx.clearRect(0, 0, 1, 1); }; img.onload = () => { try { const { width, height } = this.calculateDimensions(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) { resolve(new File( [blob], this.getProcessedFileName(file, outputFormat), { type: outputFormat, lastModified: Date.now() } )); } 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}`)); } }); } // Worker methods (simplified) initWorker() { 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 }); self.postMessage({ messageId, success: true, 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 worker:', error); this.worker.worker = null; } } 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 outputFormat = this.getOptimalFormat(file); const handler = (e) => { if (e.data.messageId !== messageId) return; this.worker.worker.removeEventListener('message', handler); this.worker.worker.removeEventListener('error', errorHandler); if (e.data.success) { resolve(new File( [e.data.blob], this.getProcessedFileName(file, e.data.format || outputFormat), { type: e.data.format || outputFormat, lastModified: Date.now() } )); } else { reject(new Error(e.data.error || 'Worker processing failed')); } }; const errorHandler = (error) => { this.worker.worker.removeEventListener('message', handler); this.worker.worker.removeEventListener('error', errorHandler); reject(new Error(`Worker error: ${error.message}`)); }; this.worker.worker.addEventListener('message', handler); this.worker.worker.addEventListener('error', errorHandler); this.worker.worker.postMessage({ messageId, file, maxDimension, quality, outputFormat }); }); } restartWorker() { 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) { this.worker.restart.count++; this.initWorker(); } } // Image processing helpers calculateDimensions(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) }; } getMaxDimension() { const screenWidth = window.screen.width; const dpr = window.devicePixelRatio || 1; if (screenWidth * dpr > 2560) return 2400; if (screenWidth * dpr > 1920) return 1920; return 1200; } shouldUseWorker(file) { return this.worker.worker && file.size > 1024 * 1024 && typeof OffscreenCanvas !== 'undefined'; } 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(file, outputFormat) { const baseName = file.name.replace(/\.[^/.]+$/, ''); const extensions = { 'image/webp': '.webp', 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif' }; return baseName + (extensions[outputFormat] || '.jpg'); } supportsWebP() { const canvas = document.createElement('canvas'); return canvas.toDataURL('image/webp').startsWith('data:image/webp'); } /******************************************************************************* * BLOB DATA MANAGEMENT *******************************************************************************/ async saveBlobData(uploadId, file) { const arrayBuffer = await file.arrayBuffer(); const upload = this.uploadStore.get(uploadId) || { id: uploadId }; upload.blobData = { buffer: arrayBuffer, name: file.name, type: file.type, size: file.size, lastModified: file.lastModified || Date.now() }; await this.uploadStore.save(upload); } async getBlobData(uploadId) { const upload = this.uploadStore.get(uploadId); if (!upload?.blobData) return null; 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 }); } /******************************************************************************* * QUEUE INTEGRATION *******************************************************************************/ async queueUpload(fieldId) { const uploads = this.getFieldUploads(fieldId); if (uploads.length === 0) return; const fieldEl = this.fieldElements.get(fieldId); if (!fieldEl) return; const formData = await this.prepareUploadFormData(fieldId, uploads, fieldEl.config); this.a11y.announce('Queuing for upload'); const operation = { endpoint: 'uploads', method: 'POST', data: formData, title: `Uploading ${uploads.length} file${uploads.length > 1 ? 's' : ''}...`, 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 for (const upload of uploads) { upload.operationId = operationId; upload.status = 'queued'; await this.uploadStore.save(upload); this.updateUploadUI(upload.id); } return operationId; } catch (error) { throw error; } } async prepareUploadFormData(fieldId, uploads, config) { const formData = new FormData(); formData.append('content', config.content); formData.append('mode', config.mode); formData.append('field_name', config.name); formData.append('fieldId', fieldId); formData.append('field_type', config.type); formData.append('subtype', config.subtype); formData.append('item_id', config.itemID); formData.append('destination', config.destination || 'meta'); const uploadMap = []; for (const upload of uploads) { const file = await this.getBlobData(upload.id); if (file) { formData.append('files[]', file); uploadMap.push(upload.id); } } formData.append('upload_ids', JSON.stringify(uploadMap)); return formData; } async submitGroupedUploads(fieldId) { const uploads = this.getFieldUploads(fieldId); const groups = this.getFieldGroups(fieldId); const fieldEl = this.fieldElements.get(fieldId); if (uploads.length === 0 || !fieldEl) return; const formData = new FormData(); const posts = []; const uploadMap = []; // Process each group for (const group of groups) { const groupUploads = uploads.filter(u => u.groupId === group.id); const post = { images: [], fields: { ...group.fields } }; for (const upload of groupUploads) { const file = await this.getBlobData(upload.id); if (file) { formData.append('files[]', file); post.images.push({ upload_id: upload.id, index: uploadMap.length }); uploadMap.push(upload.id); // Check featured const uploadEl = this.uploadElements.get(upload.id); const featured = uploadEl?.element?.querySelector('[name="featured"]'); if (featured?.checked) { post.fields.featured = upload.id; } } } if (post.images.length > 0) { posts.push(post); } } // Handle ungrouped uploads - each becomes its own post const ungrouped = uploads.filter(u => !u.groupId); for (const upload of ungrouped) { const file = await this.getBlobData(upload.id); if (file) { formData.append('files[]', file); posts.push({ images: [{ upload_id: upload.id, index: uploadMap.length }], fields: {} }); uploadMap.push(upload.id); } } formData.append('content', fieldEl.config.content); formData.append('user', fieldEl.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} ${fieldEl.config.content}${posts.length > 1 ? 's' : ''}...`, 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); for (const upload of uploads) { upload.operationId = operationId; upload.status = 'queued'; await this.uploadStore.save(upload); this.updateUploadUI(upload.id); } this.a11y.announce(`Creating ${posts.length} post${posts.length > 1 ? 's' : ''}`); return operationId; } catch (error) { this.error.log(error, { component: 'UploadManager', action: 'submitGroupedUploads', fieldId }); throw error; } } async queueUploadMeta(e) { const uploadId = this.getUploadIdFromElement(e.target); const upload = this.uploadStore.get(uploadId); if (!upload) return; const data = { [e.target.name]: e.target.value }; upload.meta = { ...upload.meta, ...data }; await this.uploadStore.save(upload); const 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: 'queueUploadMeta', uploadId }); } } /******************************************************************************* * QUEUE EVENT HANDLERS - Cleanup after success *******************************************************************************/ async handleOperationComplete(operation, fieldId) { const results = operation.result?.data || operation.serverData?.data || []; // Update attachment IDs for (const result of results) { const upload = this.uploadStore.get(result.upload_id); if (upload) { upload.attachmentId = result.attachment_id; upload.status = 'completed'; await this.uploadStore.save(upload); this.updateUploadUI(result.upload_id); } } if (!fieldId) return; // Clear completed uploads and their groups await this.clearCompletedUploads(fieldId); this.updateFieldState(fieldId); this.a11y.announce('All uploads completed successfully'); } async clearCompletedUploads(fieldId) { const uploads = this.getFieldUploads(fieldId); const groupIds = new Set(); for (const upload of uploads) { if (upload.status === 'completed') { if (upload.groupId) groupIds.add(upload.groupId); await this.clearUpload(upload.id); } } // Clear associated groups for (const groupId of groupIds) { await this.groupStore.delete(groupId); this.groupElements.delete(groupId); } } handleOperationFailed(operation, fieldId) { const uploadIds = operation.data instanceof FormData ? JSON.parse(operation.data.get('upload_ids') || '[]') : operation.data?.upload_ids || []; const status = operation.status === 'operation-failed-permanent' ? 'failed_permanent' : 'failed'; uploadIds.forEach(async uploadId => { await this.updateUploadStatus(uploadId, status); }); if (fieldId) this.updateFieldState(fieldId); } async handleOperationCancelled(fieldId) { const uploads = this.getFieldUploads(fieldId); const groups = this.getFieldGroups(fieldId); for (const upload of uploads) { await this.clearUpload(upload.id); } for (const group of groups) { await this.groupStore.delete(group.id); this.groupElements.delete(group.id); } this.updateFieldState(fieldId); this.a11y.announce('Upload cancelled'); } /******************************************************************************* * GROUP MANAGEMENT *******************************************************************************/ async createGroup(fieldId, groupId = null) { const fieldEl = this.fieldElements.get(fieldId); if (!fieldEl) return null; groupId = groupId || this.generateId('group'); // Create group data const groupData = { id: groupId, fieldId, pageUrl: window.location.href, uploads: [], fields: {} }; await this.groupStore.save(groupData); // Create DOM element const element = this.createGroupElement(groupId, fieldId); if (!element) return null; const grid = element.querySelector('.item-grid.group'); // Store DOM references this.groupElements.set(groupId, { element, grid, fieldId }); // Insert into DOM if (fieldEl.ui.groups?.container && fieldEl.ui.groups.empty) { fieldEl.ui.groups.container.insertBefore(element, fieldEl.ui.groups.empty); } else if (fieldEl.ui.groups?.container) { fieldEl.ui.groups.container.appendChild(element); } // Initialize sortable and selection this.addGroupSelectionHandler(fieldId, groupId); if (grid) this.createSortableForGrid(grid, fieldId, groupId); return { id: groupId, element, grid }; } createGroupElement(groupId, fieldId) { const element = window.getTemplate('imageGroup'); if (!element) return null; element.dataset.groupId = groupId; element.dataset.fieldId = fieldId; const fields = window.getTemplate('groupMetadata'); const fieldsContainer = element.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 fieldEl = this.fieldElements.get(fieldId); if (fieldEl?.config.content) { const summary = element.querySelector('summary'); if (summary) summary.textContent = fieldEl.config.content + ' Fields'; } } else { element.querySelector('details')?.remove(); } const gridContainer = element.querySelector('.item-grid.group'); if (gridContainer) gridContainer.dataset.groupId = groupId; return element; } async deleteGroup(groupId, moveUploadsToPreview = true) { const groupEl = this.groupElements.get(groupId); if (!groupEl) return; const group = this.groupStore.get(groupId); const fieldEl = this.fieldElements.get(groupEl.fieldId); if (moveUploadsToPreview && group?.uploads) { for (const uploadId of group.uploads) { await this.removeFromGroup(uploadId); } } // Remove from store await this.groupStore.delete(groupId); // Remove DOM element groupEl.element?.remove(); // Cleanup this.groupElements.delete(groupId); const sortableKey = `${groupEl.fieldId}-group-${groupId}`; this.sortableInstances.get(sortableKey)?.destroy(); this.sortableInstances.delete(sortableKey); this.a11y.announce('Group removed'); } async addToGroup(uploadId, targetGrid, persist = true) { const upload = this.uploadStore.get(uploadId); const uploadEl = this.uploadElements.get(uploadId); if (!upload || !uploadEl) return; const groupId = targetGrid.dataset.groupId; const group = this.groupStore.get(groupId); // Remove from previous group if needed if (upload.groupId && upload.groupId !== groupId) { const oldGroup = this.groupStore.get(upload.groupId); if (oldGroup) { oldGroup.uploads = oldGroup.uploads.filter(id => id !== uploadId); if (oldGroup.uploads.length === 0) { await this.deleteGroup(upload.groupId, false); } else { await this.groupStore.save(oldGroup); } } } // Add to new group upload.groupId = groupId; if (group && !group.uploads.includes(uploadId)) { group.uploads.push(uploadId); await this.groupStore.save(group); } // Update upload await this.uploadStore.save(upload); // Move DOM element targetGrid.appendChild(uploadEl.element); uploadEl.location = targetGrid; // Show featured radio const featured = uploadEl.element.querySelector('[name="featured"]'); if (featured) { featured.hidden = false; featured.name = `${groupId}_featured`; } // Clear checkbox const checkbox = uploadEl.element.querySelector('[name*="select-item"]'); if (checkbox) checkbox.checked = false; this.updateSortableState(targetGrid); } async removeFromGroup(uploadId) { const upload = this.uploadStore.get(uploadId); const uploadEl = this.uploadElements.get(uploadId); if (!upload || !uploadEl) return; const fieldEl = this.fieldElements.get(upload.fieldId); if (!fieldEl?.ui?.preview) return; // Update group if (upload.groupId) { const group = this.groupStore.get(upload.groupId); if (group) { group.uploads = group.uploads.filter(id => id !== uploadId); if (group.uploads.length === 0) { await this.deleteGroup(upload.groupId, false); } else { await this.groupStore.save(group); } } upload.groupId = null; await this.uploadStore.save(upload); } // Move to 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.updateSortableState(fieldEl.ui.preview); } async removeUpload(fieldId, uploadId) { const upload = this.uploadStore.get(uploadId); const uploadEl = this.uploadElements.get(uploadId); if (upload?.groupId) { const group = this.groupStore.get(upload.groupId); if (group) { group.uploads = group.uploads.filter(id => id !== uploadId); if (group.uploads.length === 0) { await this.deleteGroup(upload.groupId, false); } else { await this.groupStore.save(group); } } } uploadEl?.element?.remove(); await this.clearUpload(uploadId); this.updateFieldState(fieldId); this.maybeLockUploads(fieldId); this.selectionHandlers.get(fieldId)?.deselect(uploadId); this.a11y.announce('Upload removed'); } async handleGroupMetaChange(input) { const groupEl = input.closest(this.selectors.groups.container); if (!groupEl) return; const groupId = groupEl.dataset.groupId; const group = this.groupStore.get(groupId); if (!group) return; let name = input.name; if (name.includes(groupId)) { name = name.replace(`${groupId}_`, '').replace(`${groupId}[`, '').replace(']', ''); } group.fields[name] = input.value; await this.groupStore.save(group); } /******************************************************************************* * SORTABLE & DRAG/DROP *******************************************************************************/ initSortable(fieldId) { if (!window.Sortable) return; if (!Sortable._multiDragMounted && Sortable.MultiDrag) { Sortable.mount(new Sortable.MultiDrag()); Sortable._multiDragMounted = true; } const fieldEl = this.fieldElements.get(fieldId); if (!fieldEl) return; 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); }); // Empty group drop zone 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', onEnd: (evt) => this.handleDrop(evt, fieldId) }); } } createSortableForGrid(grid, fieldId, groupId = null) { if (!grid || grid.sortableInstance) return; const instance = 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', onEnd: (evt) => this.handleDrop(evt, fieldId), onSelect: (evt) => this.syncCheckboxToSortable(evt.item, true), onDeselect: (evt) => this.syncCheckboxToSortable(evt.item, false), onAdd: (evt) => this.updateSortableState(evt.to), onRemove: (evt) => this.updateSortableState(evt.from) }); grid.sortableInstance = instance; const gridId = groupId ? `${fieldId}-group-${groupId}` : `${fieldId}-preview`; this.sortableInstances.set(gridId, instance); return instance; } syncCheckboxToSortable(item, selected) { const checkbox = item.querySelector('[name*="select-item"]'); if (checkbox && checkbox.checked !== selected) { checkbox.checked = selected; checkbox.dispatchEvent(new Event('change', { bubbles: true })); } } /** * Unified drop handler - consolidated from multiple methods */ async handleDrop(evt, fieldId) { const target = evt.to; const source = evt.from; const items = evt.items?.length > 0 ? evt.items : [evt.item]; const uploadIds = items.map(item => item.dataset.uploadId); // Same container = reorder only if (target === source) { this.handleReorder(evt); return; } const targetType = this.getDropTargetType(target); try { switch (targetType) { case 'empty-group': const group = await this.createGroup(fieldId); if (!group) throw new Error('Group creation failed'); for (const uploadId of uploadIds) { await this.addToGroup(uploadId, group.grid, false); } break; case 'preview': for (const uploadId of uploadIds) { await this.removeFromGroup(uploadId); } break; case 'group': for (const uploadId of uploadIds) { await this.addToGroup(uploadId, target, false); } break; default: throw new Error('Unknown drop target'); } this.finalizeDrop(fieldId, items.length, targetType); } catch (error) { 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('An error occurred. Items returned to preview.'); } this.updateSortableState(target); if (source !== target) this.updateSortableState(source); } finalizeDrop(fieldId, count, targetType) { const messages = { 'empty-group': count > 1 ? `Created group with ${count} items` : 'Created group with item', 'preview': count > 1 ? `Moved ${count} items to preview` : 'Moved item to preview', 'group': count > 1 ? `Moved ${count} items to group` : 'Moved item to group' }; this.a11y.announce(messages[targetType] || 'Items moved'); this.selectionHandlers.get(fieldId)?.clearSelection(); } 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'; } handleReorder(evt) { const grid = evt.to; const fieldWrapper = grid.closest('.field, .upload'); if (!fieldWrapper) return; const items = Array.from(grid.querySelectorAll('.item:not(.sortable-ghost):not(.sortable-clone)')) .map(el => el.dataset.uploadId) .filter(Boolean); const hiddenInput = fieldWrapper.querySelector('input[type="hidden"]'); if (hiddenInput && items.length > 0) { hiddenInput.value = items.join(','); } // Update group order in store const groupId = grid.dataset.groupId; if (groupId) { const group = this.groupStore.get(groupId); if (group) { group.uploads = items; this.groupStore.save(group); } } this.a11y.announce('Item reordered'); fieldWrapper.dispatchEvent(new CustomEvent('jvb-items-reordered', { detail: { from: evt.from, to: evt.to, items }, bubbles: true })); } updateSortableState(grid) { const sortable = grid?.sortableInstance; if (sortable) sortable.option('disabled', false); } 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)); } syncSortableSelection(fieldId, selectedItems) { this.sortableInstances.forEach((instance, key) => { if (key.startsWith(fieldId)) { instance.el.querySelectorAll('.item').forEach(item => { const uploadId = item.dataset.uploadId; if (selectedItems.has(uploadId)) { Sortable.utils.select(item); } else { Sortable.utils.deselect(item); } }); } }); } /******************************************************************************* * EVENT LISTENERS *******************************************************************************/ initListeners() { this.clickHandler = this.handleClick.bind(this); this.changeHandler = this.handleChange.bind(this); this.dragEnterHandler = this.handleDragEnter.bind(this); this.dragLeaveHandler = this.handleDragLeave.bind(this); this.dragOverHandler = this.handleDragOver.bind(this); this.dropHandler = this.handleExternalDrop.bind(this); document.addEventListener('click', this.clickHandler); document.addEventListener('change', this.changeHandler); document.addEventListener('dragenter', this.dragEnterHandler); document.addEventListener('dragleave', this.dragLeaveHandler); document.addEventListener('dragover', this.dragOverHandler); document.addEventListener('drop', this.dropHandler); } handleClick(e) { // Trigger file input const dropZone = e.target.closest(this.selectors.field.dropZone); if (dropZone && !e.target.matches('input, button, a')) { dropZone.querySelector(this.selectors.field.input)?.click(); } // Action buttons const actionButton = e.target.closest('[data-action]'); if (actionButton) this.handleAction(actionButton); } handleChange(e) { const fieldId = this.getFieldIdFromElement(e.target); if (!fieldId) return; // File input if (e.target.matches(this.selectors.field.input)) { const files = Array.from(e.target.files); if (files.length > 0) this.processFiles(fieldId, files); return; } // Meta field changes const fieldEl = this.fieldElements.get(fieldId); if (!fieldEl?.config.autoUpload) return; if (fieldEl.config.destination === 'post_group') { this.handleGroupMetaChange(e.target); } else { this.queueUploadMeta(e); } } handleDragEnter(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'); } } handleDragLeave(e) { const dropZone = e.target.closest(this.selectors.field.dropZone); if (dropZone && !dropZone.contains(e.relatedTarget)) { dropZone.classList.remove('dragover'); } } handleDragOver(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`); } } /******************************************************************************* * ACTION HANDLERS *******************************************************************************/ handleAction(button) { const action = button.dataset.action; const fieldId = this.getFieldIdFromElement(button); switch (action) { case 'add-to-group': this.handleAddToGroup(fieldId); break; case 'delete-group': this.handleDeleteGroup(button); break; case 'delete-upload': case 'remove-from-group': this.handleRemoveItem(button); break; case 'upload': this.handleSubmitUploads(fieldId); break; case 'restore': this.handleRestoreSelected(); break; case 'restore-all': this.handleRestoreAll(); break; case 'clear-cache': this.handleClearCache(); break; } } async handleAddToGroup(fieldId) { const selected = this.selected.get(fieldId); if (!selected || selected.size === 0) { await this.createGroup(fieldId); } else { const group = await this.createGroup(fieldId); if (!group) return; for (const uploadId of selected) { await this.addToGroup(uploadId, group.grid, false); } this.selectionHandlers.get(fieldId)?.clearSelection(); this.a11y.announce(`Created group with ${selected.size} items`); } } async handleDeleteGroup(button) { const group = button.closest(this.selectors.groups.container); if (!group) return; const groupId = group.dataset.groupId; if (!confirm('Delete this group? Items will be moved back to the upload area.')) { return; } await this.deleteGroup(groupId, true); } async handleRemoveItem(button) { const item = button.closest(this.selectors.items.item); if (!item) return; if (!confirm('Remove this item?')) return; const uploadId = item.dataset.uploadId; const fieldId = this.getFieldIdFromElement(item); await this.removeUpload(fieldId, uploadId); } handleSubmitUploads(fieldId) { const fieldEl = this.fieldElements.get(fieldId); if (!fieldEl) return; fieldEl.element.closest('details')?.removeAttribute('open'); document.body.classList.add('uploading'); if (fieldEl.config.destination === 'post_group') { this.submitGroupedUploads(fieldId); } else { this.queueUpload(fieldId); } } /******************************************************************************* * SELECTION MANAGEMENT *******************************************************************************/ addFieldSelectionHandler(fieldId) { if (this.selectionHandlers.has(fieldId)) return; 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) => { if (['item-selected', 'item-deselected', 'range-selected'].includes(event)) { this.syncSortableSelection(fieldId, data.selectedItems); this.selected.set(fieldId, data.selectedItems); } }); this.selectionHandlers.set(fieldId, handler); } addGroupSelectionHandler(fieldId, groupId) { const key = `${fieldId}_${groupId}`; if (this.selectionHandlers.has(key)) return; 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) => { if (['item-selected', 'item-deselected', 'range-selected'].includes(event)) { this.selected.set(fieldId, data.selectedItems); } }); this.selectionHandlers.set(key, handler); } /******************************************************************************* * UI UPDATES *******************************************************************************/ updateFieldState(fieldId) { const fieldEl = this.fieldElements.get(fieldId); if (!fieldEl) return; const uploadCount = this.getFieldUploadCount(fieldId); const groupCount = this.getFieldGroups(fieldId).length; fieldEl.element.dataset.hasUploads = uploadCount > 0; fieldEl.element.dataset.uploadCount = uploadCount; fieldEl.element.dataset.hasGroups = groupCount > 0; if (fieldEl.ui.preview) { fieldEl.ui.preview.setAttribute('aria-label', `Upload preview area with ${uploadCount} item${uploadCount !== 1 ? 's' : ''}` ); } } updateFieldProgress(fieldId, current, total, message) { const fieldEl = this.fieldElements.get(fieldId); const progress = fieldEl?.ui?.progress; if (!progress?.container) return; 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.container.hidden = current === total; } updateFieldStatus(fieldId, status) { // Status is now derived from uploads, no field-level status needed } async updateUploadStatus(uploadId, status) { const upload = this.uploadStore.get(uploadId); if (!upload) return; upload.status = status; await 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; // Update class uploadEl.element.className = uploadEl.element.className.replace(/status-[\w-]+/g, ''); uploadEl.element.classList.add(`status-${upload.status}`); // Update progress 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); const progress = uploadEl?.element?.querySelector('.progress'); if (!progress) return; if (show) { progress.style.removeProperty('animation'); progress.hidden = false; } else { progress.style.animation = 'fadeOut var(--transition-base)'; setTimeout(() => { progress.hidden = true; }, 300); } } updateUploadItemProgress(uploadId, percent, status = null) { const uploadEl = this.uploadElements.get(uploadId); const progress = uploadEl?.element?.querySelector('.progress'); if (!progress) return; const fill = progress.querySelector('.fill'); const details = progress.querySelector('.details'); const icon = progress.querySelector('.icon'); if (fill) fill.style.width = `${percent}%`; if (status && details) details.textContent = this.statusMapping[status] || status; if (status && icon) icon.innerHTML = this.getStatusIcon(status).outerHTML; } maybeLockUploads(fieldId) { const fieldEl = this.fieldElements.get(fieldId); if (!fieldEl?.ui?.dropZone) return; const uploadCount = this.getFieldUploadCount(fieldId); const maxFiles = fieldEl.config.destination === 'post_group' ? 20 : (fieldEl.config.maxFiles || 999); fieldEl.ui.dropZone.hidden = uploadCount >= maxFiles; fieldEl.element.classList.toggle('at-max-uploads', uploadCount >= maxFiles); if (fieldEl.config.destination === 'post_group' && uploadCount >= maxFiles) { this.a11y.announce('Maximum of 20 uploads reached.'); } } /******************************************************************************* * CLEANUP *******************************************************************************/ async clearUpload(uploadId) { const uploadEl = this.uploadElements.get(uploadId); if (uploadEl) { this.revokePreviewUrl(uploadEl.preview); if (uploadEl.element?.dataset.previewUrl) { this.revokePreviewUrl(uploadEl.element.dataset.previewUrl); } } this.uploadElements.delete(uploadId); await this.uploadStore.delete(uploadId); } createPreviewUrl(file) { const url = URL.createObjectURL(file); this.previewUrls.add(url); return url; } revokePreviewUrl(url) { if (url?.startsWith('blob:')) { URL.revokeObjectURL(url); this.previewUrls.delete(url); } } cleanupAllPreviewUrls() { this.previewUrls.forEach(url => { try { URL.revokeObjectURL(url); } catch (e) {} }); this.previewUrls.clear(); } /******************************************************************************* * RECOVERY & RESTORATION *******************************************************************************/ async showRecoveryNotification(byField) { let totalUploads = 0; let totalGroups = 0; byField.forEach(field => { totalUploads += field.uploads.length; totalGroups += field.groups.size; }); const notification = window.getTemplate('restoreNotification'); if (!notification) return; const message = totalGroups > 0 ? `${totalGroups} group${totalGroups > 1 ? 's' : ''} with ${totalUploads} upload${totalUploads > 1 ? 's' : ''} can be restored.` : `${totalUploads} upload${totalUploads > 1 ? 's' : ''} can be recovered.`; const detailsEl = notification.querySelector('.restore-details'); if (detailsEl) detailsEl.textContent = message; // Build preview for (const [fieldId, fieldData] of byField) { const fieldTemplate = window.getTemplate('restoreField'); if (!fieldTemplate) continue; const titleEl = fieldTemplate.querySelector('h3'); if (titleEl) titleEl.textContent = fieldId; const itemGrid = fieldTemplate.querySelector('.item-grid.restore'); for (const upload of fieldData.uploads) { const file = await this.getBlobData(upload.id); if (!file) continue; const uploadItem = this.createUploadElement({ id: upload.id, preview: this.createPreviewUrl(file), meta: upload.meta, subtype: this.getSubtypeFromMime(file.type) }, false); uploadItem.dataset.fieldId = fieldId; itemGrid?.appendChild(uploadItem); } notification.querySelector('.wrap')?.appendChild(fieldTemplate); } document.querySelector('.field.upload')?.appendChild(notification); const dialog = document.querySelector('dialog.restore-uploads'); if (dialog) { this.restoreModal = new window.jvbModal(dialog); this.restoreSelection = new window.jvbHandleSelection({ container: dialog, ui: { selectAll: dialog.querySelector('#select-all-restore'), count: dialog.querySelector('.selection-count') } }); this.restoreModal.handleOpen(); } } async handleRestoreSelected() { const dialog = document.querySelector('dialog.restore-uploads'); if (!dialog) return; const selected = []; dialog.querySelectorAll('[type=checkbox]:checked').forEach(checkbox => { const item = checkbox.closest('.item'); if (item) { selected.push({ uploadId: item.dataset.uploadId, fieldId: item.dataset.fieldId }); } }); if (selected.length > 0) { await this.restoreUploads(selected); } this.cleanupRestoreModal(); } async handleRestoreAll() { const dialog = document.querySelector('dialog.restore-uploads'); if (!dialog) return; const all = []; dialog.querySelectorAll('.item.upload').forEach(item => { all.push({ uploadId: item.dataset.uploadId, fieldId: item.dataset.fieldId }); }); await this.restoreUploads(all); this.cleanupRestoreModal(); } async restoreUploads(items) { const byField = new Map(); items.forEach(item => { if (!byField.has(item.fieldId)) { byField.set(item.fieldId, []); } byField.get(item.fieldId).push(item.uploadId); }); for (const [fieldId, uploadIds] of byField) { await this.restoreFieldUploads(fieldId, uploadIds); } } async restoreFieldUploads(fieldId, uploadIds) { // Find or register field element let fieldEl = this.fieldElements.get(fieldId); if (!fieldEl) { // Try to find by data attribute const element = document.querySelector(`[data-uploader="${fieldId}"]`); if (element) { this.registerUploader(element); fieldEl = this.fieldElements.get(fieldId); } } if (!fieldEl) { console.warn(`Field ${fieldId} not found for restoration`); return; } // Show upload UI if (fieldEl.ui.dropZone) fieldEl.ui.dropZone.hidden = true; if (fieldEl.ui.groups?.display) fieldEl.ui.groups.display.hidden = false; // Restore groups first const groups = this.getFieldGroups(fieldId); for (const group of groups) { await this.restoreGroup(fieldId, group); } // Restore uploads for (const uploadId of uploadIds) { const upload = this.uploadStore.get(uploadId); if (upload) { await this.restoreUploadElement(fieldId, upload); } } this.updateFieldState(fieldId); this.refreshSortable(fieldId); this.maybeLockUploads(fieldId); // Auto-upload if configured if (fieldEl.config.autoUpload && fieldEl.config.destination !== 'post_group') { await this.queueUpload(fieldId); } } async restoreGroup(fieldId, groupData) { const group = await this.createGroup(fieldId, groupData.id); if (!group) return; // Restore field values if (groupData.fields) { const titleInput = group.element.querySelector('[name*="post_title"]'); const excerptInput = group.element.querySelector('[name*="post_excerpt"]'); if (titleInput && groupData.fields.post_title) { titleInput.value = groupData.fields.post_title; } if (excerptInput && groupData.fields.post_excerpt) { excerptInput.value = groupData.fields.post_excerpt; } } } async restoreUploadElement(fieldId, upload) { const fieldEl = this.fieldElements.get(fieldId); if (!fieldEl) return; const file = await this.getBlobData(upload.id); if (!file) return; const preview = this.createPreviewUrl(file); const element = this.createUploadElement({ id: upload.id, preview, meta: upload.meta, subtype: this.getSubtypeFromMime(file.type) }, fieldEl.config.destination === 'post_group'); // Determine location let location; if (upload.groupId) { const groupEl = this.groupElements.get(upload.groupId); location = groupEl?.grid || fieldEl.ui.preview; } else { location = fieldEl.ui.preview; } location.appendChild(element); this.uploadElements.set(upload.id, { element, preview, location }); // Update upload status upload.status = 'processed'; await this.uploadStore.save(upload); } handleClearCache() { if (!confirm('Discard these uploads?')) return; this.cleanupStoredData(); this.cleanupRestoreModal(); } async cleanupStoredData() { await this.uploadStore.clear(); await this.groupStore.clear(); } cleanupRestoreModal() { if (this.restoreModal) { this.restoreModal.handleClose(); this.restoreSelection?.destroy(); this.restoreModal.destroy(); this.restoreModal.modal.remove(); this.restoreModal = null; this.restoreSelection = null; } } /******************************************************************************* * HELPER METHODS *******************************************************************************/ generateId(prefix) { return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } 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}`; } getFieldIdFromElement(el) { const field = el.closest(this.selectors.field.field); return field?.dataset.uploader || null; } getUploadIdFromElement(el) { const item = el.closest(this.selectors.items.item); return item?.dataset.uploadId || null; } getSubtypeFromMime(mimeType) { if (mimeType.startsWith('image/')) return 'image'; if (mimeType.startsWith('video/')) return 'video'; return 'document'; } getStatusIcon(status) { return window.getIcon(this.queue.icons[status]); } getStatusProgress(status) { const progress = { 'processing': 28, 'queued': 50, 'uploading': 66, 'pending': 75, 'server_processing': 89, 'completed': 100 }; return progress[status] || 0; } formatBytes(bytes, decimals = 2) { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i]; } createUploadElement(upload, draggable = false) { const element = window.getTemplate('uploadItem'); if (!element) return null; element.dataset.uploadId = upload.id; element.dataset.subtype = upload.subtype || 'image'; const featured = element.querySelector('[name="featured"]'); const img = element.querySelector('img'); const video = element.querySelector('video'); const preview = element.querySelector('label > span'); const details = element.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 ext = 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[ext] || 'file'); if (preview) { preview.innerText = fileName; preview.prepend(icon); } img?.remove(); video?.remove(); break; } if (details) { const template = window.getTemplate('uploadMeta'); if (template) details.append(template); } element.draggable = draggable; // Update input IDs element.querySelectorAll('input').forEach(input => { const id = input.id; if (id) { const newId = id + upload.id; const label = input.parentNode.querySelector(`label[for="${id}"]`); input.id = newId; if (label) label.htmlFor = newId; } }); return element; } /** * Get all files for a 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 field */ async getFilesForField(fieldId) { const uploads = this.getFieldUploads(fieldId); const files = []; for (const upload of uploads) { const file = await this.getBlobData(upload.id); if (file) { files.push({ file, uploadId: upload.id, fieldName: this.fieldElements.get(fieldId)?.config.name, meta: upload.meta || {} }); } } return files; } /******************************************************************************* * 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 (e) { console.error('Subscriber error:', e); } }); } /******************************************************************************* * DESTROY *******************************************************************************/ 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); this.selectionHandlers.forEach(handler => handler.destroy()); this.selectionHandlers.clear(); this.cleanupAllPreviewUrls(); this.sortableInstances.forEach(instance => instance?.destroy?.()); this.sortableInstances.clear(); this.uploadElements.clear(); this.fieldElements.clear(); this.groupElements.clear(); this.selected.clear(); this.subscribers.clear(); } } // Initialize document.addEventListener('DOMContentLoaded', async function() { window.auth.subscribe((event) => { if (event === 'auth-loaded') { window.jvbUploads = new UploadManager(); } }); });