| | |
| | | class UploadManager { |
| | | constructor() { |
| | | //Load dependencies |
| | | this.queue = window.jvbQueue; |
| | | this.a11y = window.jvbA11y; |
| | | this.queue = window.jvbQueue; |
| | | this.error = window.jvbError; |
| | | this.notifications = window.jvbNotifications; |
| | | this.templates = window.jvbTemplates; |
| | | |
| | | //Load Datastore |
| | | this.initDB(); |
| | | this.subscribers = new Set(); |
| | | |
| | | //State management |
| | | this.initStores(); |
| | | this.initWorker(); |
| | | |
| | | |
| | | //Maps for DOM references |
| | | this.fields = new Map(); |
| | | this.uploads = new Map(); |
| | | this.uploadBlobs = new Map(); |
| | | this.timeouts = new Map(); |
| | | this.selected = new Map(); |
| | | this.groups = new Map(); |
| | | |
| | | //Worker |
| | | this.worker = { |
| | | worker: null, |
| | | timeout: null, |
| | | tasks: new Map(), |
| | | restart: { |
| | | count: 0, |
| | | max: 3, |
| | | 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: 10000, //10 seconds per image |
| | | batchSize: 1, |
| | | timeout: 3000, |
| | | maxConcurrent: 3, |
| | | restartAfterTimeout: true |
| | | } |
| | | }; |
| | | } |
| | | |
| | | //Groups! |
| | | this.touch = { |
| | | x: null, |
| | | y: null |
| | | 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); |
| | | |
| | | if (this.fields.has(upload.field)) { |
| | | let field = this.fields.get(upload.field); |
| | | console.log('Upload Status: ', upload.status); |
| | | switch (upload.status) { |
| | | case 'local_processing': |
| | | this.notify('upload-received', { |
| | | field: field.element, |
| | | id: upload.id |
| | | }); |
| | | } |
| | | } |
| | | this.hasBulkContext = document.querySelector('details.uploader')!==null; |
| | | this.isTouching = false; |
| | | this.groups = new Map(); |
| | | |
| | | //Notification and Subscribers |
| | | this.subscribers = new Set(); |
| | | |
| | | this.settings = { |
| | | allowedTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'], |
| | | maxFileSize: 5242880, |
| | | maxProcessingTime: 120000, // 2 minutes max for processing |
| | | processingCheckInterval: 5000, // Check every 5 seconds |
| | | smartCompression: true, |
| | | fieldTypes: { |
| | | 'single': { maxFiles: 1, allowMultiple: false }, |
| | | 'gallery': { maxFiles: 20, allowMultiple: true }, |
| | | 'groupable': { maxFiles: 20, allowMultiple: true } |
| | | 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 }); |
| | | } |
| | | }; |
| | | |
| | | this.statusMapping = { |
| | | 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 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); |
| | | } |
| | | } |
| | | //TODO: Old method of checkRecovery. All recovery logic has moved to the FormController.js |
| | | // 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); |
| | | } |
| | | } |
| | | // |
| | | // cleanupRestore() { |
| | | // this.restoreModal.handleClose(); |
| | | // this.restoreSelection.destroy(); |
| | | // this.restoreSelection = null; |
| | | // this.restoreModal.destroy(); |
| | | // this.restoreModal.modal.remove(); |
| | | // this.restoreModal = null; |
| | | // } |
| | | |
| | | async restoreUploads(uploadIds) { |
| | | const uploads = uploadIds.map(id => this.stores.uploads.get(id)).filter(Boolean); |
| | | if (uploads.length === 0) return; |
| | | await this.restoreSelectedUploads(uploads.map(u => u.id)); |
| | | } |
| | | |
| | | async clearUploads(uploadIds) { |
| | | await Promise.all(uploadIds.map(id => this.clearUpload(id))); |
| | | } |
| | | /******************************************************************************* |
| | | STATUS MANAGEMENT |
| | | *******************************************************************************/ |
| | | getStatusText(status) { |
| | | let map = { |
| | | 'received': 'Image Received', |
| | | 'local_processing': 'Processing Image...', |
| | | 'queued': 'Waiting to upload...', |
| | |
| | | 'failed_permanent': 'Upload failed permanently' |
| | | }; |
| | | |
| | | this.init(); |
| | | } |
| | | |
| | | async init() { |
| | | this.initElements(); |
| | | this.initListeners(); |
| | | this.initCompressionWorker(); |
| | | this.queue.subscribe((event, operation) => { |
| | | console.log('Operation Endpoint: ', operation.endpoint); |
| | | if (operation.endpoint !== 'uploads') { |
| | | return; |
| | | } |
| | | switch(event) { |
| | | case 'cancel-operation': |
| | | this.clearField(operation.data.get('field_key')); |
| | | break; |
| | | case 'operation-status': |
| | | console.log('Operation Data: ',operation.data); |
| | | const fieldId = operation.data?.field_key || |
| | | (operation.data instanceof FormData ? |
| | | operation.data.get('field_key') : null); |
| | | |
| | | if (fieldId) { |
| | | console.log('Updating field status:', fieldId, operation.status); |
| | | this.updateFieldStatus(fieldId, operation.status); |
| | | } |
| | | break; |
| | | } |
| | | }); |
| | | await this.checkPendingUploads(); |
| | | this.scanFields(); |
| | | } |
| | | |
| | | initElements() { |
| | | this.selectors = { |
| | | field: { |
| | | field: '.field.image', |
| | | dropZone: '.file-upload-container', |
| | | preview: '.item-grid.preview', |
| | | hiddenValue: 'input[type="hidden"]', |
| | | progress: { |
| | | progress: '.progress', |
| | | details: '.progress .details', |
| | | fill: '.progress .fill', |
| | | count: '.progress .count' |
| | | }, |
| | | }, |
| | | item: { |
| | | img: 'img', |
| | | progress: { |
| | | progress: '.progress', |
| | | details: '.progress .details', |
| | | fill: '.progress .fill', |
| | | count: '.progress .count' |
| | | }, |
| | | status: '.status', |
| | | select: '[name*="select-item"]', |
| | | actions: '.item-actions', |
| | | featured: '[name="featured"]', |
| | | meta: '.upload-meta' |
| | | }, |
| | | groups: { |
| | | container: '.item-grid.groups', |
| | | display: '.group-display', |
| | | selectAll: '#select-all-uploads', |
| | | actions: '.selection-actions', |
| | | info: '.selection-controls .info', |
| | | count: '.selection-count', |
| | | group: '.upload-group', |
| | | empty: '.empty-group' |
| | | } |
| | | }; |
| | | this.ui = {}; |
| | | } |
| | | |
| | | scanFields() { |
| | | document.querySelectorAll(this.selectors.field.field).forEach(uploader => { |
| | | this.registerUploader(uploader); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * |
| | | * @param {HTMLElement} uploader |
| | | * @param {object} options |
| | | * @param {string} options.id Uploader field ID: defaults to uploader.dataset.fieldId |
| | | * @param {string} options.type Uploader type: defaults to uploader.dataset.type |
| | | * @param {number} options.maxFiles Maximum files to allow: defaults to type defaults |
| | | * @param {boolean} options.multiple Whether to allow multiple uploads |
| | | * @param {number} options.itemID The post or term ID this is for. |
| | | * @param {string} options.mode |
| | | * @returns {string} |
| | | */ |
| | | registerUploader(uploader, options = {}) { |
| | | //Determine if this is for a post, term, content uploader, or option |
| | | let key = uploader.dataset['uploader']??this.determineKey(uploader); |
| | | |
| | | uploader.dataset['uploader'] = key; |
| | | |
| | | if (!this.fields.has(key)) { |
| | | let type = uploader.dataset.type; |
| | | |
| | | let typeConfig = this.settings.fieldTypes[type]??this.settings.fieldTypes['single']; |
| | | let config = { |
| | | key: key, |
| | | name: uploader.dataset.field, |
| | | ui: {}, |
| | | type: type, |
| | | maxFiles: typeConfig.maxFiles, |
| | | multiple: typeConfig.allowMultiple, |
| | | content: uploader.dataset.content??uploader.closest('dialog')?.dataset.content??uploader.closest('form').dataset.save??false, |
| | | itemID: uploader.dataset.itemID??uploader.closest('dialog')?.dataset.itemID??false, |
| | | context: uploader.dataset.context??uploader.closest('dialog')?.dataset.context??false, |
| | | mode: uploader.dataset.mode??'direct', |
| | | ... options |
| | | }; |
| | | |
| | | config.ui = window.uiFromSelectors(this.selectors, uploader); |
| | | config.ui.groups.groups = new Map(); |
| | | |
| | | this.selected.set(key, new Set()); |
| | | this.fields.set(key, config); |
| | | if(config.type === 'groupable' && !this.hasGroups) { |
| | | this.initGroupListeners(); |
| | | } |
| | | } |
| | | return key; |
| | | } |
| | | |
| | | /** |
| | | * Builds a key from the uploader, built from the Content Type, ItemID, and FieldName |
| | | * @param uploader |
| | | * @returns {string} |
| | | */ |
| | | determineKey(uploader) { |
| | | let content = uploader.dataset.content??uploader.closest('dialog')?.dataset.content??uploader.closest('form').dataset.save??''; |
| | | let itemID = uploader.dataset.itemID??uploader.closest('dialog')?.dataset.itemID??''; |
| | | let field = uploader.dataset.field; |
| | | return `${content}_${itemID}_${field}`; |
| | | } |
| | | |
| | | /** |
| | | * |
| | | * @param {HTMLElement} element |
| | | */ |
| | | getFieldIdFromElement(element) { |
| | | let field = element.closest('.field.image'); |
| | | if (!field) { |
| | | return; |
| | | } |
| | | return field.dataset.uploader??this.determineKey(field); |
| | | } |
| | | |
| | | getFieldFromElement(element) { |
| | | let id = this.getFieldIdFromElement(element); |
| | | return (this.fields.has(id)) ? this.fields.get(id) : false; |
| | | } |
| | | |
| | | getUploadFromElement(element) { |
| | | let id = this.getUploadIdFromElement(element); |
| | | return (this.uploads.has(id)) ? this.uploads.get(id) : false; |
| | | } |
| | | |
| | | getUploadIdFromElement(element) { |
| | | let upload = element.closest('[data-upload-id]'); |
| | | return upload?.dataset.uploadId || null; |
| | | } |
| | | |
| | | getGroupFromElement(element) { |
| | | let groupId = this.getGroupIdFromElement(element); |
| | | return (this.groups.has(groupId)) ? this.groups.get(groupId) : false; |
| | | } |
| | | getGroupIdFromElement(element) { |
| | | return element.dataset.groupId??element.closest('[data-group-id]')?.dataset.groupId??element.closest(':has([data-group-id])')?.querySelector('[data-group-id]')?.dataset.groupId??null; |
| | | } |
| | | |
| | | getModalType(field) { |
| | | // Safety check for field.ui |
| | | if (!field || !field.ui || !field.ui.field || !field.ui.field.field) { |
| | | return null; |
| | | } |
| | | |
| | | const dialog = field.ui.field.field.closest('dialog'); |
| | | if (!dialog) return null; |
| | | |
| | | if (dialog.classList.contains('edit')) return 'edit'; |
| | | if (dialog.classList.contains('create')) return 'create'; |
| | | if (dialog.classList.contains('bulkEdit')) return 'bulkEdit'; |
| | | |
| | | return dialog.className; |
| | | } |
| | | |
| | | getStatusText(status) { |
| | | return this.statusMapping[status] || status; |
| | | } |
| | | |
| | | getStatusIcon(status) { |
| | | return window.getIcon(this.queue.icons[status]); |
| | | return map[status]||status; |
| | | } |
| | | getStatusProgress(status) { |
| | | console.log('Getting status progress for: ', status); |
| | | switch (status) { |
| | | case 'local_processing': |
| | | return 28; |
| | | case 'queued': |
| | | return 50; |
| | | case 'uploading': |
| | | return 66; |
| | | case 'pending': |
| | | return 75; |
| | | case 'processing': |
| | | return 89; |
| | | case 'completed': |
| | | return 100; |
| | | default: |
| | | return 0; |
| | | } |
| | | } |
| | | |
| | | /****************************************************************************** |
| | | LISTENERS |
| | | ******************************************************************************/ |
| | | initListeners() { |
| | | this.clickHandler = this.handleClick.bind(this); |
| | | this.changeHandler = this.handleChange.bind(this); |
| | | |
| | | if (this.hasBulkContext) { |
| | | this.pasteHandler = this.handlePaste.bind(this); |
| | | document.addEventListener('paste', this.pasteHandler); |
| | | } |
| | | |
| | | |
| | | document.addEventListener('click', this.clickHandler); |
| | | document.addEventListener('change', this.changeHandler); |
| | | window.addEventListener('beforeunload', this.handleBeforeUnload.bind(this)); |
| | | } |
| | | clearListeners() { |
| | | document.removeEventListener('click', this.clickHandler); |
| | | document.removeEventListener('change', this.changeHandler); |
| | | if (this.hasBulkContext) { |
| | | document.removeEventListener('paste', this.pasteHandler); |
| | | } |
| | | } |
| | | |
| | | initGroupListeners() { |
| | | this.hasGroups = true; |
| | | |
| | | this.dragStartHandler = this.handleDragStart.bind(this); |
| | | this.dragEndHandler = this.handleDragEnd.bind(this); |
| | | this.dragEnterHandler = this.handleDragEnter.bind(this); |
| | | this.dragOverHandler = this.handleDragOver.bind(this); |
| | | this.dragLeaveHandler = this.handleDragLeave.bind(this); |
| | | this.dropHandler = this.handleDrop.bind(this); |
| | | |
| | | this.touchStartHandler = this.handleTouchStart.bind(this); |
| | | this.touchMoveHandler = this.handleTouchMove.bind(this); |
| | | this.touchEndHandler = this.handleTouchEnd.bind(this); |
| | | this.touchCancelHandler = this.handleTouchCancel.bind(this); |
| | | |
| | | document.addEventListener('dragstart', this.dragStartHandler); |
| | | document.addEventListener('dragend', this.dragEndHandler); |
| | | document.addEventListener('dragenter', this.dragEnterHandler); |
| | | document.addEventListener('dragover', this.dragOverHandler); |
| | | document.addEventListener('dragleave', this.dragLeaveHandler); |
| | | document.addEventListener('drop', this.dropHandler); |
| | | |
| | | document.addEventListener('touchstart', this.touchStartHandler); |
| | | document.addEventListener('touchmove', this.touchMoveHandler); |
| | | document.addEventListener('touchend', this.touchEndHandler); |
| | | document.addEventListener('touchcancel', this.touchCancelHandler); |
| | | } |
| | | clearGroupListeners() { |
| | | document.removeEventListener('dragstart', this.dragStartHandler); |
| | | document.removeEventListener('dragend', this.dragEndHandler); |
| | | document.removeEventListener('dragenter', this.dragEnterHandler); |
| | | document.removeEventListener('dragover', this.dragOverHandler); |
| | | document.removeEventListener('dragleave', this.dragLeaveHandler); |
| | | document.removeEventListener('drop', this.dropHandler); |
| | | |
| | | document.removeEventListener('touchstart', this.touchStartHandler); |
| | | document.removeEventListener('touchmove', this.touchMoveHandler); |
| | | document.removeEventListener('touchend', this.touchEndHandler); |
| | | document.removeEventListener('touchcancel', this.touchCancelHandler); |
| | | } |
| | | |
| | | handleClick(e) { |
| | | if (!e.target.closest(this.selectors.field.field)) { |
| | | return; |
| | | } |
| | | |
| | | if (window.targetCheck(e, '.restart-uploads')) { |
| | | e.preventDefault(); |
| | | const fieldId = this.getFieldIdFromElement(e.target); |
| | | this.restartUploads(fieldId); |
| | | } else if (window.targetCheck(e, '.dismiss-cache-restore')) { |
| | | e.preventDefault(); |
| | | const notification = e.target.closest('.upload-recovery-notification'); |
| | | if (notification) notification.remove(); |
| | | } else if (window.targetCheck(e, '#select-all-uploads')) { |
| | | e.preventDefault(); |
| | | this.handleSelectAll(e.target); |
| | | } else if (window.targetCheck(e, '.upload-select')) { |
| | | const isShiftClick = e.shiftKey && this.lastClickedUpload; |
| | | if (isShiftClick) { |
| | | e.preventDefault(); |
| | | this.handleRangeSelection(e.target, e); |
| | | } else { |
| | | this.updateSelection(e); |
| | | } |
| | | } else if (window.targetCheck(e, '.create-from-selection')) { |
| | | e.preventDefault(); |
| | | let group = this.createGroup(this.getFieldFromElement(e.target)); |
| | | this.addSelectionToGroup(group); |
| | | } else if (window.targetCheck(e, '.remove-selection')) { |
| | | e.preventDefault(); |
| | | this.removeSelection(e.target); |
| | | } else if (window.targetCheck(e, '.add-to-group, .add-selection-to-group')) { |
| | | e.preventDefault(); |
| | | this.addSelectionToGroup(e.target); |
| | | } else if (window.targetCheck(e, '.remove-group')) { |
| | | e.preventDefault(); |
| | | const groupElement = e.target.closest('.upload-group'); |
| | | if (groupElement) { |
| | | let field = this.getFieldFromElement(groupElement); |
| | | this.removeGroup(groupElement, true); |
| | | } |
| | | } else if (window.targetCheck(e, '.remove')) { |
| | | e.preventDefault(); |
| | | const uploadId = this.getUploadIdFromElement(e.target); |
| | | const fieldId = this.getFieldIdFromElement(e.target); |
| | | if (uploadId && fieldId) { |
| | | this.removeUpload(fieldId, uploadId); |
| | | } |
| | | } else if (window.targetCheck(e, '.submit-uploads')) { |
| | | e.preventDefault(); |
| | | const fieldId = this.getFieldIdFromElement(e.target); |
| | | this.submitUploads(fieldId); |
| | | } else if (window.targetCheck(e, '.retry-upload')) { |
| | | e.preventDefault(); |
| | | const uploadId = this.getUploadIdFromElement(e.target); |
| | | this.retryUpload(uploadId); |
| | | } |
| | | } |
| | | handleChange(e) { |
| | | if (!e.target.closest(this.selectors.field.field) || e.target.classList.contains(this.selectors.field.hiddenValue)) { |
| | | return; |
| | | } |
| | | e.preventDefault(); |
| | | |
| | | if (window.targetCheck(e, '[type="file"]')) { |
| | | console.log(this.fields); |
| | | let field = this.getFieldFromElement(e.target); |
| | | console.log(field); |
| | | if (!field) { |
| | | console.warn('File change on unregistered field: ', field.key) |
| | | return; |
| | | } |
| | | |
| | | const files = Array.from(e.target.files); |
| | | if (files.length === 0) return; |
| | | |
| | | this.processFiles(field.key, files); |
| | | e.target.value = ''; |
| | | } else if (e.target.name.includes('select-')) { |
| | | this.updateSelection(e); |
| | | } else if (e.target.closest('.upload-meta')) { |
| | | e.preventDefault(); |
| | | let name = e.target.name; |
| | | let value = e.target.value; |
| | | let upload = this.getUploadFromElement(e.target); |
| | | upload.changes[name] = value; |
| | | this.uploads.set(upload.id, upload); |
| | | this.persistFieldState(upload.fieldId); |
| | | |
| | | //It's meta! |
| | | //TODO: |
| | | //Step 1) determine whether the images have already been sent to the server. If not, we must wait until they have been |
| | | //Step 2) Queue the Meta changes. No need to wait, the Queue.js will handle any debouncing/timeouts |
| | | //Ensure the dependencies have all operations stored to the field that the images were uploaded with (can be multiple) |
| | | //Send to server for processing |
| | | } else if (e.target.closest('.group.fields')) { |
| | | let group = this.getGroupFromElement(e.target); |
| | | let name = e.target.name; |
| | | group.changes[name] = e.target.value; |
| | | |
| | | this.persistFieldState(group.fieldId); |
| | | this.groups.set(group.id, group); |
| | | } |
| | | } |
| | | |
| | | handlePaste(e) { |
| | | window.debouncer.schedule( |
| | | 'imagePaste', |
| | | () => { |
| | | const items = Array.from(e.clipboardData.items); |
| | | const imageItems = items.filter(item => item.type.startsWith('image/')); |
| | | |
| | | if (imageItems.length === 0) return; |
| | | |
| | | e.preventDefault(); |
| | | |
| | | const fieldId = this.getFieldIdFromElement(e.target); |
| | | if (!fieldId) return; |
| | | |
| | | // Convert clipboard items to files |
| | | const files = []; |
| | | imageItems.forEach((item, index) => { |
| | | const file = item.getAsFile(); |
| | | if (file) { |
| | | // Rename for clarity |
| | | const newFile = new File([file], `pasted_image_${index + 1}.png`, { |
| | | type: file.type, |
| | | lastModified: Date.now() |
| | | }); |
| | | files.push(newFile); |
| | | } |
| | | }); |
| | | |
| | | if (files.length > 0) { |
| | | this.processFiles(fieldId, files); |
| | | } |
| | | }, |
| | | 100 |
| | | ); |
| | | } |
| | | |
| | | isTouchOnFormElement(target) { |
| | | // Check if target is a form element or inside one |
| | | const formElements = [ |
| | | 'input', 'button', 'label', 'select', 'textarea', |
| | | ]; |
| | | |
| | | return formElements.some(selector => { |
| | | return target.matches(selector) || target.closest(selector); |
| | | }); |
| | | } |
| | | /**** DRAG AND TOUCH *****/ |
| | | startDragOperation(config) { |
| | | const { |
| | | primaryElement, |
| | | sourceType, |
| | | startPosition, |
| | | event |
| | | } = config; |
| | | |
| | | const uploadId = this.getUploadIdFromElement(primaryElement); |
| | | const fieldId = this.getFieldIdFromElement(primaryElement); |
| | | |
| | | // Determine what items to drag |
| | | const draggedItems = this.getDraggedItems(primaryElement); |
| | | |
| | | // Initialize drag state |
| | | this.dragState = { |
| | | primaryItem: uploadId, |
| | | draggedItems: draggedItems, |
| | | isDragging: true, |
| | | isMultiDrag: draggedItems.length > 1, |
| | | fieldId: fieldId, |
| | | sourceType: sourceType, |
| | | startTime: Date.now(), |
| | | startPosition: startPosition, |
| | | currentPosition: startPosition, |
| | | currentTarget: null, |
| | | validTarget: null, |
| | | dragPreview: null, |
| | | touchId: sourceType === 'touch' ? event.touches[0]?.identifier : null, |
| | | touchMoved: false |
| | | let progress = { |
| | | 'local_processing': 28, |
| | | 'queued': 50, |
| | | 'uploading': 66, |
| | | 'pending': 75, |
| | | 'processing': 89, |
| | | 'completed': 100 |
| | | }; |
| | | |
| | | // Create drag preview |
| | | this.createDragPreview(primaryElement); |
| | | |
| | | // Apply dragging state |
| | | this.applyDraggingState(true); |
| | | |
| | | const announceText = this.dragState.isMultiDrag |
| | | ? `Started dragging ${draggedItems.length} items` |
| | | : 'Started dragging item'; |
| | | |
| | | this.a11y.announce(announceText); |
| | | this.provideDragFeedback('start'); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | updateDragOperation(position, elementUnderPointer) { |
| | | if (!this.dragState.isDragging) return; |
| | | |
| | | const { sourceType, startPosition } = this.dragState; |
| | | |
| | | // Update position |
| | | this.dragState.currentPosition = position; |
| | | |
| | | // Check for significant movement (touch) |
| | | if (sourceType === 'touch' && !this.dragState.touchMoved) { |
| | | const deltaX = Math.abs(position.x - startPosition.x); |
| | | const deltaY = Math.abs(position.y - startPosition.y); |
| | | |
| | | if (deltaX > 10 || deltaY > 10) { |
| | | this.dragState.touchMoved = true; |
| | | } |
| | | } |
| | | |
| | | // Update preview and target |
| | | this.updateDragPreview(position); |
| | | this.updateDropTarget(elementUnderPointer); |
| | | } |
| | | |
| | | endDragOperation(elementUnderPointer = null) { |
| | | if (!this.dragState.isDragging) return; |
| | | |
| | | const wasSuccessful = (this.dragState.sourceType === 'drag' || this.dragState.touchMoved) && |
| | | this.dragState.validTarget; |
| | | |
| | | // Process drop if valid - but only here, not in handleDrop |
| | | if (wasSuccessful && this.dragState.validTarget) { |
| | | this.processItemDrop({ |
| | | itemIds: this.dragState.draggedItems, |
| | | targetElement: this.dragState.validTarget, |
| | | fieldId: this.dragState.fieldId, |
| | | dropType: this.dragState.isMultiDrag ? 'multiple' : 'single', |
| | | sourceType: this.dragState.sourceType |
| | | }); |
| | | } |
| | | |
| | | // Cleanup |
| | | this.cleanupDragOperation(); |
| | | |
| | | const announceText = wasSuccessful |
| | | ? (this.dragState.isMultiDrag ? `Moved ${this.dragState.draggedItems.length} items` : 'Item moved') |
| | | : 'Drag cancelled'; |
| | | |
| | | this.a11y.announce(announceText); |
| | | } |
| | | |
| | | /** |
| | | * Shared method to process any drop operation (drag or touch) |
| | | * @param {Object} dropData - Standardized drop data |
| | | * @returns {boolean} Success status |
| | | */ |
| | | processItemDrop(dropData) { |
| | | const { |
| | | itemIds, |
| | | targetElement, |
| | | fieldId, |
| | | dropType, |
| | | sourceType |
| | | } = dropData; |
| | | |
| | | if (!itemIds?.length || !targetElement || !fieldId) { |
| | | return false; |
| | | } |
| | | |
| | | // Determine if it's a preview drop |
| | | let isPreviewDrop = targetElement.classList.contains('item-grid') && targetElement.classList.contains('preview'); |
| | | |
| | | // Handle empty group drops by creating the group element |
| | | let actualTarget = targetElement; |
| | | if (targetElement.classList.contains('empty-group')) { |
| | | let group = this.createGroup(fieldId); |
| | | actualTarget = group.querySelector('.item-grid'); |
| | | isPreviewDrop = false; |
| | | } |
| | | |
| | | // Use existing addImageToGroup method for each item |
| | | // This method already handles: |
| | | // - removeImageFromCurrentLocation (cleanup of old location) |
| | | // - Adding to new location |
| | | // - Updating field.posts data structure |
| | | // - Caching the data |
| | | itemIds.forEach(uploadId => { |
| | | this.addImageToGroup(uploadId, actualTarget, isPreviewDrop); |
| | | }); |
| | | |
| | | // Clear selections for multi-drops |
| | | if (dropType === 'multiple') { |
| | | const field = this.fields.get(fieldId); |
| | | this.clearAllSelections(field); |
| | | } |
| | | |
| | | // Announce completion |
| | | const announceText = dropType === 'multiple' |
| | | ? `Moved ${itemIds.length} images to ${isPreviewDrop ? 'main area' : 'group'}` |
| | | : `Image moved to ${isPreviewDrop ? 'main area' : 'group'}`; |
| | | |
| | | this.a11y.announce(announceText); |
| | | this.provideFeedback(sourceType, 'success', { |
| | | count: itemIds.length, |
| | | isMultiple: dropType === 'multiple' |
| | | }); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | clearAllSelections(field) { |
| | | // Clear all selection checkboxes in the entire field container |
| | | const allCheckboxes = field.container.querySelectorAll('[name*="select-item"]'); |
| | | allCheckboxes.forEach(checkbox => { |
| | | checkbox.checked = false; |
| | | }); |
| | | |
| | | // Update the select all state |
| | | if (field.selectAll) { |
| | | field.selectAll.checked = false; |
| | | const label = field.selectAll.nextElementSibling; |
| | | if (label) { |
| | | label.textContent = 'Select All'; |
| | | } |
| | | } |
| | | |
| | | // Hide selection controls |
| | | if (field.selectActions) field.selectActions.hidden = true; |
| | | if (field.selectInfo) field.selectInfo.hidden = true; |
| | | } |
| | | |
| | | cleanupDragOperation() { |
| | | if (this.dragState.dragPreview) { |
| | | this.dragState.dragPreview.remove(); |
| | | } |
| | | |
| | | this.applyDraggingState(false); |
| | | this.clearDropTargetStates(); |
| | | |
| | | // Reset state |
| | | this.dragState.isDragging = false; |
| | | this.dragState.dragPreview = null; |
| | | this.dragState.draggedItems = []; |
| | | } |
| | | |
| | | /** |
| | | * Determine what items to drag (single or multiple selection) |
| | | */ |
| | | getDraggedItems(element) { |
| | | const selectedUploads = this.getSelectedUploads(element); |
| | | const primaryUploadId = element.dataset.uploadId; |
| | | |
| | | // If we have multiple selections and primary is selected, drag all |
| | | if (selectedUploads.length > 1 && selectedUploads.includes(primaryUploadId)) { |
| | | return selectedUploads; |
| | | } |
| | | |
| | | // Otherwise, just drag the primary item |
| | | return [primaryUploadId]; |
| | | } |
| | | |
| | | /** |
| | | * Apply/remove dragging visual state to items |
| | | */ |
| | | applyDraggingState(isDragging) { |
| | | this.dragState.draggedItems.forEach(uploadId => { |
| | | const element = document.querySelector(`[data-upload-id="${uploadId}"]`); |
| | | if (element) { |
| | | element.classList.toggle('dragging', isDragging); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Create drag preview element |
| | | */ |
| | | createDragPreview(originalElement) { |
| | | const { isMultiDrag, draggedItems } = this.dragState; |
| | | |
| | | if (isMultiDrag) { |
| | | this.dragState.dragPreview = this.createMultiDragPreview(originalElement, draggedItems); |
| | | } else { |
| | | this.dragState.dragPreview = this.createSingleDragPreview(originalElement); |
| | | } |
| | | |
| | | this.updateDragPreview(this.dragState.startPosition); |
| | | document.body.appendChild(this.dragState.dragPreview); |
| | | } |
| | | |
| | | /** |
| | | * Create single item drag preview |
| | | */ |
| | | createSingleDragPreview(originalElement) { |
| | | const preview = originalElement.cloneNode(true); |
| | | preview.dataset.uploadId = preview.dataset.uploadId+'-dragging'; |
| | | this.styleDragPreview(preview, false); |
| | | return preview; |
| | | } |
| | | |
| | | styleDragPreview(preview, isMulti = false) { |
| | | preview.style.cssText = ` |
| | | position: fixed; |
| | | z-index: 10000; |
| | | pointer-events: none; |
| | | opacity: 0.9; |
| | | transform: scale(1.05); |
| | | transition: transform 0.2s ease; |
| | | ${isMulti ? ` |
| | | width: 120px; |
| | | height: 120px; |
| | | background: white; |
| | | border-radius: 8px; |
| | | box-shadow: 0 8px 32px rgba(0,0,0,0.3); |
| | | padding: 4px; |
| | | ` : ` |
| | | border-radius: 4px; |
| | | box-shadow: 0 4px 16px rgba(0,0,0,0.2); |
| | | `} |
| | | `; |
| | | |
| | | // Add dragging class for additional styling |
| | | preview.classList.add('drag-preview', 'is-dragging'); |
| | | if (isMulti) { |
| | | preview.classList.add('multi-item'); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Create multiple items drag preview |
| | | */ |
| | | createMultiDragPreview(originalElement, draggedItems) { |
| | | const container = document.createElement('div'); |
| | | container.className = 'drag-preview multi-item'; |
| | | |
| | | // Create stacked effect with up to 3 items |
| | | const displayCount = Math.min(draggedItems.length, 3); |
| | | |
| | | for (let i = 0; i < displayCount; i++) { |
| | | const uploadId = draggedItems[i]; |
| | | const uploadElement = document.querySelector(`[data-upload-id="${uploadId}"]`); |
| | | |
| | | if (uploadElement) { |
| | | const stackedItem = uploadElement.cloneNode(true); |
| | | stackedItem.dataset.uploadId = uploadId + '_dragging'; |
| | | |
| | | stackedItem.style.cssText = ` |
| | | position: absolute; |
| | | top: ${i * 4}px; |
| | | left: ${i * 4}px; |
| | | width: calc(100% - ${i * 4}px); |
| | | height: calc(100% - ${i * 4}px); |
| | | opacity: ${1 - (i * 0.15)}; |
| | | transform: rotate(${(i - 1) * 2}deg); |
| | | z-index: ${10 - i}; |
| | | border-radius: 4px; |
| | | overflow: hidden; |
| | | `; |
| | | container.appendChild(stackedItem); |
| | | } |
| | | } |
| | | |
| | | // Add count badge |
| | | if (draggedItems.length > 1) { |
| | | const badge = this.createCountBadge(draggedItems.length); |
| | | container.appendChild(badge); |
| | | } |
| | | |
| | | this.styleDragPreview(container, true); |
| | | return container; |
| | | } |
| | | /** |
| | | * Update drag preview position |
| | | */ |
| | | updateDragPreview(position) { |
| | | if (!this.dragState.dragPreview) return; |
| | | |
| | | // Calculate offset based on preview type and source |
| | | let offset; |
| | | if (this.dragState.sourceType === 'touch') { |
| | | offset = this.dragState.isMultiDrag ? { x: -60, y: -80 } : { x: -50, y: -60 }; |
| | | } else { |
| | | offset = this.dragState.isMultiDrag ? { x: 15, y: 15 } : { x: 10, y: 10 }; |
| | | } |
| | | |
| | | const deltaX = position.x - this.dragState.startPosition.x; |
| | | const deltaY = position.y - this.dragState.startPosition.y; |
| | | |
| | | this.dragState.dragPreview.style.transform = `translate(${deltaX + offset.x}px, ${deltaY + offset.y}px) scale(1.05)`; |
| | | } |
| | | |
| | | /** |
| | | * Update drop target highlighting |
| | | */ |
| | | updateDropTarget(elementUnderPointer) { |
| | | // Clear previous target |
| | | if (this.dragState.currentTarget) { |
| | | this.clearDropTargetState(this.dragState.currentTarget); |
| | | } |
| | | |
| | | // Find valid drop target |
| | | const validTarget = this.findValidDropTarget(elementUnderPointer); |
| | | |
| | | // Update state |
| | | this.dragState.currentTarget = elementUnderPointer; |
| | | this.dragState.validTarget = validTarget; |
| | | |
| | | // Apply visual feedback |
| | | if (validTarget) { |
| | | this.applyDropTargetState(validTarget); |
| | | |
| | | // Haptic feedback for touch |
| | | if (this.dragState.sourceType === 'touch' && navigator.vibrate) { |
| | | const pattern = this.dragState.isMultiDrag ? [25, 10, 25] : [25]; |
| | | navigator.vibrate(pattern); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Find valid drop target from element |
| | | */ |
| | | findValidDropTarget(element) { |
| | | if (!element) return null; |
| | | |
| | | const postContainer = element.closest('.item-grid.group, .empty-group, .item-grid.preview'); |
| | | if (postContainer) { |
| | | const fieldId = this.getFieldIdFromElement(postContainer); |
| | | if (fieldId === this.dragState.fieldId) { |
| | | return postContainer; |
| | | } |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Apply drop target visual state |
| | | */ |
| | | applyDropTargetState(target) { |
| | | target.classList.add('dragover'); |
| | | |
| | | if (this.dragState.isMultiDrag) { |
| | | target.classList.add('multi-drop'); |
| | | target.setAttribute('data-item-count', this.dragState.draggedItems.length); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Clear drop target state from element |
| | | */ |
| | | clearDropTargetState(target) { |
| | | target.classList.remove('dragover', 'multi-drop'); |
| | | target.removeAttribute('data-item-count'); |
| | | } |
| | | |
| | | /** |
| | | * Clear all drop target states |
| | | */ |
| | | clearDropTargetStates() { |
| | | document.querySelectorAll('.dragover').forEach(el => { |
| | | el.classList.remove('dragover', 'multi-drop'); |
| | | el.removeAttribute('data-item-count'); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Create count badge for multi-item preview |
| | | */ |
| | | createCountBadge(count) { |
| | | const badge = document.createElement('div'); |
| | | badge.className = 'selection-count-badge'; |
| | | badge.textContent = count.toString(); |
| | | badge.style.cssText = ` |
| | | position: absolute; |
| | | top: -8px; |
| | | right: -8px; |
| | | background: var(--accent-primary); |
| | | color: white; |
| | | border-radius: 50%; |
| | | width: 24px; |
| | | height: 24px; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | font-size: 12px; |
| | | font-weight: bold; |
| | | box-shadow: 0 2px 8px rgba(0,0,0,0.3); |
| | | z-index: 20; |
| | | `; |
| | | return badge; |
| | | } |
| | | |
| | | /** |
| | | * Provide feedback for drag operations |
| | | */ |
| | | provideDragFeedback(type) { |
| | | const hapticPatterns = { |
| | | start: [50], |
| | | success: this.dragState.isMultiDrag ? [50, 25, 50, 25, 50] : [50, 25, 50], |
| | | cancel: [100] |
| | | }; |
| | | |
| | | if (this.dragState.sourceType === 'touch' && navigator.vibrate && hapticPatterns[type]) { |
| | | navigator.vibrate(hapticPatterns[type]); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Provide consistent feedback for different input methods |
| | | */ |
| | | provideFeedback(sourceType, feedbackType, data = {}) { |
| | | const hapticPatterns = { |
| | | success: data.isMultiple ? [50, 25, 50, 25, 50] : [50, 25, 50], |
| | | error: [100, 50, 100] |
| | | }; |
| | | |
| | | if (sourceType === 'touch' && navigator.vibrate && hapticPatterns[feedbackType]) { |
| | | navigator.vibrate(hapticPatterns[feedbackType]); |
| | | } |
| | | } |
| | | |
| | | clearDragoverStates() { |
| | | document.querySelectorAll('.dragover').forEach(el => { |
| | | el.classList.remove('dragover', 'multi-drop'); |
| | | el.removeAttribute('data-item-count'); |
| | | }); |
| | | } |
| | | /********* |
| | | * DRAG HANDLERS |
| | | ********/ |
| | | handleDragEnter(e) { |
| | | if (!window.targetCheck(e, '.image.field')) return; |
| | | |
| | | // Only handle external files |
| | | if (e.dataTransfer.types.includes('Files')) { |
| | | e.preventDefault(); |
| | | const uploadContainer = e.target.closest('.file-upload-container'); |
| | | if (uploadContainer) { |
| | | uploadContainer.classList.add('dragover'); |
| | | } |
| | | } |
| | | } |
| | | handleDragLeave(e) { |
| | | if (!window.targetCheck(e, '.image.field')) return; |
| | | |
| | | const uploadContainer = e.target.closest('.file-upload-container'); |
| | | if (uploadContainer && !uploadContainer.contains(e.relatedTarget)) { |
| | | uploadContainer.classList.remove('dragover'); |
| | | } |
| | | } |
| | | handleDragStart(e) { |
| | | if (!window.targetCheck(e, '.image.field')) return; |
| | | |
| | | const uploadItem = e.target.closest('[data-upload-id]'); |
| | | if (!uploadItem) return; |
| | | |
| | | const result = this.startDragOperation({ |
| | | primaryElement: uploadItem, |
| | | sourceType: 'drag', |
| | | startPosition: { x: e.clientX, y: e.clientY }, |
| | | event: e |
| | | }); |
| | | |
| | | if (result) { |
| | | e.dataTransfer.setData('text/plain', this.dragState.primaryItem); |
| | | e.dataTransfer.effectAllowed = 'move'; |
| | | } else { |
| | | e.preventDefault(); |
| | | } |
| | | } |
| | | |
| | | handleDragOver(e) { |
| | | if (!this.dragState.isDragging) return; |
| | | if (!window.targetCheck(e, '.image.field')) return; |
| | | |
| | | e.preventDefault(); |
| | | this.updateDragOperation({ x: e.clientX, y: e.clientY }, e.target); |
| | | } |
| | | |
| | | handleDrop(e) { |
| | | if (!window.targetCheck(e, '.image.field')) return; |
| | | |
| | | e.preventDefault(); |
| | | this.clearDragoverStates(); |
| | | |
| | | // Handle external files (new uploads) |
| | | const uploadContainer = e.target.closest('.file-upload-container'); |
| | | if (uploadContainer) { |
| | | const files = Array.from(e.dataTransfer.files); |
| | | if (files.length > 0) { |
| | | const fieldId = this.getFieldIdFromElement(uploadContainer); |
| | | if (fieldId) { |
| | | this.processFiles(fieldId, files); |
| | | this.a11y.announce(`${files.length} file(s) dropped for upload`); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | handleDragEnd(e) { |
| | | if (!this.dragState.isDragging) return; |
| | | |
| | | // Find the element under the final drop position |
| | | const elementUnderDrop = document.elementFromPoint( |
| | | this.dragState.currentPosition?.x || e.clientX, |
| | | this.dragState.currentPosition?.y || e.clientY |
| | | ); |
| | | |
| | | this.endDragOperation(elementUnderDrop); |
| | | } |
| | | /********* |
| | | * TOUCH HANDLERS |
| | | ********/ |
| | | handleTouchStart(e) { |
| | | if (!window.targetCheck(e, '.image.field')) return; |
| | | if (this.isTouchOnFormElement(e.target)) { |
| | | return; |
| | | } |
| | | |
| | | const uploadItem = e.target.closest('[data-upload-id]'); |
| | | if (!uploadItem) return; |
| | | |
| | | const touch = e.touches[0]; |
| | | |
| | | const result = this.startDragOperation({ |
| | | primaryElement: uploadItem, |
| | | sourceType: 'touch', |
| | | startPosition: { x: touch.clientX, y: touch.clientY }, |
| | | event: e |
| | | }); |
| | | |
| | | if (result) { |
| | | e.preventDefault(); // Prevent scrolling |
| | | } |
| | | } |
| | | |
| | | handleTouchMove(e) { |
| | | if (!this.dragState.isDragging) return; |
| | | |
| | | e.preventDefault(); |
| | | const touch = e.touches[0]; |
| | | const elementUnderTouch = document.elementFromPoint(touch.clientX, touch.clientY); |
| | | |
| | | this.updateDragOperation({ x: touch.clientX, y: touch.clientY }, elementUnderTouch); |
| | | } |
| | | |
| | | handleTouchEnd(e) { |
| | | if (!this.dragState.isDragging) return; |
| | | |
| | | e.preventDefault(); |
| | | const touch = e.changedTouches[0]; |
| | | const elementUnderTouch = document.elementFromPoint(touch.clientX, touch.clientY); |
| | | |
| | | this.endDragOperation(elementUnderTouch); |
| | | } |
| | | |
| | | handleTouchCancel(e) { |
| | | if (this.dragState.isDragging) { |
| | | this.cleanupDragOperation(); |
| | | this.a11y.announce('Drag cancelled'); |
| | | } |
| | | return progress[status]??0; |
| | | } |
| | | /******************************************************************************* |
| | | QUEUE INTEGRATION |
| | | *******************************************************************************/ |
| | | async submitUploads(fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | // Check if there are uploads to submit |
| | | const pendingUploads = Array.from(field.uploads || []) |
| | | .map(id => this.uploads.get(id)) |
| | | .filter(upload => upload && |
| | | (upload.status === 'processed' || |
| | | upload.status === 'processed-original')); |
| | | |
| | | if (pendingUploads.length === 0) { |
| | | this.notifications.add('No uploads ready to submit', 'warning'); |
| | | return; |
| | | } |
| | | |
| | | // Queue the uploads |
| | | try { |
| | | await this.queueUpload(fieldId); |
| | | this.notifications.add(`Submitting ${pendingUploads.length} upload(s)`, 'info'); |
| | | } catch (error) { |
| | | this.error.log(error, { |
| | | component: 'UploadManager', |
| | | action: 'submitUploads', |
| | | fieldId |
| | | }); |
| | | this.notifications.add('Failed to submit uploads', 'error'); |
| | | } |
| | | } |
| | | async retryUpload(uploadId) { |
| | | const upload = this.uploads.get(uploadId); |
| | | if (!upload) return; |
| | | |
| | | const field = this.fields.get(upload.fieldId); |
| | | if (!field) return; |
| | | |
| | | try { |
| | | // Reset status |
| | | this.updateUploadStatus(uploadId, 'received'); |
| | | |
| | | // If we have the processed file, skip to queuing |
| | | if (upload.processedFile) { |
| | | this.updateUploadStatus(uploadId, 'processed'); |
| | | await this.queueUpload(upload.fieldId); |
| | | } else if (upload.originalFile) { |
| | | // Reprocess the file |
| | | const reprocessed = await this.processFile(upload.fieldId, upload.originalFile); |
| | | if (reprocessed) { |
| | | await this.queueUpload(upload.fieldId); |
| | | } |
| | | } else { |
| | | throw new Error('No file data available for retry'); |
| | | } |
| | | |
| | | this.notifications.add('Retrying upload...', 'info'); |
| | | } catch (error) { |
| | | this.error.log(error, { |
| | | component: 'UploadManager', |
| | | action: 'retryUpload', |
| | | uploadId |
| | | }); |
| | | this.notifications.add('Failed to retry upload', 'error'); |
| | | } |
| | | } |
| | | async restartUploads(fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field?.uploads) return; |
| | | |
| | | const failedUploads = Array.from(field.uploads) |
| | | .map(id => this.uploads.get(id)) |
| | | .filter(upload => upload && upload.status === 'failed'); |
| | | |
| | | if (failedUploads.length === 0) { |
| | | this.notifications.add('No failed uploads to restart', 'info'); |
| | | return; |
| | | } |
| | | |
| | | for (const upload of failedUploads) { |
| | | await this.retryUpload(upload.id); |
| | | } |
| | | |
| | | this.notifications.add(`Restarting ${failedUploads.length} upload(s)`, 'info'); |
| | | } |
| | | async queueUpload(fieldId) { |
| | | //Further cache it, or is it already cached at this point? |
| | | const field = this.fields.get(fieldId); |
| | | if (!field?.uploads) return; |
| | | |
| | | const uploads = Array.from(field.uploads); |
| | | if (uploads.length === 0) { |
| | | return; |
| | | } |
| | | |
| | | const data = this.prepareUploadData(field, uploads); |
| | | this.a11y.announce('Queuing for upload'); |
| | | let img = (uploads.length === 1) ? 'image' : 'images'; |
| | | const operation = { |
| | | endpoint: 'uploads', |
| | | method: 'POST', |
| | | data: data, |
| | | title: `Uploading ${uploads.length} ${img} to server...`, |
| | | popup: `Uploading ${uploads.length} ${img}...`, |
| | | canMerge: false, |
| | | headers: { |
| | | 'action_nonce': jvbSettings.dash |
| | | }, |
| | | append: '_upload' |
| | | } |
| | | try { |
| | | const operationId = await this.queue.addToQueue(operation); |
| | | |
| | | uploads.forEach(uploadId => { |
| | | let upload = this.uploads.get(uploadId); |
| | | if (!upload) { |
| | | return; |
| | | } |
| | | upload.operationId = operationId; |
| | | this.updateUploadStatus(uploadId, 'queued'); |
| | | }); |
| | | field.operationId = operationId; |
| | | |
| | | return operationId; |
| | | } catch (error) { |
| | | throw error; |
| | | } finally { |
| | | this.persistFieldState(field.key); |
| | | } |
| | | } |
| | | |
| | | prepareUploadData(field, uploads) { |
| | | console.log('Preparing Upload:', field); |
| | | const formData = new FormData(); |
| | | formData.append('content', field.content); |
| | | formData.append('mode', field.mode); |
| | | formData.append('field_name', field.name); |
| | | formData.append('field_key', field.key); |
| | | formData.append('field_type', field.type); |
| | | formData.append('item_id', field.itemID); //post, term, or user id |
| | | formData.append('context', field.context); //post, term, or user |
| | | let uploadMap = []; |
| | | uploads.forEach(uploadId => { |
| | | let upload = this.uploads.get(uploadId); |
| | | if (upload) { |
| | | const fileToUpload = upload.processedFile || upload.originalFile; |
| | | if (fileToUpload) { |
| | | formData.append('files[]', fileToUpload); |
| | | uploadMap.push(upload.id); |
| | | } else { |
| | | console.warn(`No file for upload ${uploadId}`); |
| | | } |
| | | } else { |
| | | console.warn(`Upload ${uploadId} not found in uploads map`); |
| | | } |
| | | }); |
| | | formData.append('upload_map', uploadMap); |
| | | |
| | | console.log('Final FormData:'); |
| | | for (let pair of formData.entries()) { |
| | | console.log(pair[0], pair[1]); |
| | | } |
| | | |
| | | return formData; |
| | | } |
| | | |
| | | async queueImageMeta(e) { |
| | | const upload = this.getUploadFromElement(element); |
| | | if (!upload) return; |
| | | |
| | | const field = this.fields.get(upload.fieldId); |
| | | if (!field) return; |
| | | |
| | | // Collect meta data from the form |
| | | const metaContainer = element.closest('.upload-meta'); |
| | | if (!metaContainer) return; |
| | | |
| | | const metaData = { |
| | | title: metaContainer.querySelector('[name="title"]')?.value || '', |
| | | alt_text: metaContainer.querySelector('[name="alt_text"]')?.value || '', |
| | | caption: metaContainer.querySelector('[name="caption"]')?.value || '', |
| | | description: metaContainer.querySelector('[name="description"]')?.value || '' |
| | | }; |
| | | |
| | | // Update upload meta |
| | | upload.meta = { ...upload.meta, ...metaData }; |
| | | this.uploads.set(upload.id, upload); |
| | | |
| | | // Mark that we have meta changes |
| | | this.hasMetaChanges = true; |
| | | |
| | | // Determine if upload has been sent to server |
| | | const isOnServer = upload.status === 'completed' && upload.attachmentId; |
| | | |
| | | if (isOnServer) { |
| | | // Queue immediate update |
| | | await this.sendMetaUpdate(upload); |
| | | } else if (upload.operationId) { |
| | | // Wait for upload to complete, then send meta |
| | | this.queueDependentMetaUpdate(upload); |
| | | } else { |
| | | // Upload hasn't been queued yet, meta will be sent with initial upload |
| | | this.persistFieldState(field.key); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Send meta update to server |
| | | */ |
| | | async sendMetaUpdate(upload) { |
| | | const formData = new FormData(); |
| | | formData.append('attachment_id', upload.attachmentId); |
| | | formData.append('title', upload.meta.title); |
| | | formData.append('alt_text', upload.meta.alt_text); |
| | | formData.append('caption', upload.meta.caption); |
| | | formData.append('description', upload.meta.description); |
| | | |
| | | const operation = { |
| | | endpoint: 'uploads/meta', |
| | | method: 'POST', |
| | | data: formData, |
| | | title: `Updating metadata for ${upload.meta.originalName}`, |
| | | canMerge: true, |
| | | headers: { |
| | | 'action_nonce': jvbSettings.dash |
| | | } |
| | | }; |
| | | |
| | | try { |
| | | await this.queue.addToQueue(operation); |
| | | this.notifications.add('Metadata updated', 'success'); |
| | | } catch (error) { |
| | | this.error.log(error, { |
| | | component: 'UploadManager', |
| | | action: 'sendMetaUpdate', |
| | | uploadId: upload.id |
| | | }); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Queue meta update that depends on upload completion |
| | | */ |
| | | queueDependentMetaUpdate(upload) { |
| | | const operation = { |
| | | endpoint: 'uploads/meta', |
| | | method: 'POST', |
| | | dependencies: [upload.operationId], |
| | | data: () => { |
| | | // This function will be called when dependencies are resolved |
| | | const formData = new FormData(); |
| | | formData.append('operation_id', upload.operationId); |
| | | formData.append('upload_id', upload.id); |
| | | formData.append('title', upload.meta.title); |
| | | formData.append('alt_text', upload.meta.alt_text); |
| | | formData.append('caption', upload.meta.caption); |
| | | formData.append('description', upload.meta.description); |
| | | return formData; |
| | | }, |
| | | title: `Updating metadata after upload`, |
| | | canMerge: true, |
| | | headers: { |
| | | 'action_nonce': jvbSettings.dash |
| | | } |
| | | }; |
| | | |
| | | this.queue.addToQueue(operation); |
| | | } |
| | | /******************************************************************************* |
| | | IMAGE PROCESSING |
| | | UPLOAD METHODS |
| | | *******************************************************************************/ |
| | | async processFiles(fieldId, files) { |
| | | const field = this.fields.get(fieldId); |
| | | if(!field) return; |
| | | async createUpload(uploadId, file, fieldId) { |
| | | let field = this.fields.get(fieldId); |
| | | if (!field) return null; |
| | | |
| | | //Validate Files |
| | | const validFiles = files.filter(file=>this.validateFile(file, field)); |
| | | if (validFiles.length === 0) return; |
| | | |
| | | if (!this.checkFieldLimits(fieldId, validFiles.length)) { |
| | | // this.notify(`Cannot add ${validFiles.length} files. Field limit exceeded.`, 'warning'); |
| | | return; |
| | | } |
| | | const processedUploads = await this.processBatch(fieldId, validFiles); |
| | | |
| | | this.maybeLockUploads(fieldId); |
| | | |
| | | if (field.groupDisplay) { |
| | | field.groupDisplay.hidden = false; |
| | | } |
| | | if (processedUploads.length > 0) { |
| | | await this.queueUpload(fieldId); |
| | | } |
| | | |
| | | this.hideUploadProgress(fieldId); |
| | | |
| | | this.a11y.announce(`Processed ${processedUploads.length} of ${validFiles.length} files`); |
| | | } |
| | | |
| | | checkFieldLimits(fieldId, additionalFiles) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return false; |
| | | |
| | | const currentCount = field.uploads?.size || 0; |
| | | const totalCount = currentCount + additionalFiles; |
| | | |
| | | if (totalCount > field.maxFiles) { |
| | | this.notifications.add( |
| | | `Cannot add ${additionalFiles} files. Max ${field.maxFiles} allowed, currently have ${currentCount}.`, |
| | | 'warning' |
| | | ); |
| | | return false; |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | generateUploadId() { |
| | | return `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; |
| | | } |
| | | validateFile(file, field) { |
| | | // Type validation |
| | | if (!this.settings.allowedTypes.includes(file.type)) { |
| | | this.notify(`Invalid file type: ${file.type}`, 'error'); |
| | | return false; |
| | | } |
| | | |
| | | // Size validation |
| | | if (file.size > this.settings.maxFileSize) { |
| | | this.notify(`File too large: ${this.formatBytes(file.size)}`, 'error'); |
| | | return false; |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | formatBytes(bytes, decimals = 2) { |
| | | if (bytes === 0) return '0 Bytes'; |
| | | |
| | | const k = 1024; |
| | | const dm = decimals < 0 ? 0 : decimals; |
| | | const sizes = ['Bytes', 'KB', 'MB', 'GB']; |
| | | |
| | | const i = Math.floor(Math.log(bytes) / Math.log(k)); |
| | | |
| | | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; |
| | | } |
| | | |
| | | async processBatch(fieldId, files) { |
| | | const results = []; |
| | | const processingQueue = []; |
| | | const maxConcurrent = this.worker.settings.maxConcurrent; |
| | | |
| | | let total = files.length; |
| | | for (let i = 0; i < files.length; i++) { |
| | | this.updateUploadProgress(fieldId, i, total); |
| | | // Wait if we've reached max concurrent processing |
| | | if (processingQueue.length >= maxConcurrent) { |
| | | await Promise.race(processingQueue); |
| | | } |
| | | |
| | | const processPromise = this.processFile(fieldId, files[i]) |
| | | .then(upload => { |
| | | // Remove from processing queue |
| | | const index = processingQueue.indexOf(processPromise); |
| | | if (index > -1) processingQueue.splice(index, 1); |
| | | |
| | | if (upload) results.push(upload); |
| | | return upload; |
| | | }) |
| | | .catch(error => { |
| | | console.error(`Failed to process ${files[i].name}:`, error); |
| | | // Remove from processing queue |
| | | const index = processingQueue.indexOf(processPromise); |
| | | if (index > -1) processingQueue.splice(index, 1); |
| | | return null; |
| | | }); |
| | | |
| | | processingQueue.push(processPromise); |
| | | } |
| | | |
| | | // Wait for remaining files |
| | | await Promise.all(processingQueue); |
| | | return results; |
| | | } |
| | | |
| | | async processFile(fieldId, file) { |
| | | const field = this.fields.get(fieldId); |
| | | |
| | | const upload = await this.setUpload(fieldId, file); |
| | | const uploadId = upload.id; |
| | | try { |
| | | // Update UI immediately |
| | | this.addImageToGroup(uploadId); |
| | | this.updateUploadStatus(uploadId, 'local_processing'); |
| | | |
| | | // Attempt to process the image |
| | | let processedFile = null; |
| | | let processingFailed = false; |
| | | |
| | | try { |
| | | processedFile = await this.processImage(file, uploadId); |
| | | } catch (error) { |
| | | console.warn(`Processing failed for ${file.name}, using original:`, error); |
| | | processingFailed = true; |
| | | processedFile = file; // Use original |
| | | } |
| | | |
| | | // Update upload with processed file |
| | | upload.processedFile = processedFile; |
| | | upload.processingFailed = processingFailed; |
| | | |
| | | // Update status |
| | | this.updateUploadStatus(uploadId, 'processed'); |
| | | |
| | | // Save to uploads map |
| | | this.uploads.set(uploadId, upload); |
| | | |
| | | // Persist state |
| | | if (field && field.key) { |
| | | await this.persistFieldState(field.key); |
| | | } |
| | | |
| | | const message = processingFailed |
| | | ? `${file.name} added (original format)` |
| | | : `${file.name} processed and ready`; |
| | | this.a11y.announce(message); |
| | | |
| | | return upload; |
| | | |
| | | } catch (error) { |
| | | // Clean up failed upload |
| | | this.cleanupFailedUpload(uploadId, field.key); |
| | | |
| | | this.error.log(error, { |
| | | component: 'UploadManager', |
| | | action: 'processFile', |
| | | uploadId, |
| | | fileName: file.name |
| | | }); |
| | | |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | async processImage(file, uploadId) { |
| | | const timeout = this.worker.settings.timeout; |
| | | |
| | | return new Promise((resolve, reject) => { |
| | | let timeoutId; |
| | | let taskCompleted = false; |
| | | |
| | | // Set timeout |
| | | timeoutId = setTimeout(() => { |
| | | if (!taskCompleted) { |
| | | taskCompleted = true; |
| | | |
| | | // Remove from active tasks |
| | | this.worker.tasks.delete(uploadId); |
| | | |
| | | // Maybe restart worker if configured |
| | | if (this.worker.settings.restartAfterTimeout) { |
| | | this.restartCompressionWorker(); |
| | | } |
| | | |
| | | reject(new Error(`Processing timeout for ${file.name}`)); |
| | | } |
| | | }, timeout); |
| | | |
| | | // Track this task |
| | | this.worker.tasks.set(uploadId, { file, timeoutId }); |
| | | |
| | | // Process image |
| | | this.handleProcess(file, uploadId) |
| | | .then(result => { |
| | | if (!taskCompleted) { |
| | | taskCompleted = true; |
| | | clearTimeout(timeoutId); |
| | | this.worker.tasks.delete(uploadId); |
| | | resolve(result); |
| | | } |
| | | }) |
| | | .catch(error => { |
| | | if (!taskCompleted) { |
| | | taskCompleted = true; |
| | | clearTimeout(timeoutId); |
| | | this.worker.tasks.delete(uploadId); |
| | | reject(error); |
| | | } |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | async handleProcess(file, uploadId) { |
| | | // Skip non-images |
| | | if (!file.type.startsWith('image/')) { |
| | | return file; |
| | | } |
| | | |
| | | const maxDimension = this.getMaxDimension(); |
| | | const quality = 0.85; |
| | | |
| | | // Try worker first if available |
| | | if (this.shouldUseWorker(file)) { |
| | | try { |
| | | // Ensure worker is initialized |
| | | if (!this.worker.worker) { |
| | | this.initCompressionWorker(); |
| | | } |
| | | |
| | | if (this.worker.worker) { |
| | | return await this.processWithWorker(file, uploadId, maxDimension, quality); |
| | | } |
| | | } catch (error) { |
| | | console.warn('Worker processing failed, falling back to main thread:', error); |
| | | } |
| | | } |
| | | |
| | | // Fallback to main thread |
| | | return await this.processOnMainThread(file, maxDimension, quality); |
| | | } |
| | | |
| | | /** |
| | | * Process image on main thread with better error handling |
| | | */ |
| | | async processOnMainThread(file, maxDimension, quality) { |
| | | return new Promise((resolve, reject) => { |
| | | const img = new Image(); |
| | | const canvas = document.createElement('canvas'); |
| | | const ctx = canvas.getContext('2d'); |
| | | let objectUrl = null; |
| | | |
| | | const cleanup = () => { |
| | | img.onload = null; |
| | | img.onerror = null; |
| | | if (objectUrl) { |
| | | URL.revokeObjectURL(objectUrl); |
| | | objectUrl = null; |
| | | } |
| | | // Explicitly clean up canvas |
| | | canvas.width = 1; |
| | | canvas.height = 1; |
| | | ctx.clearRect(0, 0, 1, 1); |
| | | }; |
| | | |
| | | img.onload = () => { |
| | | try { |
| | | const { width, height } = this.calculateOptimalDimensions(img, maxDimension); |
| | | canvas.width = width; |
| | | canvas.height = height; |
| | | |
| | | // Enhanced image smoothing |
| | | ctx.imageSmoothingEnabled = true; |
| | | ctx.imageSmoothingQuality = 'high'; |
| | | ctx.drawImage(img, 0, 0, width, height); |
| | | |
| | | const outputFormat = this.getOptimalFormat(file); |
| | | const outputQuality = this.getOptimalQuality(file, quality); |
| | | |
| | | canvas.toBlob( |
| | | (blob) => { |
| | | cleanup(); |
| | | if (blob) { |
| | | const processedFile = new File( |
| | | [blob], |
| | | this.getProcessedFileName(file, outputFormat), |
| | | { type: outputFormat, lastModified: Date.now() } |
| | | ); |
| | | resolve(processedFile); |
| | | } else { |
| | | reject(new Error('Canvas toBlob failed')); |
| | | } |
| | | }, |
| | | outputFormat, |
| | | outputQuality |
| | | ); |
| | | |
| | | } catch (error) { |
| | | cleanup(); |
| | | reject(new Error(`Canvas processing failed: ${error.message}`)); |
| | | } |
| | | }; |
| | | |
| | | img.onerror = () => { |
| | | cleanup(); |
| | | reject(new Error(`Failed to load image: ${file.name}`)); |
| | | }; |
| | | |
| | | try { |
| | | objectUrl = URL.createObjectURL(file); |
| | | img.src = objectUrl; |
| | | } catch (error) { |
| | | cleanup(); |
| | | reject(new Error(`Failed to create object URL: ${error.message}`)); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Get optimal output format |
| | | */ |
| | | getOptimalFormat(file) { |
| | | // Keep original format for certain types |
| | | if (file.type === 'image/gif' || file.type === 'image/svg+xml') { |
| | | return file.type; |
| | | } |
| | | |
| | | // Use WebP if supported, otherwise JPEG |
| | | return this.supportsWebP() ? 'image/webp' : 'image/jpeg'; |
| | | } |
| | | |
| | | /** |
| | | * Get optimal quality setting |
| | | */ |
| | | getOptimalQuality(file, requestedQuality) { |
| | | // Higher quality for smaller files |
| | | if (file.size < 500 * 1024) return Math.max(requestedQuality, 0.9); |
| | | if (file.size < 2 * 1024 * 1024) return requestedQuality; |
| | | |
| | | // Lower quality for very large files |
| | | return Math.min(requestedQuality, 0.8); |
| | | } |
| | | |
| | | /** |
| | | * Generate processed file name |
| | | */ |
| | | getProcessedFileName(originalFile, outputFormat) { |
| | | const baseName = originalFile.name.replace(/\.[^/.]+$/, ''); |
| | | |
| | | const extensions = { |
| | | 'image/webp': '.webp', |
| | | 'image/jpeg': '.jpg', |
| | | 'image/png': '.png', |
| | | 'image/gif': '.gif' |
| | | let data = { |
| | | uploadId: uploadId, |
| | | file: file, |
| | | field: field, |
| | | }; |
| | | |
| | | return baseName + (extensions[outputFormat] || '.jpg'); |
| | | 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'; |
| | | } |
| | | /** |
| | | * Get maximum dimension based on device capabilities |
| | | * Called by handleAction |
| | | * @param button |
| | | */ |
| | | getMaxDimension() { |
| | | const screenWidth = window.screen.width; |
| | | const devicePixelRatio = window.devicePixelRatio || 1; |
| | | async handleRemoveItem(button) { |
| | | console.log('Handling remove upload'); |
| | | const item = button.closest(this.selectors.items.item); |
| | | if (!item) return; |
| | | |
| | | // Scale based on device capabilities |
| | | if (screenWidth * devicePixelRatio > 2560) return 2400; |
| | | if (screenWidth * devicePixelRatio > 1920) return 1920; |
| | | return 1200; |
| | | } |
| | | const uploadId = item.dataset.uploadId; |
| | | const attachmentId = item.dataset.id; |
| | | |
| | | /** |
| | | * Determine if we should use Web Worker |
| | | */ |
| | | shouldUseWorker(file) { |
| | | // Use worker for large files or when available |
| | | return this.worker.worker && |
| | | file.size > 1024 * 1024 && // > 1MB |
| | | typeof OffscreenCanvas !== 'undefined'; |
| | | } |
| | | if (!uploadId && !attachmentId) return; |
| | | if (!confirm('Remove this item?')) return; |
| | | |
| | | async processWithWorker(file, uploadId, maxDimension, quality) { |
| | | return new Promise((resolve, reject) => { |
| | | if (!this.worker.worker) { |
| | | reject(new Error('Worker not available')); |
| | | return; |
| | | if (uploadId) { |
| | | await this.removeUpload(uploadId); |
| | | } else { |
| | | const fieldId = this.getFieldIdFromElement(button); |
| | | item.remove(); |
| | | |
| | | if (fieldId) { |
| | | this.updateHiddenInput(fieldId); |
| | | this.maybeLockUploads(fieldId); |
| | | } |
| | | } |
| | | |
| | | // Create unique message ID for this task |
| | | const messageId = `${uploadId}_${Date.now()}`; |
| | | this.a11y.announce('Item removed'); |
| | | } |
| | | |
| | | // Handler for this specific message |
| | | const messageHandler = (e) => { |
| | | if (e.data.messageId !== messageId) return; |
| | | updateHiddenInput(fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field?.ui.hidden) return; |
| | | |
| | | // Remove handler |
| | | this.worker.worker.removeEventListener('message', messageHandler); |
| | | this.worker.worker.removeEventListener('error', errorHandler); |
| | | |
| | | if (e.data.success) { |
| | | const processedFile = new File( |
| | | [e.data.blob], |
| | | this.getProcessedFileName(file, e.data.format || 'image/webp'), |
| | | { type: e.data.format || 'image/webp', lastModified: Date.now() } |
| | | ); |
| | | resolve(processedFile); |
| | | } else { |
| | | reject(new Error(e.data.error || 'Worker processing failed')); |
| | | const 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; |
| | | } |
| | | }; |
| | | |
| | | const errorHandler = (error) => { |
| | | this.worker.worker.removeEventListener('message', messageHandler); |
| | | this.worker.worker.removeEventListener('error', errorHandler); |
| | | reject(new Error(`Worker error: ${error.message}`)); |
| | | }; |
| | | |
| | | // Add handlers |
| | | this.worker.worker.addEventListener('message', messageHandler); |
| | | this.worker.worker.addEventListener('error', errorHandler); |
| | | |
| | | // Send message to worker |
| | | this.worker.worker.postMessage({ |
| | | messageId, |
| | | file, |
| | | maxDimension, |
| | | quality, |
| | | outputFormat: this.getOptimalFormat(file) |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Restart compression worker |
| | | */ |
| | | restartCompressionWorker() { |
| | | console.log('Restarting compression worker...'); |
| | | |
| | | // Terminate existing worker |
| | | if (this.worker.worker) { |
| | | this.worker.worker.terminate(); |
| | | this.worker.worker = null; |
| | | } |
| | | |
| | | // Clear active tasks |
| | | this.worker.tasks.clear(); |
| | | |
| | | // Check restart limit |
| | | if (this.worker.restart.count >= this.worker.restart.max) { |
| | | console.error('Max worker restarts reached, disabling worker'); |
| | | return; |
| | | } |
| | | |
| | | this.worker.restart.count++; |
| | | |
| | | // Reinitialize |
| | | this.initCompressionWorker(); |
| | | } |
| | | |
| | | /** |
| | | * Initialize Web Worker for image compression |
| | | */ |
| | | initCompressionWorker() { |
| | | if (this.worker.worker || typeof Worker === 'undefined') return; |
| | | |
| | | try { |
| | | const workerScript = ` |
| | | self.onmessage = async function(e) { |
| | | const { messageId, file, maxDimension, quality, outputFormat } = e.data; |
| | | |
| | | try { |
| | | // Create ImageBitmap from file |
| | | const bitmap = await createImageBitmap(file); |
| | | |
| | | // Calculate dimensions |
| | | const scale = Math.min(maxDimension / bitmap.width, maxDimension / bitmap.height, 1); |
| | | const width = Math.round(bitmap.width * scale); |
| | | const height = Math.round(bitmap.height * scale); |
| | | |
| | | // Create OffscreenCanvas |
| | | const canvas = new OffscreenCanvas(width, height); |
| | | const ctx = canvas.getContext('2d'); |
| | | |
| | | // Draw and resize |
| | | ctx.imageSmoothingEnabled = true; |
| | | ctx.imageSmoothingQuality = 'high'; |
| | | ctx.drawImage(bitmap, 0, 0, width, height); |
| | | |
| | | // Clean up bitmap |
| | | bitmap.close(); |
| | | |
| | | // Convert to blob |
| | | const blob = await canvas.convertToBlob({ |
| | | type: outputFormat, |
| | | quality: quality |
| | | }); |
| | | |
| | | self.postMessage({ |
| | | messageId, |
| | | success: true, |
| | | blob: blob, |
| | | format: outputFormat |
| | | }); |
| | | |
| | | } catch (error) { |
| | | self.postMessage({ |
| | | messageId, |
| | | success: false, |
| | | error: error.message |
| | | }); |
| | | } |
| | | }; |
| | | `; |
| | | |
| | | const blob = new Blob([workerScript], { type: 'application/javascript' }); |
| | | this.worker.worker = new Worker(URL.createObjectURL(blob)); |
| | | |
| | | } catch (error) { |
| | | console.warn('Failed to initialize compression worker:', error); |
| | | this.worker.worker = null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Calculate optimal dimensions with aspect ratio preservation |
| | | */ |
| | | calculateOptimalDimensions(img, maxDimension) { |
| | | let { width, height } = img; |
| | | |
| | | // Don't upscale |
| | | if (width <= maxDimension && height <= maxDimension) { |
| | | return { width, height }; |
| | | } |
| | | |
| | | // Calculate scale factor |
| | | const scale = Math.min(maxDimension / width, maxDimension / height); |
| | | |
| | | return { |
| | | width: Math.round(width * scale), |
| | | height: Math.round(height * scale) |
| | | }; |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Check WebP support |
| | | */ |
| | | supportsWebP() { |
| | | const canvas = document.createElement('canvas'); |
| | | return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0; |
| | | } |
| | | |
| | | /** |
| | | * Clean up failed upload |
| | | */ |
| | | cleanupFailedUpload(uploadId, fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (field?.uploads) { |
| | | field.uploads.delete(uploadId); |
| | | } |
| | | |
| | | const upload = this.uploads.get(uploadId); |
| | | if (upload) { |
| | | // Clean up preview URL |
| | | if (upload.preview?.startsWith('blob:')) { |
| | | URL.revokeObjectURL(upload.preview); |
| | | } |
| | | |
| | | // Remove element |
| | | upload.element?.remove(); |
| | | |
| | | // Remove from uploads |
| | | this.uploads.delete(uploadId); |
| | | } |
| | | |
| | | // Remove from active tasks |
| | | this.worker.tasks.delete(uploadId); |
| | | } |
| | | /******************************************************************************* |
| | | UI FUNCTIONALITY |
| | | *******************************************************************************/ |
| | | /** |
| | | * Update upload status correctly |
| | | */ |
| | | updateUploadStatus(uploadId, status) { |
| | | console.log('Updating upload status for: ', uploadId); |
| | | let upload = this.uploads.get(uploadId); |
| | | if(!upload) { |
| | | return; |
| | | } |
| | | upload.status = status; |
| | | |
| | | this.updateImageUI(upload.id); |
| | | this.persistFieldState(upload.fieldId); |
| | | } |
| | | updateImageUI(uploadId) { |
| | | console.log('Updating image UI: ', uploadId); |
| | | const upload = this.uploads.get(uploadId); |
| | | console.log(upload); |
| | | if (!upload?.element) return; |
| | | |
| | | |
| | | const progressEl = upload.element.querySelector('.progress'); |
| | | const itemEl = upload.element; |
| | | |
| | | console.log('Updating Upload UI:', upload); |
| | | // Update status class on item for CSS styling |
| | | if (itemEl) { |
| | | itemEl.className = itemEl.className.replace(/status-[\w-]+/g, ''); |
| | | itemEl.classList.add(`status-${upload.status}`); |
| | | } |
| | | |
| | | if (progressEl) { |
| | | let icon = this.getStatusIcon(upload.status); |
| | | let message = this.getStatusText(upload.status); |
| | | let progress = this.getStatusProgress(upload.status); |
| | | |
| | | const fill = progressEl.querySelector('.fill'); |
| | | const itemIcon = progressEl.querySelector('span.icon'); |
| | | const itemMessage = progressEl.querySelector('span.details'); |
| | | |
| | | if (fill) { |
| | | fill.style.width = `${progress}%`; |
| | | } |
| | | if (itemMessage) itemMessage.textContent = message; |
| | | if (itemIcon) { |
| | | window.removeChildren(itemIcon); |
| | | itemIcon.append(icon); |
| | | } |
| | | |
| | | if (upload.status === 'completed') { |
| | | setTimeout(() => { |
| | | if (progressEl) { |
| | | window.fade(progressEl, false); |
| | | } |
| | | }, 1000); |
| | | } |
| | | } |
| | | } |
| | | /** |
| | | * Hide the uploader drop zone if we have reached our limit |
| | | */ |
| | | maybeLockUploads(fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | // Hide/show drop zone based on file count |
| | | if (field.ui.field.dropZone) { |
| | | field.ui.field.dropZone.hidden = field.uploads && field.uploads.size >= field.maxFiles; |
| | | } |
| | | } |
| | | createImageElement(upload, draggable = false) { |
| | | let image = window.getTemplate('uploadItem'); |
| | | if (!image) { |
| | | console.error('Image template not found'); |
| | | return; |
| | | } |
| | | image.dataset.uploadId = upload.id; |
| | | image.querySelector('[name="featured"]').value = upload.id; |
| | | let [ |
| | | featured, |
| | | img, |
| | | details |
| | | ] = [ |
| | | image.querySelector('[name="featured"]'), |
| | | image.querySelector('img'), |
| | | image.querySelector('details') |
| | | ]; |
| | | [ |
| | | featured.value, |
| | | img.src, |
| | | img.alt |
| | | ] = [ |
| | | upload.id, |
| | | upload.preview, |
| | | upload.originalFile?.name ?? upload.meta?.originalName ?? '', |
| | | ]; |
| | | if (details) { |
| | | let template = window.getTemplate('uploadMeta'); |
| | | if (template){ |
| | | details.append(template); |
| | | } |
| | | } |
| | | image.draggable = draggable; |
| | | |
| | | // Update input IDs safely |
| | | image.querySelectorAll('input').forEach(input => { |
| | | let id = input.id; |
| | | if (id) { |
| | | let newId = id + upload.id; |
| | | let label = input.parentNode.querySelector(`label[for="${id}"]`); |
| | | input.id = newId; |
| | | if (label) { |
| | | label.htmlFor = newId; |
| | | if (Object.hasOwn(el.dataset, 'upload-id') && el.dataset.uploadId > 0) { |
| | | return el.dataset.uploadId; |
| | | } |
| | | } |
| | | }); |
| | | |
| | | return image; |
| | | } |
| | | |
| | | updateUploadProgress(fieldId, current, total, message) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | let progressBar = field.ui.field.progress.progress; |
| | | |
| | | // Create progress bar if it doesn't exist |
| | | if (!progressBar) { |
| | | progressBar = window.getTemplate('imageProgress'); |
| | | |
| | | // Insert after drop zone or at top of container |
| | | const insertAfter = field.dropZone || field.container.firstElementChild; |
| | | if (insertAfter) { |
| | | insertAfter.insertAdjacentElement('afterend', progressBar); |
| | | } else { |
| | | field.container.prepend(progressBar); |
| | | } |
| | | } |
| | | |
| | | // Update progress bar |
| | | const progressPercent = total > 0 ? Math.round((current / total) * 100) : 0; |
| | | const progressFill = field.ui.field.progress.fill; |
| | | const progressMessage = field.ui.field.progress.details; |
| | | const progressCount = field.ui.field.progress.count; |
| | | |
| | | if (progressFill) { |
| | | progressFill.style.width = `${progressPercent}%`; |
| | | } |
| | | |
| | | if (progressMessage) { |
| | | progressMessage.textContent = message; |
| | | } |
| | | |
| | | if (progressCount) { |
| | | progressCount.textContent = `${current}/${total}`; |
| | | } |
| | | |
| | | // Add completion styling |
| | | if (current === total) { |
| | | progressBar.classList.add('completed'); |
| | | } |
| | | } |
| | | |
| | | hideUploadProgress(fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | const progressBar = field.ui.field.progress.progress; |
| | | if (progressBar) { |
| | | window.fade(progressBar, false); |
| | | } |
| | | } |
| | | /******************************************************************************* |
| | | INDEXEDDB CACHE FUNCTIONALITY |
| | | *******************************************************************************/ |
| | | async initDB() { |
| | | if (!('indexedDB' in window)) return; |
| | | |
| | | const request = indexedDB.open(`jvb_uploads_db`, 1); |
| | | |
| | | request.onupgradeneeded = (e) => { |
| | | const db = e.target.result; |
| | | if (!db.objectStoreNames.contains('fieldStates')) { |
| | | const store = db.createObjectStore('fieldStates', { keyPath: 'fieldId' }); |
| | | store.createIndex('timestamp', 'timestamp', { unique: false }); |
| | | store.createIndex('content', 'content', { unique: false }); |
| | | store.createIndex('itemId', 'itemId', { unique: false }); |
| | | } |
| | | |
| | | // Blob storage remains separate for performance |
| | | if (!db.objectStoreNames.contains('uploadBlobs')) { |
| | | db.createObjectStore('uploadBlobs', { keyPath: 'uploadId' }); |
| | | } |
| | | }; |
| | | |
| | | request.onsuccess = (e) => { |
| | | this.db = e.target.result; |
| | | this.loadFields(); |
| | | }; |
| | | |
| | | request.onerror = (e) => { |
| | | console.error('IndexedDB error:', e); |
| | | }; |
| | | } |
| | | |
| | | async loadFields() { |
| | | if (!this.db) return; |
| | | |
| | | return new Promise((resolve) => { |
| | | const tx = this.db.transaction(['fieldStates', 'uploadBlobs'], 'readonly'); |
| | | const fieldStates = tx.objectStore('fieldStates'); |
| | | const blobStore = tx.objectStore('uploadBlobs'); |
| | | const request = fieldStates.getAll(); |
| | | |
| | | request.onsuccess = (e) => { |
| | | e.target.result.forEach(field => { |
| | | let uploads = field.uploads; |
| | | let uploadIds = uploads.map(upload => upload.id); |
| | | field.uploads = new Set(uploadIds); |
| | | this.fields.set(field.key, field); |
| | | uploads.forEach(upload => { |
| | | this.uploads.set(upload.id, upload); |
| | | }); |
| | | }); |
| | | this.notify('uploads-loaded', { items: Array.from(this.uploads.values()) }); |
| | | resolve(); |
| | | }; |
| | | |
| | | const blobRequest = blobStore.getAll(); |
| | | |
| | | blobRequest.onsuccess = (e) => { |
| | | e.target.result.forEach(item => { |
| | | this.uploadBlobs.set(item.id, item); |
| | | }); |
| | | this.notify('blobs-loaded', { items: Array.from(this.uploadBlobs.values()) }); |
| | | resolve(); |
| | | }; |
| | | }); |
| | | } |
| | | |
| | | getUpload(uploadId) { |
| | | return this.uploads.get(uploadId); |
| | | } |
| | | |
| | | clearField(fieldId) { |
| | | let uploads = Array.from(this.fields.uploads); |
| | | uploads.forEach(upload => { |
| | | this.uploads.delete(upload); |
| | | }); |
| | | this.fields.delete(fieldId); |
| | | if (this.db) { |
| | | const tx = this.db.transaction(['fieldStates', 'uploadBlobs'], 'readwrite'); |
| | | tx.objectStore('fieldStates').delete(fieldId); |
| | | uploads.forEach(upload => { |
| | | tx.objectStore('uploadBlobs').delete(upload); |
| | | }); |
| | | } |
| | | } |
| | | |
| | | updateFieldStatus(fieldId, status) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | field.uploads.forEach(upload => { |
| | | console.log('Attempting to set upload to status: ', status); |
| | | this.updateUploadStatus(upload, status); |
| | | }); |
| | | |
| | | // Update UI based on status |
| | | const container = field.ui.field.field; |
| | | if (container) { |
| | | container.dataset.uploadStatus = status; |
| | | |
| | | // Show/hide relevant UI elements |
| | | const submitBtn = container.querySelector('.submit-uploads'); |
| | | if (submitBtn) { |
| | | submitBtn.disabled = status === 'uploading' || status === 'processing'; |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle successful upload completion |
| | | */ |
| | | handleUploadComplete(operation) { |
| | | const response = operation.response; |
| | | if (!response?.uploads) return; |
| | | |
| | | // Map server IDs to uploads |
| | | response.uploads.forEach(serverUpload => { |
| | | const upload = this.uploads.get(serverUpload.upload_id); |
| | | if (upload) { |
| | | upload.attachmentId = serverUpload.attachment_id; |
| | | this.updateUploadStatus(serverUpload.upload_id, 'completed'); |
| | | this.uploads.set(upload.id, upload); |
| | | |
| | | // Clear from cache since it's now on server |
| | | this.clearUpload(upload.id); |
| | | } |
| | | }); |
| | | |
| | | // Persist updated field state |
| | | const fieldKey = operation.data.get('field_key'); |
| | | if (fieldKey) { |
| | | this.persistFieldState(fieldKey); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Clear individual upload from cache after successful server upload |
| | | */ |
| | | async clearUpload(uploadId) { |
| | | const upload = this.uploads.get(uploadId); |
| | | if (!upload) return; |
| | | |
| | | // Clean up preview URL |
| | | if (upload.preview?.startsWith('blob:')) { |
| | | URL.revokeObjectURL(upload.preview); |
| | | } |
| | | |
| | | this.persistFieldState(upload.fieldId); |
| | | // Remove from memory |
| | | this.uploads.delete(uploadId); |
| | | this.uploadBlobs.delete(uploadId); |
| | | |
| | | // Remove from IndexedDB |
| | | if (this.db) { |
| | | const tx = this.db.transaction(['uploadBlobs'], 'readwrite'); |
| | | await tx.objectStore('uploadBlobs').delete(uploadId); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Store upload with DataStore integration |
| | | */ |
| | | async setUpload(fieldId, file, uploadId = null) { |
| | | if (!uploadId) { |
| | | uploadId = this.generateUploadId(); |
| | | } |
| | | const upload = { |
| | | id: uploadId, |
| | | fieldId: fieldId, |
| | | groupId: null, |
| | | originalFile: file, |
| | | processedFile: null, |
| | | status: 'received', |
| | | progress: { percent: 0, message: 'Received...' }, |
| | | preview: URL.createObjectURL(file), |
| | | createdAt: Date.now(), |
| | | meta: { |
| | | title: '', |
| | | alt_text: '', |
| | | caption: '', |
| | | originalName: file.name, |
| | | originalType: file.type, |
| | | originalSize: file.size |
| | | }, |
| | | changes: {} |
| | | }; |
| | | |
| | | // Add to field |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) { |
| | | console.error(`Field ${fieldId} not found`); |
| | | return null; |
| | | } |
| | | if (!field.uploads) field.uploads = new Set(); |
| | | field.uploads.add(uploadId); |
| | | |
| | | upload.element = this.createImageElement(upload, field.type==='groupable'); |
| | | upload.ui = window.uiFromSelectors(this.selectors.item, upload.element); |
| | | |
| | | // Store in memory |
| | | this.uploads.set(uploadId, upload); |
| | | this.updateImageUI(uploadId); |
| | | |
| | | // Persist to DataStore |
| | | await this.persistFieldState(fieldId); |
| | | |
| | | return upload; |
| | | } |
| | | |
| | | getFieldUploads(fieldId, stripElements) { |
| | | const field = this.fields.get(fieldId); |
| | | console.log('Got field uploads: ', field); |
| | | if (!field?.uploads) return []; |
| | | |
| | | return Array.from(field.uploads) |
| | | .map(id => { |
| | | let upload = this.uploads.get(id); |
| | | if (!upload) return null; |
| | | if (stripElements) { |
| | | // Create a clean copy without DOM references |
| | | const { element, ui, ...cleanUpload } = upload; |
| | | upload = cleanUpload; |
| | | } |
| | | return upload; |
| | | //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; |
| | | |
| | | /** |
| | | * Persist upload to DataStore |
| | | */ |
| | | async persistFieldState(fieldId) { |
| | | if (!this.db) return; |
| | | |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | // Create clean field config without UI references |
| | | const { ui, container, dropZone, previewGrid, selectAll, selectActions, selectInfo, selectCount, groupDisplay, ...cleanConfig } = field; |
| | | |
| | | const fieldState = { |
| | | fieldId: fieldId, |
| | | timestamp: Date.now(), |
| | | |
| | | config: { |
| | | key: cleanConfig.key, |
| | | id: cleanConfig.id, |
| | | name: cleanConfig.name, |
| | | type: cleanConfig.type, |
| | | content: cleanConfig.content, |
| | | itemID: cleanConfig.itemID, |
| | | context: cleanConfig.context, |
| | | mode: cleanConfig.mode, |
| | | maxFiles: cleanConfig.maxFiles, |
| | | multiple: cleanConfig.multiple |
| | | }, |
| | | |
| | | // Recovery context |
| | | context: { |
| | | url: window.location.href, |
| | | modalType: this.getModalType(field), |
| | | formId: field.formId |
| | | }, |
| | | |
| | | // Uploads with their group associations (cleaned) |
| | | uploads: this.getFieldUploads(fieldId, true), |
| | | |
| | | // Groups structure (ensure these are also cleaned) |
| | | groups: Array.from(this.groups.entries()) |
| | | .filter(([id, data]) => data.fieldId === fieldId && data.uploads.size > 0) |
| | | .map(([id, data]) => ({ |
| | | id: data.id, |
| | | uploads: Array.from(data.uploads), |
| | | meta: data.meta, |
| | | changes: data.changes |
| | | })) |
| | | }; |
| | | |
| | | const tx = this.db.transaction(['fieldStates'], 'readwrite'); |
| | | await tx.objectStore('fieldStates').put(fieldState); |
| | | } |
| | | /******************************************************************************* |
| | | RESTORE FUNCTIONALITY |
| | | *******************************************************************************/ |
| | | async checkPendingUploads() { |
| | | if (!this.db) return; |
| | | |
| | | const tx = this.db.transaction(['fieldStates'], 'readonly'); |
| | | const fieldStore = tx.objectStore('fieldStates'); |
| | | |
| | | const allFieldStates = await new Promise(resolve => { |
| | | const request = fieldStore.getAll(); |
| | | request.onsuccess = () => resolve(request.result); |
| | | }); |
| | | |
| | | // Filter for pending uploads (not yet sent to server) |
| | | const pendingFields = allFieldStates.filter(field => |
| | | field.uploads.some(upload => |
| | | upload.status === 'processing' || |
| | | upload.status === 'processed' || |
| | | upload.status === 'pending' |
| | | ) |
| | | ); |
| | | |
| | | if (pendingFields.length === 0) return; |
| | | |
| | | // Show recovery notification |
| | | this.showRecoveryNotification(pendingFields); |
| | | } |
| | | |
| | | showRecoveryNotification(pendingFields) { |
| | | const totalUploads = pendingFields.reduce((sum, field) => sum + field.uploads.length, 0); |
| | | |
| | | let notification = window.getTemplate('restoreNotification'); |
| | | [ |
| | | notification.querySelector('.restore-details').textContent, |
| | | ] = [ |
| | | `${totalUploads} upload(s) from ${pendingFields.length} field(s) can be recovered.` |
| | | ]; |
| | | |
| | | pendingFields.forEach(field => { |
| | | console.log(field); |
| | | let template = window.getTemplate('restoreField'); |
| | | field.uploads.forEach(upload => { |
| | | let uploadItem = window.getTemplate('restoreItem'); |
| | | [ |
| | | uploadItem.querySelector('img').src |
| | | ] = [ |
| | | upload.preview |
| | | ]; |
| | | template.append(uploadItem); |
| | | }); |
| | | notification.append(template); |
| | | }); |
| | | |
| | | |
| | | // Add event handlers |
| | | notification.querySelector('[data-action="restore"]').addEventListener('click', () => { |
| | | this.restoreFieldStates(pendingFields); |
| | | notification.remove(); |
| | | }); |
| | | |
| | | notification.querySelector('[data-action="dismiss"]').addEventListener('click', () => { |
| | | this.notifications.add('Uploads saved for later restoration', 'info'); |
| | | notification.remove(); |
| | | }); |
| | | |
| | | notification.querySelector('[data-action="clear"]').addEventListener('click', () => { |
| | | this.clearCachedUploads(pendingFields); |
| | | notification.remove(); |
| | | }); |
| | | |
| | | document.body.appendChild(notification); |
| | | } |
| | | |
| | | async restoreFieldStates(fieldStates) { |
| | | // Group by URL |
| | | const byUrl = new Map(); |
| | | fieldStates.forEach(field => { |
| | | if (!byUrl.has(field.context.url)) { |
| | | byUrl.set(field.context.url, []); |
| | | if (key === 'status') { |
| | | await this.setUploadStatus(upload, value); |
| | | } |
| | | byUrl.get(field.context.url).push(field); |
| | | upload[key] = value; |
| | | return this.stores.uploads.save(upload); |
| | | }); |
| | | await Promise.all(promises); |
| | | } |
| | | |
| | | // If all on current page, restore directly |
| | | if (byUrl.size === 1 && byUrl.has(window.location.href)) { |
| | | for (const fieldState of fieldStates) { |
| | | await this.restoreField(fieldState); |
| | | } |
| | | this.notifications.add(`Restored ${fieldStates.length} field(s)`, 'success'); |
| | | } else { |
| | | // Store intent to restore and navigate |
| | | sessionStorage.setItem('jvb_restore_uploads', JSON.stringify(fieldStates)); |
| | | |
| | | // Navigate to first URL |
| | | const firstUrl = byUrl.keys().next().value; |
| | | if (window.location.href !== firstUrl) { |
| | | window.location.href = firstUrl; |
| | | } |
| | | 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 restoreField(fieldState) { |
| | | const { config, context, uploads, groups } = fieldState; |
| | | async removeUpload(uploadId) { |
| | | let upload = this.stores.uploads.get(uploadId); |
| | | if (!upload) return; |
| | | const fieldId = upload.field; // grab before clearing |
| | | |
| | | // If in a modal, open it first |
| | | if (context.modalType) { |
| | | await this.openModalForRestore(context); |
| | | } |
| | | |
| | | // Find the field element |
| | | const fieldElement = document.querySelector( |
| | | `.field.image[data-field-id="${config.id}"]` |
| | | ); |
| | | |
| | | if (!fieldElement) { |
| | | console.warn(`Field ${config.id} not found for restoration`); |
| | | return; |
| | | } |
| | | |
| | | // Register the field |
| | | const fieldKey = this.registerUploader(fieldElement, config); |
| | | const field = this.fields.get(fieldKey); |
| | | |
| | | // Restore uploads |
| | | for (const uploadData of uploads) { |
| | | await this.restoreUpload(field, uploadData); |
| | | } |
| | | |
| | | // Restore groups |
| | | if (groups && groups.length > 0) { |
| | | await this.restoreGroups(field, groups, uploads); |
| | | } |
| | | |
| | | // Update UI |
| | | this.maybeLockUploads(fieldKey); |
| | | |
| | | // Queue for upload if needed |
| | | if (config.mode === 'direct') { |
| | | await this.queueUpload(fieldKey); |
| | | } |
| | | } |
| | | |
| | | async restoreUpload(field, uploadData) { |
| | | // Reconstruct the file from blob data |
| | | const blobData = await this.getBlobData(uploadData.id); |
| | | let file = null; |
| | | |
| | | if (blobData) { |
| | | file = new File( |
| | | [blobData.data], |
| | | blobData.name, |
| | | { type: blobData.type, lastModified: blobData.lastModified } |
| | | ); |
| | | uploadData.processedFile = file; |
| | | } |
| | | |
| | | // Add to field |
| | | if (!field.uploads) field.uploads = new Set(); |
| | | field.uploads.add(uploadData.id); |
| | | |
| | | // Recreate DOM element |
| | | uploadData.element = this.createImageElement(uploadData, field.type === 'groupable'); |
| | | |
| | | // Restore to correct location |
| | | const location = uploadData.groupId |
| | | ? field.ui.groups.groups.get(uploadData.groupId) |
| | | : field.ui.field.preview; |
| | | |
| | | if (location) { |
| | | location.append(uploadData.element); |
| | | uploadData.location = location; |
| | | } |
| | | |
| | | // Store in memory |
| | | this.uploads.set(uploadData.id, uploadData); |
| | | } |
| | | |
| | | async restoreGroups(field, groups, uploads) { |
| | | for (const groupData of groups) { |
| | | // Create group element |
| | | const groupElement = this.createGroupElement(groupData.id, field.key); |
| | | field.ui.groups.groups.set(groupData.id, groupElement); |
| | | field.ui.groups.container.insertBefore(groupElement, field.ui.groups.empty); |
| | | |
| | | // Create group Set |
| | | const groupSet = new Set(groupData.uploadIds); |
| | | this.groups.set(groupData.id, groupSet); |
| | | |
| | | // Restore group metadata |
| | | if (groupData.meta) { |
| | | this.groupsMeta.set(groupData.id, groupData.meta); |
| | | // TODO: Populate meta fields in groupElement |
| | | } |
| | | |
| | | // Move uploads to group |
| | | groupData.uploadIds.forEach(uploadId => { |
| | | const upload = uploads.find(u => u.id === uploadId); |
| | | if (upload && upload.element) { |
| | | groupElement.querySelector('.item-grid').append(upload.element); |
| | | upload.location = groupElement.querySelector('.item-grid'); |
| | | upload.groupId = groupData.id; |
| | | } |
| | | }); |
| | | } |
| | | } |
| | | |
| | | async getBlobData(uploadId) { |
| | | if (!this.db) return null; |
| | | |
| | | const tx = this.db.transaction(['uploadBlobs'], 'readonly'); |
| | | const request = tx.objectStore('uploadBlobs').get(uploadId); |
| | | |
| | | return new Promise(resolve => { |
| | | request.onsuccess = () => resolve(request.result); |
| | | request.onerror = () => resolve(null); |
| | | }); |
| | | } |
| | | |
| | | async openModalForRestore(context) { |
| | | const { modalType, formId } = context; |
| | | |
| | | // Find and click the appropriate button to open the modal |
| | | let trigger = null; |
| | | |
| | | switch(modalType) { |
| | | case 'create': |
| | | trigger = document.querySelector('[data-action="create"]'); |
| | | break; |
| | | case 'edit': |
| | | // Need to find the specific edit button |
| | | trigger = document.querySelector(`[data-action="edit"][data-id="${context.itemId}"]`); |
| | | break; |
| | | case 'bulkEdit': |
| | | trigger = document.querySelector('[data-action="bulk-edit"]'); |
| | | break; |
| | | } |
| | | |
| | | if (trigger) { |
| | | trigger.click(); |
| | | |
| | | // Wait for modal to open |
| | | await new Promise(resolve => setTimeout(resolve, 300)); |
| | | } |
| | | } |
| | | |
| | | async clearCachedUploads(fieldStates) { |
| | | if (!this.db) return; |
| | | |
| | | const tx = this.db.transaction(['fieldStates', 'uploadBlobs'], 'readwrite'); |
| | | |
| | | for (const field of fieldStates) { |
| | | // Delete field state |
| | | await tx.objectStore('fieldStates').delete(field.fieldId); |
| | | |
| | | // Delete all associated blobs |
| | | for (const upload of field.uploads) { |
| | | await tx.objectStore('uploadBlobs').delete(upload.id); |
| | | |
| | | // Clean up preview URLs |
| | | if (upload.preview?.startsWith('blob:')) { |
| | | URL.revokeObjectURL(upload.preview); |
| | | } |
| | | } |
| | | } |
| | | |
| | | this.notifications.add('Cached uploads cleared', 'info'); |
| | | } |
| | | |
| | | // Check for restoration intent on page load |
| | | async checkRestorationIntent() { |
| | | const restoreData = sessionStorage.getItem('jvb_restore_uploads'); |
| | | if (!restoreData) return; |
| | | |
| | | const fieldStates = JSON.parse(restoreData); |
| | | const currentUrlFields = fieldStates.filter(f => f.context.url === window.location.href); |
| | | |
| | | if (currentUrlFields.length > 0) { |
| | | for (const fieldState of currentUrlFields) { |
| | | await this.restoreField(fieldState); |
| | | } |
| | | |
| | | // Remove restored fields from session storage |
| | | const remaining = fieldStates.filter(f => f.context.url !== window.location.href); |
| | | if (remaining.length > 0) { |
| | | sessionStorage.setItem('jvb_restore_uploads', JSON.stringify(remaining)); |
| | | 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 { |
| | | sessionStorage.removeItem('jvb_restore_uploads'); |
| | | } |
| | | |
| | | this.notifications.add(`Restored ${currentUrlFields.length} field(s)`, 'success'); |
| | | } |
| | | } |
| | | /******************************************************************************* |
| | | GROUP FUNCTIONALITY |
| | | Includes selection, dragging, and grouping logic |
| | | *******************************************************************************/ |
| | | /** |
| | | * |
| | | * @param {string} uploadId as defined by setUpload |
| | | * @param {HTMLElement|null} target The target location |
| | | * @param {boolean} persist whethet to cache this change |
| | | */ |
| | | addImageToGroup(uploadId, target = null, persist = true) { |
| | | let upload = this.getUpload(uploadId); |
| | | if(!upload) { |
| | | return; |
| | | } |
| | | let field = this.fields.get(upload.fieldId); |
| | | if (!field) { |
| | | return; |
| | | } |
| | | //Already in the Preview Grid, or already in the group we're moving to |
| | | if (!target && upload.location === field.ui.field.preview || target === upload.location) { |
| | | return; |
| | | } |
| | | |
| | | if (upload.location) { |
| | | let groupId = upload.location.dataset.groupId; |
| | | if (groupId) { |
| | | let group = this.groups.get(groupId); |
| | | if (group) { |
| | | group.delete(uploadId); |
| | | if (group.size === 0) { |
| | | this.removeGroup(groupId); |
| | | } |
| | | } |
| | | await this.stores.groups.save(group); |
| | | } |
| | | } |
| | | |
| | | upload.element.querySelector('[name="featured"]').hidden = !target; |
| | | //If no target, it's going to the preview grid |
| | | if (!target) { |
| | | target = field.ui.field.preview; |
| | | } else { |
| | | let groupId = target.dataset.groupId; |
| | | let group = this.groups.get(groupId); |
| | | if (!group) { |
| | | group = this.createGroup(upload.fieldId); |
| | | } |
| | | group.uploads.add(uploadId); |
| | | } |
| | | |
| | | |
| | | target.append(upload.element); |
| | | if (persist) { |
| | | this.persistFieldState(field.key); |
| | | } |
| | | } |
| | | |
| | | addSelectionToGroup(target) { |
| | | let field = this.getFieldFromElement(target); |
| | | if (!field) { |
| | | return; |
| | | } |
| | | if (this.selected.get(field.key).size === 0) { |
| | | return; |
| | | } |
| | | let group = this.getGroupFromElement(target); |
| | | if (!group) { |
| | | group = this.createGroup(field.key); |
| | | } |
| | | |
| | | Array.from(this.selected).forEach(uploadId => { |
| | | this.addImageToGroup(uploadId, group.grid, false); |
| | | }); |
| | | |
| | | this.persistFieldState(group.fieldId); |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Remove an empty group from the field |
| | | * @param {string} groupId - The group to remove |
| | | * @param {boolean} confirm - ask for confirmation |
| | | */ |
| | | removeGroup(groupId, confirm = false) { |
| | | let group = this.groups.get(groupId); |
| | | if (!group) { |
| | | return; |
| | | } |
| | | if (confirm) { |
| | | if(!window.confirm('This will delete this group. Any uploads in this group will return to the main grid. Are you sure?')){ |
| | | return; |
| | | } |
| | | } |
| | | |
| | | if (group.uploads.size > 0) { |
| | | Array.from(group.uploads).forEach(upload => { |
| | | this.addImageToGroup(upload); |
| | | }); |
| | | } |
| | | |
| | | let groupElement = group.element; |
| | | // Remove DOM element |
| | | if (groupElement) { |
| | | window.fade(groupElement, false); |
| | | this.a11y.announce('Empty group removed'); |
| | | } |
| | | |
| | | this.persistFieldState(group.fieldId); |
| | | } |
| | | |
| | | createGroup(fieldId) { |
| | | let field = this.fields.get(fieldId); |
| | | if(!field) { |
| | | return; |
| | | } |
| | | let index = field.ui.groups.size; |
| | | field.ui.groups.groups.set(`group-${index}`, this.createGroupElement(`group-${index}`, fieldId)); |
| | | let group = field.ui.groups.groups.get(`group-${index}`); |
| | | field.ui.groups.container.insertAfter(group, field.ui.groups.empty); |
| | | let groupConfig = { |
| | | fieldId: field.key, |
| | | id: `group-${index}`, |
| | | element: group, |
| | | grid: group.querySelector('.item-grid'), |
| | | uploads: new Set(), |
| | | meta: { |
| | | post_title: '', |
| | | post_excerpt: '', |
| | | }, |
| | | changes: {}, |
| | | }; |
| | | this.groups.set(`group-${index}`, groupConfig); |
| | | return groupConfig; |
| | | } |
| | | |
| | | createGroupElement(groupId, fieldId) { |
| | | let post = window.getTemplate('imageGroup'); |
| | | if (!post) { |
| | | return; |
| | | } |
| | | post.dataset.groupId = groupId; |
| | | post.dataset.fieldId = fieldId; |
| | | let fields = window.getTemplate('groupMetaData'); |
| | | post.querySelector('.fields')?.append(fields); |
| | | |
| | | return post; |
| | | } |
| | | |
| | | /** |
| | | * Handle select all functionality |
| | | */ |
| | | handleSelectAll(element, checked = null) { |
| | | const field = this.getFieldFromElement(element); |
| | | if (!field) return; |
| | | |
| | | // Use element's checked state if not provided |
| | | if (checked === null) { |
| | | checked = element.checked; |
| | | } |
| | | |
| | | const target = field.previewGrid; |
| | | const previewItems = target.querySelectorAll('[data-upload-id]') || []; |
| | | |
| | | previewItems.forEach(item => { |
| | | const checkbox = item.querySelector('[name*="select-item"]'); |
| | | if (checkbox) { |
| | | checkbox.checked = checked; |
| | | } |
| | | }); |
| | | |
| | | this.updateSelectAll(element); |
| | | this.a11y.announce(checked ? 'All uploads selected' : 'All uploads deselected'); |
| | | |
| | | // Clear last clicked since we're selecting/deselecting all |
| | | this.lastClickedUpload = null; |
| | | } |
| | | |
| | | updateSelection(e) { |
| | | let field = this.getFieldFromElement(e.target); |
| | | let upload = this.getUploadFromElement(e.target); |
| | | if (!field || ! upload) { |
| | | console.log('No field or upload found...'); |
| | | return; |
| | | } |
| | | |
| | | this.lastClickedUpload = upload.id; |
| | | let action = e.target.checked; |
| | | if (action) { |
| | | this.selected.get(field.key).add(upload.id); |
| | | } else { |
| | | this.selected.get(field.key).delete(upload.id); |
| | | } |
| | | } |
| | | |
| | | updateSelectAll(element) { |
| | | const field = this.getFieldFromElement(element); |
| | | if (!field) return; |
| | | |
| | | const selected = this.getSelectedUploads(element); |
| | | if (selected.length > 0 ) { |
| | | field.selectActions.hidden = false; |
| | | field.selectInfo.hidden = false; |
| | | field.selectCount.textContent = `${selected.length}`; |
| | | } else { |
| | | field.selectActions.hidden = true; |
| | | field.selectInfo.hidden = true; |
| | | } |
| | | let selectAll = selected.length === field.container.querySelectorAll('.item-grid.preview .upload-item').length; |
| | | field.selectAll.checked = selectAll; |
| | | field.selectAll.nextElementSibling.textContent = (selectAll) ? 'Clear Selection' : 'Select All'; |
| | | } |
| | | |
| | | getSelectedUploads(element) { |
| | | let field = this.getFieldFromElement(element); |
| | | if (!field) { |
| | | return; |
| | | } |
| | | return Array.from(this.selected.get(field.key)??[]); |
| | | } |
| | | |
| | | |
| | | handleRangeSelection(currentElement, event) { |
| | | const field = this.getFieldFromElement(currentElement); |
| | | if (!field) return; |
| | | |
| | | const currentUploadId = this.getUploadIdFromElement(currentElement); |
| | | if (!currentUploadId || !this.lastClickedUpload) return; |
| | | |
| | | // Get all upload items in the preview grid |
| | | const container = currentElement.closest('.item-grid'); |
| | | const allItems = Array.from(container.querySelectorAll('[data-upload-id]')); |
| | | |
| | | // Find indices of first and current items |
| | | const firstIndex = allItems.findIndex(item => |
| | | item.dataset.uploadId === this.lastClickedUpload |
| | | ); |
| | | const currentIndex = allItems.findIndex(item => |
| | | item.dataset.uploadId === currentUploadId |
| | | ); |
| | | |
| | | if (firstIndex === -1 || currentIndex === -1) return; |
| | | |
| | | // Determine range (handle both directions) |
| | | const startIndex = Math.min(firstIndex, currentIndex); |
| | | const endIndex = Math.max(firstIndex, currentIndex); |
| | | |
| | | // Select all items in range (including the clicked one!) |
| | | for (let i = startIndex; i <= endIndex; i++) { |
| | | const item = allItems[i]; |
| | | const checkbox = item.querySelector('[name*="select-item"]'); |
| | | if (checkbox) { |
| | | checkbox.checked = true; |
| | | } |
| | | } |
| | | |
| | | currentElement.checked = true; |
| | | // Update selection UI |
| | | this.updateSelectAll(currentElement); |
| | | |
| | | // Announce the range selection |
| | | const selectedCount = endIndex - startIndex + 1; |
| | | this.a11y.announce(`Selected ${selectedCount} items in range`); |
| | | |
| | | // Update the last clicked item to the current one |
| | | this.lastClickedUpload = currentUploadId; |
| | | } |
| | | |
| | | removeSelection(button) { |
| | | let fieldId = this.getFieldIdFromElement(button); |
| | | |
| | | const selectedUploads = this.getSelectedUploads(button); |
| | | if (selectedUploads.length === 0) { |
| | | this.notify('No uploads selected', 'warning'); |
| | | return; |
| | | } |
| | | |
| | | selectedUploads.forEach(upload => { |
| | | this.removeUpload(fieldId, upload); |
| | | }); |
| | | } |
| | | |
| | | removeUpload(fieldId, uploadId) { |
| | | const field = this.fields.get(fieldId); |
| | | const upload = this.uploads.get(uploadId); |
| | | |
| | | if (!field || !upload) return; |
| | | |
| | | // Remove from field |
| | | field.uploads?.delete(uploadId); |
| | | |
| | | // Remove from group if grouped |
| | | if (upload.groupId) { |
| | | const group = this.groups.get(upload.groupId); |
| | | group?.delete(uploadId); |
| | | } |
| | | |
| | | // Clean up element |
| | | upload.element?.remove(); |
| | | |
| | | // Clean up memory |
| | | this.clearUpload(uploadId); |
| | | |
| | | // Update UI |
| | | await this.clearUpload(uploadId); |
| | | this.updateHiddenInput(fieldId); |
| | | this.maybeLockUploads(fieldId); |
| | | this.updateSelectAll(field.ui.field.field); |
| | | |
| | | let handler = this.selectionHandlers.get(fieldId); |
| | | if (handler) { |
| | | handler.deselect(uploadId); |
| | | } |
| | | |
| | | this.a11y.announce('Upload removed'); |
| | | } |
| | | |
| | | /************************************************************************** |
| | | META |
| | | Handled separately, in case it is edited in the middle of processing images |
| | | **************************************************************************/ |
| | | 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); |
| | | } |
| | | |
| | | /************************************************************************** |
| | | SUBSCRIBERS |
| | | **************************************************************************/ |
| | | /** |
| | | * Event system |
| | | */ |
| | | /******************************************************************************* |
| | | 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 => cb(event, data)); |
| | | 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(); |
| | | } |
| | | |
| | | handleBeforeUnload(e) { |
| | | // Check for any uploads in processing or pending state |
| | | const unsavedUploads = Array.from(this.uploads.values()).filter(upload => |
| | | upload.status === 'processing' || |
| | | upload.status === 'pending' || |
| | | upload.status === 'uploading' |
| | | 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)) |
| | | ); |
| | | |
| | | if (unsavedUploads.length > 0) { |
| | | const message = 'You have uploads in progress. Are you sure you want to leave?'; |
| | | e.preventDefault(); |
| | | e.returnValue = message; |
| | | return message; |
| | | } |
| | | } |
| | | /************************************************************************** |
| | | CLEANUP |
| | | **************************************************************************/ |
| | | cleanup() { |
| | | this.clearListeners(); |
| | | if (this.hasGroups) { |
| | | this.clearGroupListeners(); |
| | | } |
| | | this.compressionWorker = null; |
| | | this.subscribers.clear(); |
| | | // 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', () => { |
| | | window.jvbUploads = new UploadManager(); |
| | | document.addEventListener('DOMContentLoaded', async function () { |
| | | window.auth.subscribe((event) => { |
| | | if (event === 'auth-loaded') { |
| | | window.jvbUploads = new UploadManager(); |
| | | } |
| | | }); |
| | | }); |