class UploadManager { constructor() { this.a11y = window.jvbA11y; this.queue = window.jvbQueue; this.error = window.jvbError; this.templates = window.jvbTemplates; 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.changes = new Map(); this.previewUrls = new Set(); this.initElements(); this.initListeners(); this.defineTemplates(); } defineTemplates() { const T = this.templates; const images = this; T.define('uploadItem', { refs: { select: '[name="select-item"]', featured: '[name="featured"]', img: 'img', video: 'video', file: 'label > span', details: 'details', alt: '[name="image-alt-text"]', title: '[name="image-title"]', description: '[name="image-caption"]', }, manyRefs: { inputs: 'input, select, textarea', }, setup({el, refs, manyRefs, data}) { const isNewUpload = Object.hasOwn(data, 'file'); let mimeType; let url; let alt; let previewUrl = false; if (isNewUpload) { el.dataset.uploadId = data.uploadId; mimeType = images.getSubtypeFromMime(data.file.type)||'image'; url = (mimeType !== 'document') ? images.createPreviewUrl(data.file) : false; previewUrl = url; alt = data.file.name||''; } else { el.dataset.id = data.id; mimeType = images.getSubtypeFromURL(data.medium??data.src); url = data.medium??data.src; alt = data['image-alt-text']??''; } el.dataset.subtype = mimeType; if (refs.featured) { refs.featured.value = data.uploadId; } switch (mimeType) { case 'image': if (refs.img) { refs.img.src = url; refs.img.alt = alt; if (previewUrl) refs.img.dataset.previewUrl = previewUrl; } if (refs.video) refs.video.remove(); if (refs.file) refs.file.remove(); break; case 'video': if (refs.video) { refs.video.src = url; refs.video.alt = alt; if (previewUrl) refs.video.dataset.previewUrl = previewUrl; } if (refs.img) refs.img.remove(); if (refs.file) refs.file.remove(); break; case 'document': if (refs.preview) { let ext = data.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'); refs.preview.innerText = data.file.name??data.title; refs.preview.prepend(icon); } if (refs.img) refs.img.remove(); if (refs.video) refs.video.remove(); break; } if (refs.details) { if (Object.hasOwn(data, 'field') && Object.hasOwn(data.field,'config') && Object.hasOwn(data.field.config, 'showMeta') && !data.field.config.showMeta) { refs.details.remove(); } else { if(Object.hasOwn(data, 'id')) { refs.details.dataset.attachmentId = data.id; } else if (Object.hasOwn(data, 'uploadId')) { refs.details.dataset.uploadId = data.uploadId; } refs.details.setAttribute('data-ignore', ''); if (mimeType !== 'image' && refs.alt) { refs.alt.closest('.field')?.remove(); } else if (Object.hasOwn(data, 'image-alt-text') && refs.alt) { refs.alt.value = data['image-alt-text']; } if ((Object.hasOwn(data, 'title') || Object.hasOwn(data, 'file')) && refs.title) { refs.title.value = data.title||data.file.name; } if (Object.hasOwn(data, 'image-caption') && refs.description) { refs.description.value = data['image-caption']; } } } el.draggable = el.dataset.mode !== 'single'; if (manyRefs.inputs) { for (let input of manyRefs.inputs) { let wrapper = input.closest('[data-field]')??input.closest('.radio-button')??el; window.prefixInput(input, `${data.id??data.uploadId}-`, wrapper); } } } }); T.define('imageGroup', { refs: { selectAll: '[data-select-all]', fields: '.fields', details: 'details', grid: '.item-grid', }, setup({el, refs, manyRefs, data}) { el.dataset.groupId = data.groupId; if (refs.selectAll) { let wrapper = refs.selectAll.closest('.field'); window.prefixInput(refs.selectAll, `select-all-${data.groupId}`, wrapper,true); } let fields = T.create('groupMetadata', {groupId: data.groupId}); if (fields) { refs.fields.append(fields); } else { refs.details.remove(); } if (refs.grid) { refs.grid.dataset.groupId = data.groupId; } } }); T.define('groupMetadata', { manyRefs: { inputs: 'input,textarea,select' }, setup({el, refs, manyRefs, data}) { if (refs.inputs) { refs.inputs.forEach(input => { let wrapper = input.closest('[data-field]'); window.prefixInput(input, `${data.groupId}-`, wrapper); }); } } }); T.define('restoreNotification', { refs: { details: '.details', wrap: '.wrap', }, setup({el, refs, manyRefs, data}) { if (refs.details) { let source = data.bySource.size > 1 ? ` across ${data.bySource.size} pages` : ''; let upload = data.pendingUploads.length > 1 ? 'uploads' : 'upload'; refs.details.textContent = `${data.pendingUploads.length} ${upload} can be recovered${source}`; } if (!refs.wrap) { console.warn('No wrap element in template'); return; } let i = 1; for (const [src, uploads] of data.bySource) { let data = { index: i, isCurrent: src === window.location.href, src: src, uploads: uploads }; refs.wrap.append(T.create('restoreField', data)); i++; } } }); T.define('restoreField', { refs: { h3: 'h3', a: 'h3 a', grid: '.item-grid' }, async setup({el, refs, manyRefs, data}) { let fieldId = images.registerField(el, false, false, `recovery_${data.index}`); if (data.isCurrent) { el.open = true; refs.a?.remove(); if (refs.h3) { refs.h3.textContent = 'From this page:'; } } else { if (refs.a) { refs.a.href = data.src; refs.a.title = 'Navigate to page and restore'; refs.a.textContent = data.src; } } let filtered = [... new Set(data.uploads.map(upload => upload.group??'preview'))]; for (let groupId of filtered) { let group = (groupId === 'preview') ? true : images.stores.groups.get(groupId); if (!group) continue; let element = await images.createGroupElement(groupId, fieldId); let groupGrid = element.querySelector('.item-grid'); let groupUploads = data.uploads.filter(upload => upload.group === (groupId === 'preview') ? null : groupId); for (const [key, value] of Object.entries(group.fields??{})) { let field = element.querySelector(`input[name*="${key}"]`); if (field) field.value = value; } for (let upload of groupUploads) { let item = await images.createUpload(upload.id, images.formatFile(upload), fieldId); groupGrid.append(item); } refs.grid.append(element); } } }); } 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 ((event === 'operation-status' || event === 'cancel-operation') && ['image_upload', 'video_upload', 'document_upload'].includes(operation.type)) { let uploadIds = []; if (operation.data) { // Handle FormData if (operation.data instanceof FormData) { const dataObj = this.stores.uploads.formDataToObject(operation.data); uploadIds = dataObj['upload_ids'] || []; } // Handle regular object else { uploadIds = operation.data['upload_ids'] || []; } } // If not in data, check result (for completed operations from backend) if (uploadIds.length === 0 && operation.result && operation.result.upload_ids) { uploadIds = operation.result.upload_ids; } // Still no upload_ids? Log warning and bail if (!uploadIds || uploadIds.length === 0) { console.warn('[UploadManager] No upload_ids found for operation:', { id: operation.id, type: operation.type, status: operation.status, hasData: !!operation.data, hasResult: !!operation.result }); return; } // Handle cancellation if (event === 'cancel-operation') { return this.handleOperationCancelled(uploadIds); } // Update upload status based on operation status this.setBulkUpload(uploadIds, 'status', operation.status).then(() => { // Log for debugging console.log(`[UploadManager] Updated ${uploadIds.length} uploads to status: ${operation.status}`); }); // Handle completion if (operation.status === 'completed') { // For group uploads, mark as processed but keep for reference if (operation.type === 'process_upload_groups') { uploadIds.forEach(uploadId => { this.setBulkUpload([uploadId], 'serverProcessed', true).then(() => {}); }); // Log created posts if available if (operation.result && operation.result.created_posts) { console.log('[UploadManager] Created posts:', operation.result.created_posts); } // Remove uploads after a delay to allow UI to update setTimeout(() => { uploadIds.forEach(uploadId => { this.removeUpload(uploadId).then(() => {}); }); }, 2000); } // For direct uploads, remove immediately else { uploadIds.forEach(uploadId => { this.removeUpload(uploadId).then(() => {}); }); } } // Handle failures if (operation.status === 'failed' || operation.status === 'failed_permanent') { console.error('[UploadManager] Operation failed:', { id: operation.id, type: operation.type, uploadIds: uploadIds, error: operation.error_message }); } } }); } storesReady() { return this.stores.ready.length === 2; } handleStores(storeName, event) { if (event === 'data-ready') { this.stores.ready.push(storeName); if (this.storesReady()) { this.checkRecovery().then(()=>{}); } } } 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-wrapper', 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: '[data-select-all]', actions: '.selection-actions', count: '.selected .info', 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: '.item.upload', 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) { if (!window.targetCheck(e, this.selectors.fields.field)) return; //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) { let isMeta = e.target.closest('[data-upload-id], [data-attachment-id]'); if (isMeta) { this.queueUploadMeta(e); } 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.config.destination === 'post_group') { this.handleGroupMetaChange(e.target); } else { this.queueUploadMeta(e); } } handleGroupMetaChange(input) { // Get the groupId directly from the input's data attribute const groupId = input.dataset.groupId; if (!groupId) return; // Capture values immediately (before debouncer) const inputName = input.name; if (!inputName) return; const inputValue = input.value; // Extract the field name from the input name // Names are like "groupId[post_title]" or "groupId_post_title" const name = inputName .replace(`${groupId}[`, '') .replace(`${groupId}_`, '') .replace(']', ''); // Schedule the save with captured values window.debouncer.schedule(`group-meta-${groupId}-${name}`, async () => { const group = this.stores.groups.get(groupId); if (!group) return; // Initialize fields object if it doesn't exist if (!group.fields) { group.fields = {}; } group.fields[name] = inputValue; 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.updateHandlerItems(fieldId); }); this.a11y.announce(`${files.length} file(s) dropped for upload`); } } async queueUploads(endpoint, fieldId, dependsOn = null) { 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.repeaterPath || 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); if (dependsOn) { data.append('depends_on', dependsOn); } } 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; } this.notify('groups_uploaded', { fieldId: fieldId, posts: posts, content: field.config.content, }); } 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); this.notify('sent-to-queue', { field: field, operation: operationId, }); } else { await this.setBulkUpload(uploads, 'status', 'failed'); } 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: { 'X-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 = []; const validGroups = groups.filter(group => { const groupUploads = this.getGroupUploadsInOrder(group); return groupUploads.length > 0 && groupUploads.some(u => this.formatFile(u)); }); for (const group of validGroups) { const groupElement = this.groups.get(group.id)?.element; const fields = this.collectGroupFieldsFromDOM(groupElement, group.id); const post = { groupId: group.id, images: [], fields: fields }; const groupUploads = this.getGroupUploadsInOrder(group); for (const upload of groupUploads) { const file = this.formatFile(upload); if (file) { files.push(file); const imageData = { upload_id: upload.id, index: uploadMap.length }; const uploadEl = this.uploads.get(upload.id); const featuredInput = uploadEl?.element?.querySelector(`input[name="${group.id}_featured"]`); if (featuredInput?.checked) { post.fields.featured = upload.id; } post.images.push(imageData); uploadMap.push(upload.id); } } if (post.images.length > 0) { posts.push(post); } } // Handle remaining uploads not in any group const remaining = uploads.filter(u => !u.group); for (const upload of remaining) { const post = { groupId: window.generateID('group'), 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); } if (post.images.length > 0) { posts.push(post); } } return {posts, uploadMap, files}; } getGroupUploadsInOrder(group) { if (!group.uploads || group.uploads.length === 0) return []; return group.uploads .map(uploadId => this.stores.uploads.get(uploadId)) .filter(Boolean); // Remove any that don't exist } collectGroupFieldsFromDOM(groupElement, groupId) { if (!groupElement) return {}; const fields = {}; const inputs = groupElement.querySelectorAll('input, textarea, select'); inputs.forEach(input => { // Extract field name from input name like "groupId[post_title]" const name = input.name .replace(`${groupId}[`, '') .replace(`${groupId}_`, '') .replace(']', ''); // Skip system fields like featured, select-all if (['featured', 'select-all'].some(skip => name.includes(skip))) return; if (input.value) { fields[name] = input.value; } }); return fields; } 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 }; } queueUploadMeta(e) { let attachmentId = e.target.closest('[data-attachment-id]')?.dataset.attachmentId; let isUpload = false; if (!attachmentId) { attachmentId = e.target.closest('[data-upload-id]')?.dataset.uploadId; isUpload = true; if (!attachmentId) return; } if (!this.changes.has(attachmentId)) { let object = {}; if (isUpload) { object['uploadId'] = attachmentId; } else { object['attachmentId'] = attachmentId; } this.changes.set(attachmentId, object); } let field = e.target.closest('[data-field]'); let name = field.dataset.field; this.changes.get(attachmentId)[name] = e.target.value; this.scheduleSave(); } scheduleSave() { window.debouncer.schedule( `upload-meta`, async () => { if (this.changes.size > 0) { let items = {}; for (let [id, meta] of this.changes.entries()) { console.log(id, meta); items[id] = meta; } let data = { user: window.auth.getUser(), items: items }; await this.sendToQueue('uploads/meta', data, 'Uploading Meta', 'Uploading Meta', true); this.changes.clear(); } }, 2000 ); } /********************************************************************* FIELD LOGIC *********************************************************************/ scanFields(container, autoUpload = true, imageMeta = true) { const fields = container.querySelectorAll(this.selectors.fields.field); fields.forEach(uploader => this.registerField(uploader, autoUpload, imageMeta)); } registerField(element, autoUpload = true, imageMeta = true, id = null) { const data = { element: element, id: (id) ? id : this.determineFieldId(element), config: this.extractFieldConfig(element, autoUpload, imageMeta), 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); } this.maybeLockUploads(data.id); return data.id; } extractFieldConfig(el, autoUpload, imageMeta) { const config = { autoUpload: autoUpload, showMeta: imageMeta, destination: el.dataset.destination || 'meta', content: this.extractFieldContent(el), mode: el.dataset.mode || 'direct', type: el.dataset.type || 'single', name: el.dataset.field, itemID: this.extractFieldItemId(el) ?? 0, maxFiles: ('max-files' in el.dataset) ? parseInt(el.dataset.maxFiles) : 0, subType: el.dataset.subtype ?? 'image', repeaterPath: null }; const repeaterRow = el.closest('[data-index]'); const repeater = repeaterRow?.closest('[data-field][data-repeater-id]'); if (repeater && repeaterRow) { config.repeaterPath = `${repeater.dataset.field}:${repeaterRow.dataset.index}:${config.name}`; } return config; } 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 || ''; // If inside a repeater row, include repeater name + index for uniqueness const repeaterRow = fieldElement.closest('[data-index]'); const repeater = repeaterRow?.closest('[data-field][data-repeater-id]'); if (repeater && repeaterRow) { return `${content}${itemID}${repeater.dataset.field}_${repeaterRow.dataset.index}_${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 entry = queue.shift(); const blob = await this.processImage(entry.file, maxWidth, maxHeight); results.push({ uploadId: entry.uploadId, blob: blob }); } }; 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 processedImages = await this.processImages( imageEntries.map(e => ({ file: e.file, uploadId: e.uploadId })) ); // Update image uploads with processed blobs for (const { uploadId, blob } of processedImages) { const entry = imageEntries.find(e => e.uploadId === uploadId); if (entry) { entry.upload.blob = blob; entry.upload.fields.size = blob.size; entry.upload.status = 'queued'; await this.setUpload(uploadId, entry.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']}); const allGroups = Array.from(this.stores.groups.data.values()); for (const group of allGroups) { const hasUploads = this.stores.uploads.filterByIndex({group: group.id}).length > 0; if (!hasUploads) { await this.stores.groups.delete(group.id); } } if (pendingUploads.length === 0) 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); }); let data = { bySource: bySource, pendingUploads: pendingUploads }; document.body.append(this.templates.create('restoreNotification', data)); let notification = document.querySelector('dialog.restore-uploads'); this.restoreModal = new window.jvbModal(notification); this.restoreSelection = new window.jvbHandleSelection(notification, { wrapper: { wrapper: '.restore-field', id: 'selection' }, items: '.item-grid.restore', selectAll: { bulkControls: '.selection-actions', checkbox: '#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) { if ('crudManager' in window && fieldId.startsWith(window.crudManager.content)) { let [content, itemId, fieldName] = fieldId.split('_'); if (parseInt(itemId) > 0) { window.crudManager.openEditModal(itemId); field = document.querySelector(`[data-uploader="${fieldId}"]`); } else { console.log('No field found for '+fieldId); return; } } else { 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 field = this.fields.get(fieldId); if (!field) return null; let data = { uploadId: uploadId, file: file, field: field, }; return this.templates.create('uploadItem', data); } getSubtypeFromURL(url) { if (!url || url === '') { return ''; } const imgs = ['.webp', '.jpg', '.jpeg', '.png', '.gif', '.svg']; const videos = ['.mp4', '.ogg', '.mov', '.webm', '.avi']; const path = url.split('?')[0].toLowerCase(); if (imgs.some(ext => path.endsWith(ext))) return 'image'; if (videos.some(ext => path.endsWith(ext))) return 'video'; return 'document'; } getSubtypeFromMime(mimeType) { if (mimeType.startsWith('image/')) return 'image'; if (mimeType.startsWith('video/')) return 'video'; return 'document'; } /** * Called by handleAction * @param button */ async handleRemoveItem(button) { console.log('Handling remove upload'); const item = button.closest(this.selectors.items.item); if (!item) return; const uploadId = item.dataset.uploadId; const attachmentId = item.dataset.id; if (!uploadId && !attachmentId) return; if (!confirm('Remove this item?')) return; if (uploadId) { await this.removeUpload(uploadId); } else { const fieldId = this.getFieldIdFromElement(button); item.remove(); if (fieldId) { this.updateHiddenInput(fieldId); this.maybeLockUploads(fieldId); } } this.a11y.announce('Item removed'); } updateHiddenInput(fieldId) { const field = this.fields.get(fieldId); if (!field?.ui.hidden) return; const remaining = Array.from(field.ui.grid?.querySelectorAll(this.selectors.items.item) || []) .map(el => { if (Object.hasOwn(el.dataset, 'id') && el.dataset.id > 0) { return el.dataset.id; } if (Object.hasOwn(el.dataset, 'upload-id') && el.dataset.uploadId > 0) { return el.dataset.uploadId; } //For timeline return el.dataset.itemId; }) .filter(Boolean); const newValue = remaining.join(','); if (field.ui.hidden.value === newValue) return; field.ui.hidden.value = newValue; field.ui.hidden.dispatchEvent(new Event('change', { bubbles: true })); } async setBulkUpload(uploads, key, value) { const promises = Array.from(uploads).map(async (upload) => { if (typeof upload === 'string') upload = await this.stores.uploads.get(upload); if (!upload) return; 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 (typeof upload === 'string') upload = await this.stores.uploads.get(upload); if (!upload) return; 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; const fieldId = upload.field; // grab before clearing 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); } else { await this.stores.groups.save(group); } } await this.clearUpload(uploadId); this.updateHiddenInput(fieldId); this.maybeLockUploads(fieldId); let handler = this.selectionHandlers.get(fieldId); 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; const emptyGroup = field.groupUI.empty; if (emptyGroup?.nextSibling) { field.groupUI.grid.insertBefore(element, emptyGroup.nextSibling); } else { 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 data = { groupId: groupId, fieldId: fieldId, } let element = this.templates.create('imageGroup', data); this.groups.set(groupId, { element: element, ui: window.uiFromSelectors(this.selectors.group, element) }); this.getSelectionHandler(fieldId)?.addWrapper(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); } else { await this.stores.groups.save(group); } } } //clear any selection if (element.ui.checkbox) element.ui.checkbox.checked = false; // Remove from field-level selection const fieldHandler = this.selectionHandlers.get(upload.field); if (fieldHandler && fieldHandler.isSelected(uploadId)) { fieldHandler.deselect(uploadId); } 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; await this.stores.groups.save(group); } } let target = (groupId) ? this.groups.get(groupId)?.ui.grid : field.ui.grid; if (target) { target.append(element.element); if (groupId) { await this.handleReorder(upload.field, groupId); } } await 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) ) ); const field = this.fields.get(group.field); if (field) { const sortableKey = this.getGroupKey(group.field, groupId); const selectionHandler = this.selectionHandlers.get(sortableKey); if (selectionHandler?.destroy) { selectionHandler.destroy(); } this.selectionHandlers.get(group.field)?.removeWrapper(element.element); // Existing sortable cleanup 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??0; field.ui.dropZone.hidden = max > 0 && count >= max; } /******************************************************************************* OPERATION METHODS *******************************************************************************/ async handleOperationCancelled(uploads) { if (uploads.length === 0) return; uploads.forEach(upload => { this.removeUpload(upload); }); } /******************************************************************************* 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; if (field.config.destination !== 'post_group') return; let handler = new window.jvbHandleSelection(field.element, { selectAll: { checkbox: this.selectors.fields.selectAll, count: this.selectors.fields.count, bulkControls: this.selectors.fields.actions }, item: { item: this.selectors.items.item, checkbox: this.selectors.items.checkbox, idAttribute: 'uploadId', }, wrapper: { wrapper: '.preview-wrap, .upload-group', id: 'groupId' }, }); handler.subscribe((event, data) => { this.selected.set(fieldId, data.selectedItems); }); this.selectionHandlers.set(key, handler); } return this.selectionHandlers.get(key); } updateHandlerItems(fieldId) { let handler = this.getSelectionHandler(fieldId); if (!handler) return; handler.collectItems(); } /******************************************************************************* 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 }, dragClass: 'dragging', ignore: '.empty-group', 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); } } }, 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.stopPropagation(); 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(); e.stopPropagation(); 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; const targetGroupId = dropTarget.dataset.groupId || null; // Process sequentially to avoid race conditions for (const uploadId of uploadIds) { await this.addToGroup(uploadId, targetGroupId); } await this.handleReorder(fieldId, targetGroupId); this.selectionHandlers.get(fieldId)?.clearSelection(); } 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; } if (!groupId) { this.updateHiddenInput(fieldId); } else { let items = Array.from(target.children) .filter(el => el.matches(this.selectors.items.item) && !el.classList.contains('ghost')) .map(upload => upload.dataset.uploadId) .filter(id => id); let group = this.stores.groups.get(groupId); if (group) { group.uploads = items; this.stores.groups.save(group).then(()=>{}); } } 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(); } }); });