class UploadManager { constructor() { this.a11y = window.jvbA11y; this.queue = window.jvbQueue; this.error = window.jvbError; this.subscribers = new Set(); this.initStores(); this.initWorker(); //Maps for DOM references this.fields = new Map(); this.uploads = new Map(); this.groups = new Map(); this.selected = new Map(); this.selectionHandlers = new Map(); this.sortables = new Map(); this.previewUrls = new Set(); this.initElements(); this.initListeners(); } initStores() { const {uploads, groups} = window.jvbStore.register( 'uploads', [ { storeName: 'uploads', keyPath: 'id', indexes: [ { name: 'field', keyPath: 'field' }, { name: 'status', keyPath: 'status' }, { name: 'group', keyPath: 'group' }, { name: 'src', keyPath: 'src' } ], }, { storeName: 'groups', keyPath: 'id', indexes: [ { name: 'field', keyPath: 'field' }, { name: 'src', keyPath: 'src' } ] } ] ); this.stores = { uploads: uploads, groups: groups, ready: [] }; this.stores.uploads.subscribe(this.handleStores.bind(this, 'uploads')); this.stores.groups.subscribe(this.handleStores.bind(this, 'groups')); 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; if (!fieldId) { return; } switch (event) { case 'cancel-operation': this.handleOperationCancelled(fieldId).then(()=>{}); break; case 'operation-status': this.handleFieldStatus(fieldId, operation).then(()=>{}); break; case 'operation-completed': this.handleOperationComplete(operation, fieldId).then(()=>{}); break; case 'operation-failed': case 'operation-failed-permanent': this.handleOperationFailed(operation, fieldId).then(()=>{}); break; } }); } storesReady() { return this.stores.ready.length === 2; } handleStores(storeName, event) { if (event === 'data-ready') { this.stores.ready.push(storeName); if (this.storesReady()) { this.checkRecovery(); } } } initWorker() { this.worker = null; this.workerState = { worker: null, tasks: new Map(), restart: { count: 0, max: 3 }, settings: { timeout: 3000, maxConcurrent: 3, restartAfterTimeout: true } }; } initElements() { this.selectors = { fields: { field: '[data-upload-field]', input: 'input[type="file"]', dropZone: '.file-upload-container', preview: '.preview-wrap', grid: '.item-grid.preview', progress: { progress: '.file-upload-container .progress', fill: '.file-upload-container .progress .fill', details: '.file-upload-container .progress .details', icon: '.file-upload-container .progress .icon' }, selectAll: '[name="select-all-uploads"]', actions: '.selection-actions', count: '.selection-count', hidden: 'input[type="hidden"]' }, // groups = selectors that affect groups as a whole groups: { container: '.group-display', grid: '.item-grid.groups', empty: '.empty-group', header: '.sidebar .header', }, // group = selectors that affect individual groups group: { item: '.upload-group', actions: '.selection-actions', selectAll: '[name="select-all-group"]', count: '.group-header .info', fields: 'details .fields', grid: '.item-grid.group', total: '.group-content .group-count' }, items: { item: '[data-upload-id]', checkbox: '[name*="select-item"]', featured: '[name="featured"]', image: 'img', details: 'details', progress: { progress: '.progress', fill: '.fill', details: '.details', icon: '.icon' } } }; } 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.handleDrop.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); window.addEventListener('beforeunload', () => { this.cleanupAllPreviewUrls(); }); } async setUpload(uploadId, data) { const defaults = { id: uploadId, attachment: null, group: null, field: null, src: window.location.href, blob: null, status: 'local_processing', operationId: null, fields: {} }; const upload = { ...defaults, ...data }; Object.preventExtensions(upload); await this.stores.uploads.save(upload); return upload; } /********************************************************************* UTILITY *********************************************************************/ 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); } } formatFile(upload) { if (!upload.blob) return null; return new File([upload.blob], upload.fields.originalName || 'file', { type: upload.fields.type || upload.blob.type, lastModified: upload.fields.lastModified || Date.now() }); } /********************************************************************* LISTENERS *********************************************************************/ handleClick(e) { //Open the file input if it's a dropzone let dropZone = window.targetCheck(e, this.selectors.fields.dropZone); if (dropZone && !e.target.matches('input, button, a')){ dropZone.querySelector(this.selectors.fields.input)?.click(); } //Handle action buttons const button = window.targetCheck(e, '[data-action]'); if (button) this.handleAction(button); } handleAction(button) { const action = button.dataset.action; const fieldId = this.getFieldIdFromElement(button); switch (action) { case 'add-to-group': this.handleAddToGroup(fieldId).then(()=>{}); break; case 'delete-group': this.handleDeleteGroup(button); break; case 'delete-upload': case 'remove-from-group': this.handleRemoveItem(button).then(()=>{}); break; case 'upload': this.queueUploads('uploads/groups',fieldId).then(()=>{}); break; case 'restore': this.handleRestoreSelected().then(()=>{}); break; case 'restore-all': this.handleRestoreAll().then(()=>{}); break; case 'clear-cache': this.handleClearCache().then(()=>{}); break; } } handleChange(e) { let fieldId = this.getFieldIdFromElement(e.target); if (!fieldId) return; if (e.target.matches(this.selectors.fields.input)) { const files = Array.from(e.target.files); if (files.length > 0) this.processFiles(fieldId, files).then(()=>{}); return; } // Skip selection-related inputs if (e.target.matches(this.selectors.items.checkbox) || e.target.matches(this.selectors.items.featured) || e.target.matches('[name*="select-"]')) { return; } let field = this.fields.get(fieldId); if (!field || !field.config.autoUpload) return; if (field.config.destination === 'post_group') { this.handleGroupMetaChange(e.target); } else { this.queueUploadMeta(e).then(()=>{}); } } handleGroupMetaChange(input) { const element = input.closest(this.selectors.group.fields); if (!element) return; const groupId = element.dataset.groupId; const group = this.stores.groups.get(groupId); // Changed from this.groups if (!group) return; window.debouncer.schedule(`group-meta-${groupId}`, async (input, groupId) => { let name = input.name .replace(`${groupId}_`, '') .replace(`${groupId}[`, '') .replace(']', ''); group.fields[name] = input.value; await this.setGroup(groupId, group); }, 300); } handleDragEnter(e) { if (!e.dataTransfer.types.includes('Files')) return; const dropZone = e.target.closest(this.selectors.fields.dropZone); if (dropZone) { e.preventDefault(); dropZone.classList.add('dragover'); } } handleDragLeave(e) { const dropZone = e.target.closest(this.selectors.fields.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.fields.dropZone); if (dropZone) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; } } handleDrop(e) { const dropZone = e.target.closest(this.selectors.fields.dropZone); if (!dropZone) return; e.preventDefault(); dropZone.classList.remove('dragover'); dropZone.classList.add('uploading'); const files = Array.from(e.dataTransfer.files); if (files.length === 0) return; const fieldId = this.getFieldIdFromElement(dropZone); if (fieldId) { this.processFiles(fieldId, files).then(()=>{}); this.a11y.announce(`${files.length} file(s) dropped for upload`); } } async queueUploads(endpoint, fieldId) { let data = new FormData(); const field = this.fields.get(fieldId); if (!field) return; let uploads = this.stores.uploads.filterByIndex({field: fieldId}); if (uploads.length === 0) return; const [ isUpload, isGroups] = [ endpoint === 'uploads', endpoint === 'uploads/groups']; data.append('fieldId', field.id); data.append('content', field.config.content); if (isUpload) { data.append('mode', field.config.mode); data.append('field_name', field.config.name); data.append('fieldId', field.id); data.append('field_type', field.config.type); data.append('subtype', field.config.subtype); data.append('item_id', field.config.itemID); data.append('destination', field.config.destination); } let posts, uploadMap, files; if (isGroups) { ({posts, uploadMap, files} = this.collectGroups(fieldId)); } else if (isUpload) { ({uploadMap, files} = this.collectUploads(fieldId)); } if (isGroups) { data.append('posts', JSON.stringify(posts)); } files.forEach(file => { data.append('files[]', file); }); data.append('upload_ids', JSON.stringify(uploadMap)); let title, popup; if (isUpload) { title = `Uploading ${uploads.length} file${uploads.length>1?'s':''} to server...`; popup = `Uploading ${uploads.length} file${uploads.length>1?'s':''}...`; } else if (isGroups) { title = `Creating ${posts.length} ${field.config.content}${posts.length > 1 ? 's' : ''} from uploads...`; popup = `Creating ${posts.length} post${posts.length>1?'s':''}...`; } await this.setBulkUpload(uploads, 'status', 'queued'); let operationId = this.sendToQueue(endpoint, data, title, popup); if (endpoint === 'uploads/groups') { let details = field.element.closest('details'); if (details) { details.open = false; } } if (operationId) { field.operationId = operationId; await this.setBulkUpload(uploads, 'operationId', operationId); await this.setBulkUpload(uploads, 'status', 'uploading'); await this.setBulkGroup(fieldId, 'operationId', operationId); this.fields.set(field.id, field); } else { await this.setBulkUpload(uploads, 'status', 'failed'); } this.notify('sent-to-queue', fieldId); return operationId; } async sendToQueue(endpoint, data, title = '', popup = '', mergable = false) { if (popup === '') { popup = title; } const operation = { endpoint: endpoint, method: 'POST', data: data, title: title, popup: popup, canMerge: mergable, sendNow: endpoint === 'uploads/groups', headers: { 'action_nonce': window.auth.getNonce('dash') }, append: '_upload' } try { return await this.queue.addToQueue(operation); } catch (error) { this.error.log(error, { component: 'UploadManager', action: 'sentToQueue' }); return false; } } collectGroups(fieldId) { let uploads = this.stores.uploads.filterByIndex({field: fieldId}); let groups = this.stores.groups.filterByIndex({field: fieldId}); let posts = []; let uploadMap = []; let files = []; for (const group of groups) { const post = { images: [], fields: group.fields??{} }; const groupUploads = uploads.filter(u => u.group === group.id); for (const upload of groupUploads) { const file = this.formatFile(upload); if (file) { files.push(file); const imageData = { upload_id: upload.id, index: uploadMap.length }; let uploadEl = this.uploads.get(upload.id); if (uploadEl.ui?.featured?.checked) { post.fields.featured = upload.id; } post.images.push(imageData); uploadMap.push(upload.id); } } posts.push(post); } const remaining = uploads.filter(u => !u.group); for (const upload of remaining) { const post = { images: [], fields: {} }; const file = this.formatFile(upload); if (file) { files.push(file); const imageData = { upload_id: upload.id, index: uploadMap.length }; post.images.push(imageData); uploadMap.push(upload.id); } posts.push(post); } return {posts, uploadMap, files}; } collectUploads(fieldId) { let uploads = this.stores.uploads.filterByIndex({field: fieldId}); if (uploads.length === 0) return; let uploadMap = []; let files = []; for (const upload of uploads) { const file = this.formatFile(upload); if (file) { files.push(file); uploadMap.push(upload.id); } } return { uploadMap, files }; } async queueUploadMeta(e) { const uploadId = e.target.closest(this.selectors.items.item)?.dataset.uploadId; const upload = this.stores.uploads.get(uploadId); if (!uploadId || !upload) return; const field = this.fields.get(upload.field); if (!field) return; let data = {}; data[e.target.name] = e.target.value; upload.fields = { ...upload.fields, ...data }; await this.setUpload(upload.id, upload); let queueData = {}; queueData[upload.attachmentId ?? upload.id] = upload.fields; return await this.sendToQueue('uploads/meta', queueData, 'Uploading Meta', '', true); } async handleOperationComplete(operation, fieldId) { const response = operation.response; // Handle direct upload results (from uploads endpoint) if (response?.data) { const results = Array.isArray(response.data) ? response.data : Object.values(response.data); for (const result of results) { if (result.upload_id && result.attachment_id) { const upload = this.stores.uploads.get(result.upload_id); if (upload) { upload.attachmentId = result.attachment_id; upload.status = 'completed'; await this.stores.uploads.save(upload); } } } } // Clear completed uploads and groups const uploads = this.stores.uploads.filterByIndex({field: fieldId}); const groups = this.stores.groups.filterByIndex({field: fieldId}); await Promise.all([ ...uploads .filter(upload => upload.status === 'completed') .map(upload => this.clearUpload(upload.id)), ...groups.map(group => this.stores.groups.delete(group.id)) ]); this.notify('uploads-complete', { fieldId, response }); } /********************************************************************* FIELD LOGIC *********************************************************************/ scanFields(container, autoUpload = true) { const fields = container.querySelectorAll(this.selectors.fields.field); fields.forEach(uploader => this.registerField(uploader, autoUpload)); } registerField(element, autoUpload = true, id = null) { const data = { element: element, id: (id) ? id : this.determineFieldId(element), config: this.extractFieldConfig(element, autoUpload), uploads: new Set(), operationId: null, groups: [], ui: window.uiFromSelectors(this.selectors.fields, element), groupUI: window.uiFromSelectors(this.selectors.groups, element) }; this.fields.set(data.id, data); element.dataset.uploader = data.id; this.getSelectionHandler(data.id); if (data.config.type !== 'single') { this.initSortable(data.id); } return data.id; } extractFieldConfig(fieldElement, autoUpload) { return { autoUpload: autoUpload, destination: fieldElement.dataset.destination || 'meta', //TODO: why do we need this? content: this.extractFieldContent(fieldElement), mode: fieldElement.dataset.mode || 'direct', type: fieldElement.dataset.type || 'single', name: fieldElement.dataset.field, itemID: this.extractFieldItemId(fieldElement)??0, maxFiles: parseInt(fieldElement.dataset.maxFiles)??25, subType: fieldElement.dataset.subtype?? 'image' }; } extractFieldContent(fieldElement) { return fieldElement.dataset.content || fieldElement.closest('dialog')?.dataset.content || fieldElement.closest('form')?.dataset.save || null; } extractFieldItemId(fieldElement) { return fieldElement.dataset.itemId || fieldElement.closest('dialog')?.dataset.itemId || null; } determineFieldId(fieldElement) { let content = this.extractFieldContent(fieldElement); content = (content === null) ? '' : content+'_'; let itemID = this.extractFieldItemId(fieldElement); itemID = (itemID === null) ? '' : itemID+'_'; const field = fieldElement.dataset.field || ''; return `${content}${itemID}${field}`; } getFieldIdFromElement(el) { const field = el.closest(this.selectors.fields.field); return field?.dataset.uploader || null; } updateFieldProgress(fieldId, current, total, message) { const field = this.fields.get(fieldId); if (!field) return; window.showProgress(field.ui.progress,current, total, message); } /********************************************************************* IMAGE PROCESSING FILE PROCESSING *********************************************************************/ getWorker() { if (!this.workerState.worker && typeof OffscreenCanvas !== 'undefined') { this.workerState.worker = new Worker('worker.js'); this.workerState.worker.onmessage = (e) => this.handleWorkerMessage(e); this.workerState.worker.onerror = (e) => this.handleWorkerError(e); } return this.workerState.worker; } handleWorkerMessage(e) { const { id, blob } = e.data; const task = this.workerState.tasks.get(id); if (task) { clearTimeout(task.timeoutId); task.resolve(blob); this.workerState.tasks.delete(id); } } handleWorkerError(e) { // Reject all pending tasks this.workerState.tasks.forEach(task => { clearTimeout(task.timeoutId); task.reject(e); }); this.workerState.tasks.clear(); this.restartWorker(); } restartWorker() { if (this.workerState.worker) { this.workerState.worker.terminate(); this.workerState.worker = null; } this.workerState.restart.count++; } async processImages(files, maxWidth = 2200, maxHeight = 2200){ const results = []; const queue = [...files]; const concurrency = this.workerState.settings.maxConcurrent; const processNext = async () => { while (queue.length > 0) { const file = queue.shift(); results.push(await this.processImage(file, maxWidth, maxHeight)); } }; await Promise.all( Array.from({length: Math.min(concurrency, files.length)}, () => processNext()) ); return results; } async processImage(file, maxWidth = 2200, maxHeight = 2200, timeout = 3000){ if (typeof OffscreenCanvas=== 'undefined') { return this.resizeImage(file,maxWidth,maxHeight); } try { return await this.withTimeout( this.workerImage(file, maxWidth, maxHeight), timeout ); } catch (e) { return this.resizeImage(file, maxWidth, maxHeight); } } withTimeout(promise, ms) { return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), ms) ) ]); } async workerImage(file, maxWidth = 2200, maxHeight = 2200) { const { settings, restart } = this.workerState; if (restart.count >= restart.max) { throw new Error('Worker max restarts exceeded'); } const bitmap = await createImageBitmap(file); let { width, height } = bitmap; if (width > maxWidth || height > maxHeight) { const ratio = Math.min(maxWidth / width, maxHeight / height); width = Math.round(width * ratio); height = Math.round(height * ratio); } const worker = this.getWorker(); const id = crypto.randomUUID(); return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { this.workerState.tasks.delete(id); if (settings.restartAfterTimeout) { this.restartWorker(); } reject(new Error('Timeout')); }, settings.timeout); this.workerState.tasks.set(id, { resolve, reject, timeoutId }); worker.postMessage( { id, imageBitmap: bitmap, width, height, type: file.type, quality: 0.9 }, [bitmap] ); }); } resizeImage(file, maxWidth, maxHeight) { return new Promise((resolve) => { const img = new Image(); img.onload = () => { URL.revokeObjectURL(img.src); // Calculate new dimensions keeping aspect ratio let { width, height } = img; if (width > maxWidth || height > maxHeight) { const ratio = Math.min(maxWidth / width, maxHeight / height); width = Math.round(width * ratio); height = Math.round(height * ratio); } // Draw to canvas at new size const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; canvas.getContext('2d').drawImage(img, 0, 0, width, height); // Export as blob for upload canvas.toBlob(resolve, file.type, 0.9); }; img.src = URL.createObjectURL(file); }); } async processFiles(fieldId, files) { let field = this.fields.get(fieldId); if (!field) return; if (field.groupUI.container) { field.groupUI.container.hidden = false; } const totalFiles = files.length; let processed = 0; this.updateFieldProgress(fieldId, 0, totalFiles, 'Processing files...'); // Create upload records for all files first const uploadEntries = await Promise.all( files.map(async (file) => { const uploadId = window.generateID('upload'); const upload = await this.setUpload(uploadId, { id: uploadId, field: fieldId, status: 'local_processing', blob: null, fields: { originalName: file.name, originalSize: file.size, type: file.type, lastModified: file.lastModified } }); const element = await this.createUpload(uploadId, file, fieldId); this.uploads.set(uploadId, { element: element, ui: window.uiFromSelectors(this.selectors.items, element) }); await this.addToGroup(uploadId, null); return { uploadId, upload, file }; }) ); // Batch process images with concurrency control const imageEntries = uploadEntries.filter(e => e.file.type.startsWith('image/')); const otherEntries = uploadEntries.filter(e => !e.file.type.startsWith('image/')); // Process images in batches const processedBlobs = await this.processImages( imageEntries.map(e => e.file) ); // Update image uploads with processed blobs for (let i = 0; i < imageEntries.length; i++) { const { uploadId, upload } = imageEntries[i]; upload.blob = processedBlobs[i]; upload.fields.size = processedBlobs[i].size; upload.status = 'queued'; await this.setUpload(uploadId, upload); processed++; this.updateFieldProgress(fieldId, processed, totalFiles, 'Processing files...'); } // Handle non-image files (no processing needed) for (const { uploadId, upload, file } of otherEntries) { upload.blob = file; upload.status = 'queued'; await this.setUpload(uploadId, upload); processed++; this.updateFieldProgress(fieldId, processed, totalFiles, 'Processing files...'); } this.maybeLockUploads(fieldId); if (field.config.autoUpload && field.config.destination !== 'post_group') { await this.queueUploads('uploads', fieldId); } } /************************************************************* RECOVERY *************************************************************/ async checkRecovery() { const pendingUploads = this.stores.uploads.filterByIndex({status: ['local_processing', 'queued', 'uploading']}); if (pendingUploads.length === 0) return; let notification = window.getTemplate('restoreNotification'); if (!notification) { this.error.log( 'No restore notification', { component: 'UploadManager', src: window.location.href } ); return; } // Group by source page const bySource = new Map(); pendingUploads.forEach(upload => { const src = upload.src || 'unknown'; if (!bySource.has(src)) bySource.set(src, []); bySource.get(src).push(upload); }); const currentSrc = window.location.href; let source = bySource.size > 1 ? ` across ${bySource.size} pages` : ''; let upload = pendingUploads.length > 1 ? 'uploads' : 'upload'; let message = `${pendingUploads.length} ${upload} can be recovered${source}`; let details = notification.querySelector('.details'); if (details) { details.textContent = message; } let i = 1; for (const [src, uploads] of bySource) { let template = window.getTemplate('restoreField'); if (!template) continue; let fieldId = this.registerField(template,false, 'recovery_'+i); let field = this.fields.get(fieldId); i++; let isCurrent = src === currentSrc; let [ h3, a, grid ] = [ template.querySelector('h3'), template.querySelector('h3 a'), template.querySelector('.item-grid') ]; template.open = isCurrent; if (!isCurrent) { [a.href, a.title,a.textContent] = [src, 'Navigate to Page and Restore', src]; } else { a.remove(); h3.textContent = 'From this page:'; } let filteredGroupIds = [...new Set(uploads.map(upload => upload.group??'preview'))]; for (let groupId of filteredGroupIds) { let group = (groupId === 'preview') ? true : this.stores.groups.get(groupId); if (!group) continue; let groupElement = await this.createGroupElement(groupId,field.id); let groupGrid = groupElement.querySelector('.item-grid'); let theseUploads = uploads.filter(upload => upload.group === (groupId === 'preview') ? null : groupId); for (const [key, value] of Object.entries(group.fields ?? {})) { let field = groupElement.querySelector(`input[name*="${key}"]`); if (field) field.value = value; } for (let upload of theseUploads) { let item = await this.createUpload(upload.id, this.formatFile(upload), field.id); groupGrid.append(item); } grid.append(groupElement); } notification.querySelector('.wrap').append(template); } document.body.append(notification); notification = document.querySelector('dialog.restore-uploads'); this.restoreModal = new window.jvbModal(notification); this.restoreSelection = new window.jvbHandleSelection({ container: notification, wrapper: '.restore-uploads .wrap', bulkControls: '.selection-actions', selectAll: '#select-all-restore', count: '.selection-count' }); this.restoreModal.handleOpen(); } async handleRestoreSelected() { if (!this.restoreSelection) return; let selected = Array.from(this.restoreSelection.selectedItems); if (selected.length === 0) { return; } await this.restoreSelectedUploads(selected); } async handleRestoreAll() { if (!this.restoreModal) return; const allUploads = Array.from(this.restoreModal.modal.querySelectorAll('.item.upload')).map(item => item.dataset.uploadId); await this.restoreSelectedUploads(allUploads); } async restoreSelectedUploads(selectedUploads) { let currentPage = window.location.href; let uploads = Array.from(this.stores.uploads.data.values()).filter( upload => selectedUploads.includes(upload.id) && upload.src === currentPage ); let groups = [... new Set(uploads.map(upload => upload.group))].filter(Boolean); let fieldId = uploads[0].field; let field = document.querySelector(`[data-uploader="${fieldId}"]`); if (!field) { console.log('No field found for '+fieldId); return; } let fieldData = this.fields.get(fieldId); if (fieldData.groupUI.container) { fieldData.groupUI.container.hidden = false; } let usedIds = []; for (let gr of groups) { let group = this.stores.groups.get(gr); await this.createGroup(fieldId,gr); let element = this.groups.get(gr); let theseUploads = uploads.filter(upload => upload.group === gr); if (group && this.groups.has(gr)) { let fields = group.fields; for (const [key, value] of Object.entries(fields)) { let fi = element.element.querySelector(`input[name*="${key}"]`); if (fi) { fi.value = value; } } }else { //Couldn't restore the group for some reason, just add it to the main preview grid instead gr = null; } for (let upload of theseUploads) { let item = await this.createUpload(upload.id, this.formatFile(upload), fieldId); this.uploads.set(upload.id, { element: item, ui: window.uiFromSelectors(this.selectors.items, item) }); await this.addToGroup(upload.id, gr); usedIds.push(upload.id); } } let remaining = uploads.filter(upload => !usedIds.includes(upload.id)); for (let upload of remaining) { let item = await this.createUpload(upload.id, this.formatFile(upload), fieldId); this.uploads.set(upload.id, { element: item, ui: window.uiFromSelectors(this.selectors.items, item) }); await this.addToGroup(upload.id, null); } this.cleanupRestore(); } cleanupRestore() { this.restoreModal.handleClose(); this.restoreSelection.destroy(); this.restoreSelection = null; this.restoreModal.destroy(); this.restoreModal.modal.remove(); this.restoreModal = null; } /******************************************************************************* STATUS MANAGEMENT *******************************************************************************/ getStatusText(status) { let map = { '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' }; return map[status]||status; } getStatusProgress(status) { let progress = { 'local_processing': 28, 'queued': 50, 'uploading': 66, 'pending': 75, 'processing': 89, 'completed': 100 }; return progress[status]??0; } /******************************************************************************* UPLOAD METHODS *******************************************************************************/ async createUpload(uploadId, file, fieldId) { let image = window.getTemplate('uploadItem'); if (!image) return null; let field = this.fields.get(fieldId); if (!field) return null; image.dataset.uploadId = uploadId; let mimeType = this.getSubtypeFromMime(file.type)||'image'; image.dataset.subtype = mimeType; 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 = uploadId; switch (mimeType) { case 'image': if (img) { const previewUrl = this.createPreviewUrl(file); img.src = previewUrl; img.alt = file.name || ''; img.dataset.previewUrl = previewUrl; } video?.remove(); preview?.remove(); break; case 'video': if (video){ const previewUrl = this.createPreviewUrl(file); video.src = previewUrl; video.dataset.previewUrl = previewUrl; } img?.remove(); preview?.remove(); break; case 'document': let ext = file.name.split('.').pop()?.toLowerCase()??''; let map = { 'pdf': 'file-pdf', 'csv': 'file-csv', 'doc': 'file-doc', 'docx': 'file-doc', 'txt': 'file-txt', 'xls': 'file-xls', 'xlsx': 'file-xls' }; let icon = window.getIcon(map[ext]??'file'); if (preview) { preview.innerText = file.name; preview.prepend(icon); } img?.remove(); video?.remove(); break; } if (details) { let template = window.getTemplate('uploadMeta'); if (template) details.append(template); } image.draggable = field.config.type !== 'single'??false; image.querySelectorAll('input').forEach(input => { let id = input.id; if (id) { let newId = id + uploadId; let label = input.parentNode.querySelector(`label[for="${id}"]`); input.id = newId; if (label) label.htmlFor = newId; } }); return image; } getSubtypeFromMime(mimeType) { if (mimeType.startsWith('image/')) return 'image'; if (mimeType.startsWith('video/')) return 'video'; return 'document'; } /** * Called by handleAction * @param button */ async handleRemoveItem(button) { const item = button.closest(this.selectors.items.item); if (!item) return; const uploadId = item.dataset.uploadId; if (!confirm('Remove this item?')) return; await this.removeUpload(uploadId); this.a11y.announce('Item removed'); } async setBulkUpload(uploads, key, value) { const promises = Array.from(uploads).map(async (upload) => { if (key === 'status') { await this.setUploadStatus(upload, value); } upload[key] = value; return this.stores.uploads.save(upload); }); await Promise.all(promises); } async setUploadStatus(upload, status) { if (upload.progress) { window.showProgress(upload.progress, this.getStatusProgress(status), 100, this.getStatusText(status), this.queue.icons[status]??''); } } async removeUpload(uploadId) { let upload = this.stores.uploads.get(uploadId); if (!upload) return; if (upload.group) { let group = this.stores.groups.get(upload.group); group.uploads = group.uploads.filter(id => id !== uploadId); if (group.uploads.length === 0) { await this.removeGroup(group.id, false); } } await this.clearUpload(uploadId); this.maybeLockUploads(upload.field); let handler = this.selectionHandlers.get(upload.field); if (handler){ handler.deselect(uploadId); } this.a11y.announce('Upload removed'); } async clearUpload(uploadId) { const element = this.uploads.get(uploadId); if (element) { this.revokePreviewUrl(element.preview); if (element.element) { const previewUrl = element.element.dataset.previewUrl; this.revokePreviewUrl(previewUrl); element.element.remove(); } } this.uploads.delete(uploadId); await this.stores.uploads.delete(uploadId); } /******************************************************************************* GROUP METHODS *******************************************************************************/ async handleAddToGroup(fieldId) { const selected = this.selected.get(fieldId); if (!selected || selected.size === 0) return; let groupId = await this.createGroup(fieldId); if (!groupId) return; await Promise.all( Array.from(selected).map(uploadId => this.addToGroup(uploadId, groupId)) ); this.selectionHandlers.get(fieldId)?.clearSelection(); this.a11y.announce(`Created group with ${selected.size} items`); } async createGroup(fieldId, groupId = null) { let field = this.fields.get(fieldId); if (!field) return; if (!groupId) { groupId = window.generateID('group'); } const element = this.createGroupElement(groupId, fieldId); if (!element) return null; field.groupUI.grid.append(element); // Create Sortable for this group's grid const grid = element.querySelector('.item-grid'); if (grid) { grid.dataset.groupId = groupId; this.createSortable(fieldId, grid, groupId); } let storedData = this.stores.groups.data.has(groupId) ? this.stores.groups.data.get(groupId) : {}; await this.setGroup(groupId, { ...storedData, id: groupId, field: fieldId }); return groupId; } createGroupElement(groupId, fieldId = null) { let element = window.getTemplate('imageGroup'); if (!element) return; element.dataset.groupId = groupId; if (fieldId) { element.dataset.fieldId = fieldId; } const selectAll = element.querySelector('[data-select-all]'); if (selectAll) { const newId = `select-all-${groupId}`; const label = element.querySelector(`label[for="${selectAll.id}"]`); selectAll.id = newId; selectAll.name = newId; if (label) label.htmlFor = newId; } let fields = window.getTemplate('groupMetadata'); let container = element.querySelector('.fields'); if (fields && container) { container.append(fields); let title = container.querySelector('[name="post_title"]'); let excerpt = container.querySelector('[name="post_excerpt"]'); if (title) { title.id = `${groupId}_title`; title.name = `${groupId}[post_title]`; } if (excerpt) { excerpt.id = `${groupId}_excerpt`; excerpt.name = `${groupId}[post_excerpt]`; } } else { element.querySelector('details')?.remove(); } const grid = element.querySelector('.item-grid'); if (grid) { grid.dataset.groupId = groupId; } this.groups.set(groupId, { element: element, ui: window.uiFromSelectors(this.selectors.group, element) }); return element; } async setGroup(groupId, data) { const defaults = { id: groupId, src: window.location.href, uploads: [], operationId: null, field: null, fields: {} }; const group = {...defaults, ...data}; Object.preventExtensions(group); await this.stores.groups.save(group); } async setBulkGroup(fieldId, key, value) { let groups = this.stores.groups.filterByIndex({field:fieldId}); if (groups.length === 0) { return; } let Promises = groups.map(group => { group[key] = value; this.stores.groups.save(group); }); await Promise.all(Promises); } async addToGroup(uploadId, groupId = null){ const upload = this.stores.uploads.get(uploadId); const element = this.uploads.get(uploadId); if (!upload || !element) return; const field = this.fields.get(upload.field); if (!field) return; //Check if it's already in this destination, it's probably a reorder const isInDOM = element.element?.parentElement !== null; if (isInDOM && ((!groupId && upload.group === null) || groupId === upload.group)) { this.handleReorder(upload.field, groupId); return; } if (upload.group) { const group = this.stores.groups.get(upload.group); if (group) { group.uploads = group.uploads.filter(id => id !== uploadId); if (group.uploads.length === 0) { await this.removeGroup(group.id, false); } } } //clear any selection if (element.ui.checkbox) element.ui.checkbox.checked = false; if (this.selected.get(upload.field)?.has(uploadId)) { this.selected.get(upload.field).delete(uploadId); } if (element.ui.featured) element.ui.featured.hidden = !groupId; if (!groupId) { upload.group = null; } else { if (element.ui.featured) element.ui.featured.name = `${groupId}_featured`; let group = this.stores.groups.get(groupId); if (group) { group.uploads.push(uploadId); upload.group = groupId; this.stores.groups.save(group); } } let target = (groupId) ? this.groups.get(groupId)?.ui.grid : field.ui.grid; if (target) { target.append(element.element) } this.stores.uploads.save(upload); } handleDeleteGroup(button) { const group = button.closest(this.selectors.group.item); if (!group) return; let groupId = group.dataset.groupId; if (!confirm('Delete this group? Items will be moved back to the upload area.')) { return; } let uploads = this.stores.uploads.filterByIndex({group: groupId}); Promise.all( uploads.map(upload => this.addToGroup(upload.id, null)) ).then(() => { this.removeGroup(groupId, false).then(()=>{}); this.a11y.announce('Group deleted. Items returned to upload area'); }); } async removeGroup(groupId, confirm = true) { let element = this.groups.get(groupId); let group = this.stores.groups.get(groupId); if (!group) return; let keepUploads = true; if (confirm && group.uploads.length > 0) { keepUploads = window.confirm('Keep uploads in this group?'); } await Promise.all( group.uploads.map(uploadId => keepUploads ? this.addToGroup(uploadId, null) : this.removeUpload(uploadId) ) ); // Destroy the Sortable for this group const sortableKey = this.getGroupKey(group.field, groupId); const sortable = this.sortables.get(sortableKey); if (sortable?.destroy) { sortable.destroy(); } this.sortables.delete(sortableKey); if (element?.element) { element.element.remove(); } this.groups.delete(groupId); await this.stores.groups.delete(groupId); this.a11y.announce('Group removed'); } maybeLockUploads(fieldId) { let field = this.fields.get(fieldId); if (!field || !field.ui.dropZone) return; let uploads = this.stores.uploads.filterByIndex({field: fieldId}); let count = uploads.length; let max = field.config.maxFiles??25; field.ui.dropZone.hidden = count >= max; } /******************************************************************************* OPERATION METHODS *******************************************************************************/ async handleOperationCancelled(fieldId) { const uploads = this.stores.uploads.filterByIndex({field: fieldId}); const groups = this.stores.groups.filterByIndex({field: fieldId}); await Promise.all([ ...uploads.map(upload => this.removeUpload(upload.id)), ...groups.map(group => this.removeGroup(group.id, false)) ]); this.a11y.announce('Upload Cancelled'); } async handleOperationFailed(operation, fieldId) { // Mark uploads as failed, maybe show retry UI await this.setBulkUpload( this.stores.uploads.filterByIndex({field: fieldId}), 'status', 'failed' ); } async handleFieldStatus(fieldId, operation) { let status = operation.status; let uploads = this.stores.uploads.filterByIndex({field: fieldId}); await this.setBulkUpload(uploads, 'status', status); } /******************************************************************************* SELECTION HANDLERS *******************************************************************************/ getGroupKey(fieldId, groupId = null) { return (groupId) ? `${fieldId}_${groupId}` : `${fieldId}`; } getSelectionHandler(fieldId) { let key = this.getGroupKey(fieldId); if (!this.selectionHandlers.has(key)) { let field = this.fields.get(fieldId); if (!field) return; let handler = new window.jvbHandleSelection({ container: field.element, item: this.selectors.items.item, count: this.selectors.fields.count, bulkControls: this.selectors.fields.actions, checkbox: this.selectors.items.checkbox, selectAll: this.selectors.fields.selectAll, wrapper: `${this.selectors.fields.preview}, ${this.selectors.group.item}`, }); handler.subscribe((event, data) => { this.selected.set(fieldId, data.selectedItems); console.log(Array.from(this.selected)); this.syncSortableSelection(fieldId, data.selectedItems); }); this.selectionHandlers.set(key, handler); } return this.selectionHandlers.get(key); } /******************************************************************************* SORTABLE *******************************************************************************/ initSortable(fieldId) { if (!window.Sortable) return; const field = this.fields.get(fieldId); if (!field) return; if (!Sortable._multiDragMounted && Sortable.MultiDrag) { Sortable.mount(new Sortable.MultiDrag()); Sortable._multiDragMounted = true; } // Create sortable for the main preview grid this.createSortable(fieldId, field.ui.grid, null); // Set up empty-group as native drop zone this.initEmptyGroupDropZone(fieldId); } createSortable(fieldId, gridElement, groupId) { if (!gridElement) return null; const key = this.getGroupKey(fieldId, groupId); // Already exists if (this.sortables.has(key)) { return this.sortables.get(key); } const sortable = new Sortable(gridElement, { animation: 150, draggable: '.item', multiDrag: true, selectedClass: 'selected', avoidImplicitDeselect: true, group: { name: fieldId, pull: true, put: true }, ghostClass: 'ghost', chosenClass: 'chosen', dragClass: 'dragging', onStart: (evt) => { // Get the dragged item's ID const draggedItem = evt.item; const uploadId = draggedItem?.dataset.uploadId; // Get the selected items Set for this field const selectedItems = this.selected.get(fieldId); // If the dragged item isn't selected, select it if (uploadId && (!selectedItems || !selectedItems.has(uploadId))) { const handler = this.selectionHandlers.get(fieldId); if (handler) { handler.select(uploadId); } } // Sync all selections to Sortable this.syncSortableSelection(fieldId); }, onEnd: (evt) => this.sortableDrop(evt, fieldId), }); this.sortables.set(key, sortable); return sortable; } initEmptyGroupDropZone(fieldId) { const field = this.fields.get(fieldId); const emptyZone = field?.groupUI?.empty; if (!emptyZone) return; emptyZone.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; emptyZone.classList.add('drag-over'); }); emptyZone.addEventListener('dragleave', (e) => { if (!emptyZone.contains(e.relatedTarget)) { emptyZone.classList.remove('drag-over'); } }); emptyZone.addEventListener('drop', async (e) => { e.preventDefault(); emptyZone.classList.remove('drag-over'); // Get selected items from our tracking const selectedItems = this.selected.get(fieldId); if (!selectedItems || selectedItems.size === 0) return; const groupId = await this.createGroup(fieldId); if (!groupId) return; await Promise.all( Array.from(selectedItems).map(uploadId => this.addToGroup(uploadId, groupId)) ); this.selectionHandlers.get(fieldId)?.clearSelection(); }); } async sortableDrop(evt, fieldId) { const dropTarget = evt.to; const items = evt.items?.length > 0 ? Array.from(evt.items) : [evt.item]; const uploadIds = items.map(item => item.dataset.uploadId).filter(Boolean); if (uploadIds.length === 0) return; // Determine target group from the grid's data attribute const targetGroupId = dropTarget.dataset.groupId || null; await Promise.all( uploadIds.map(uploadId => this.addToGroup(uploadId, targetGroupId)) ); this.selectionHandlers.get(fieldId)?.clearSelection(); } syncSortableSelection(fieldId) { const selectedItems = this.selected.get(fieldId) || new Set(); for (const [uploadId, uploadData] of this.uploads) { const upload = this.stores.uploads.get(uploadId); if (!upload || upload.field !== fieldId) continue; const element = uploadData.element; if (!element) continue; const shouldBeSelected = selectedItems.has(uploadId); if (shouldBeSelected && !element.classList.contains('selected')) { Sortable.utils.select(element); } else if (!shouldBeSelected && element.classList.contains('selected')) { Sortable.utils.deselect(element); } } } handleReorder(fieldId, groupId = null) { let target = (groupId) ? this.groups.get(groupId)?.ui.grid : this.fields.get(fieldId)?.ui.grid; if (!target) { console.log ('Couldn\'t Reorder items...'); return; } //Get current order from DOM let items = Array.from(target.querySelectorAll(this.selectors.items.item+':not(.ghost)')) .map(upload => upload.dataset.uploadId) .filter(id => id); if (!groupId) { let hiddenInput = this.fields.get(fieldId)?.ui.hidden; if (hiddenInput) { hiddenInput.value = items.join(','); } } else { let group = this.groups.get(groupId); if (group) { group.uploads = items; } } this.a11y.announce('Items reordered'); } /******************************************************************************* * 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); } }); } /******************************************************************** CLEANUP ********************************************************************/ destroy() { this.subscribers.clear(); this.previewUrls.forEach(url => { this.revokePreviewUrl(url); }); this.previewUrls.clear(); } cleanupAllPreviewUrls() { this.previewUrls.forEach(url => this.revokePreviewUrl(url)); this.previewUrls.clear(); } async handleClearCache() { const currentSrc = window.location.href; const uploads = this.stores.uploads.filterByIndex({src: currentSrc}); const groups = this.stores.groups.filterByIndex({src:currentSrc}); await Promise.all([ ...uploads.map(upload => this.clearUpload(upload.id)), ...groups.map(group => { this.groups.get(group.id)?.element?.remove(); this.groups.delete(group.id); return this.stores.groups.delete(group.id); }) ]); if (this.restoreModal) { this.cleanupRestore(); } this.a11y.announce('Cache cleared for this page'); } /** * Get files from all upload fields in a form * Returns array of {file, fieldName, uploadId, meta} */ async getFilesForForm(formElement) { const uploadFields = formElement.querySelectorAll(this.selectors.fields.field); const allFiles = []; for (const fieldElement of uploadFields) { const fieldId = this.determineFieldId(fieldElement); const fieldName = fieldElement.dataset.field; const uploads = this.stores.uploads.filterByIndex({ field: fieldId }); for (const upload of uploads) { const file = this.formatFile(upload); if (file) { allFiles.push({ file: file, fieldName: fieldName, uploadId: upload.id, meta: upload.fields || {} }); } } } return allFiles; } /** * Clear all uploads and groups for a specific field from stores */ async clearFieldFromStores(fieldId) { const uploads = this.stores.uploads.filterByIndex({ field: fieldId }); const groups = this.stores.groups.filterByIndex({ field: fieldId }); // Clear all uploads await Promise.all( uploads.map(upload => this.clearUpload(upload.id)) ); // Clear all groups await Promise.all( groups.map(group => { this.groups.get(group.id)?.element?.remove(); this.groups.delete(group.id); return this.stores.groups.delete(group.id); }) ); } } document.addEventListener('DOMContentLoaded', async function () { window.auth.subscribe((event) => { if (event === 'auth-loaded') { window.jvbUploads = new UploadManager(); } }); });