| | |
| | | this.a11y = window.jvbA11y; |
| | | this.queue = window.jvbQueue; |
| | | this.error = window.jvbError; |
| | | this.templates = window.jvbTemplates; |
| | | |
| | | this.subscribers = new Set(); |
| | | |
| | | this.initStores(); |
| | | this.initWorker(); |
| | | |
| | | |
| | | //Maps for DOM references |
| | | this.fields = new Map(); |
| | | this.uploads = new Map(); |
| | |
| | | this.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]')??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() { |
| | |
| | | this.queue.subscribe((event, operation) => { |
| | | if ((event === 'operation-status' || event === 'cancel-operation') |
| | | && ['image_upload', 'video_upload', 'document_upload'].includes(operation.type)) { |
| | | const data = operation.data instanceof FormData |
| | | ? this.stores.uploads.formDataToObject(operation.data) |
| | | : operation.data; |
| | | console.log(data); |
| | | let uploads = data['upload_ids']; |
| | | if (!uploads || uploads.length === 0) return; |
| | | if (event === 'cancel-operation') return this.handleOperationCancelled(uploads); |
| | | this.setBulkUpload(uploads, 'status', operation.status).then(()=>{}); |
| | | 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 (operation.data['field_name'] !== '' && operation.data['item_id']) { |
| | | this.notify('upload_complete', { |
| | | field: operation.data['field_name'], |
| | | item_id: operation['item_id'] |
| | | }); |
| | | } |
| | | } |
| | | |
| | | // 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') { |
| | | uploads.forEach(upload => { |
| | | this.removeUpload(upload).then(()=>{}); |
| | | // 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 |
| | | }); |
| | | } |
| | | } |
| | |
| | | fields: { |
| | | field: '[data-upload-field]', |
| | | input: 'input[type="file"]', |
| | | dropZone: '.file-upload-container', |
| | | dropZone: '.file-upload-wrapper', |
| | | preview: '.preview-wrap', |
| | | grid: '.item-grid.preview', |
| | | progress: { |
| | |
| | | }; |
| | | |
| | | const upload = { ...defaults, ...data }; |
| | | |
| | | Object.preventExtensions(upload); |
| | | await this.stores.uploads.save(upload); |
| | | return upload; |
| | |
| | | } |
| | | } |
| | | handleChange(e) { |
| | | |
| | | let fieldId = this.getFieldIdFromElement(e.target); |
| | | if (!fieldId) return; |
| | | 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); |
| | |
| | | } |
| | | |
| | | let field = this.fields.get(fieldId); |
| | | if (!field || !field.config.autoUpload) return; |
| | | |
| | | if (field.config.destination === 'post_group') { |
| | | this.handleGroupMetaChange(e.target); |
| | | } else { |
| | | this.queueUploadMeta(e).then(()=>{}); |
| | | this.queueUploadMeta(e); |
| | | } |
| | | } |
| | | handleGroupMetaChange(input) { |
| | |
| | | |
| | | // Capture values immediately (before debouncer) |
| | | const inputName = input.name; |
| | | if (!inputName) return; |
| | | const inputValue = input.value; |
| | | |
| | | // Extract the field name from the input name |
| | |
| | | } |
| | | } |
| | | |
| | | async queueUploads(endpoint, fieldId) { |
| | | async queueUploads(endpoint, fieldId, dependsOn = null) { |
| | | let data = new FormData(); |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | |
| | | 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 (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, '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'); |
| | | } |
| | | this.notify('sent-to-queue', fieldId); |
| | | return operationId; |
| | | } |
| | | |
| | |
| | | canMerge: mergable, |
| | | sendNow: endpoint === 'uploads/groups', |
| | | headers: { |
| | | 'action_nonce': window.auth.getNonce('dash') |
| | | 'X-Action-Nonce': window.auth.getNonce('dash') |
| | | }, |
| | | append: '_upload' |
| | | } |
| | |
| | | let uploadMap = []; |
| | | let files = []; |
| | | |
| | | for (const group of groups) { |
| | | 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 |
| | | }; |
| | | |
| | | // Use helper to get uploads in stored order |
| | | const groupUploads = this.getGroupUploadsInOrder(group); |
| | | |
| | | for (const upload of groupUploads) { |
| | |
| | | uploadMap.push(upload.id); |
| | | } |
| | | } |
| | | posts.push(post); |
| | | |
| | | 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: {} |
| | | }; |
| | |
| | | post.images.push(imageData); |
| | | uploadMap.push(upload.id); |
| | | } |
| | | posts.push(post); |
| | | |
| | | if (post.images.length > 0) { |
| | | posts.push(post); |
| | | } |
| | | } |
| | | |
| | | return {posts, uploadMap, files}; |
| | |
| | | return { uploadMap, files }; |
| | | } |
| | | |
| | | async queueUploadMeta(e) { |
| | | const uploadId = e.target.closest(this.selectors.items.item)?.dataset.uploadId; |
| | | const upload = this.stores.uploads.get(uploadId); |
| | | if (!uploadId || !upload) return; |
| | | 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; |
| | | |
| | | const field = this.fields.get(upload.field); |
| | | if (!field) return; |
| | | |
| | | let data = {}; |
| | | data[e.target.name] = e.target.value; |
| | | } |
| | | |
| | | upload.fields = { ...upload.fields, ...data }; |
| | | await this.setUpload(upload.id, upload); |
| | | if (!this.changes.has(attachmentId)) { |
| | | let object = {}; |
| | | if (isUpload) { |
| | | object['uploadId'] = attachmentId; |
| | | } else { |
| | | object['attachmentId'] = attachmentId; |
| | | } |
| | | this.changes.set(attachmentId, object); |
| | | } |
| | | |
| | | let queueData = {}; |
| | | queueData[upload.attachmentId ?? upload.id] = upload.fields; |
| | | return await this.sendToQueue('uploads/meta', queueData, 'Uploading Meta', '', true); |
| | | 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) { |
| | | scanFields(container, autoUpload = true, imageMeta = true) { |
| | | const fields = container.querySelectorAll(this.selectors.fields.field); |
| | | fields.forEach(uploader => this.registerField(uploader, autoUpload)); |
| | | fields.forEach(uploader => this.registerField(uploader, autoUpload, imageMeta)); |
| | | } |
| | | |
| | | registerField(element, autoUpload = true, id = null) { |
| | | registerField(element, autoUpload = true, imageMeta = true, id = null) { |
| | | const data = { |
| | | element: element, |
| | | id: (id) ? id : this.determineFieldId(element), |
| | | config: this.extractFieldConfig(element, autoUpload), |
| | | config: this.extractFieldConfig(element, autoUpload, imageMeta), |
| | | uploads: new Set(), |
| | | operationId: null, |
| | | groups: [], |
| | |
| | | return data.id; |
| | | } |
| | | |
| | | extractFieldConfig(fieldElement, autoUpload) { |
| | | extractFieldConfig(fieldElement, autoUpload, imageMeta) { |
| | | return { |
| | | autoUpload: autoUpload, |
| | | showMeta: imageMeta, |
| | | destination: fieldElement.dataset.destination || 'meta', //TODO: why do we need this? |
| | | content: this.extractFieldContent(fieldElement), |
| | | mode: fieldElement.dataset.mode || 'direct', |
| | |
| | | |
| | | const processNext = async () => { |
| | | while (queue.length > 0) { |
| | | const file = queue.shift(); |
| | | results.push(await this.processImage(file, maxWidth, maxHeight)); |
| | | const entry = queue.shift(); |
| | | const blob = await this.processImage(entry.file, maxWidth, maxHeight); |
| | | results.push({ uploadId: entry.uploadId, blob: blob }); |
| | | } |
| | | }; |
| | | |
| | |
| | | id: uploadId, |
| | | field: fieldId, |
| | | status: 'local_processing', |
| | | blob: null, |
| | | // blob: null, |
| | | fields: { |
| | | originalName: file.name, |
| | | originalSize: file.size, |
| | |
| | | const otherEntries = uploadEntries.filter(e => !e.file.type.startsWith('image/')); |
| | | |
| | | // Process images in batches |
| | | const processedBlobs = await this.processImages( |
| | | imageEntries.map(e => e.file) |
| | | const processedImages = await this.processImages( |
| | | imageEntries.map(e => ({ file: e.file, uploadId: e.uploadId })) |
| | | ); |
| | | |
| | | // Update image uploads with processed blobs |
| | | for (let i = 0; i < imageEntries.length; i++) { |
| | | const { uploadId, upload } = imageEntries[i]; |
| | | upload.blob = processedBlobs[i]; |
| | | upload.fields.size = processedBlobs[i].size; |
| | | upload.status = 'queued'; |
| | | await this.setUpload(uploadId, upload); |
| | | processed++; |
| | | this.updateFieldProgress(fieldId, processed, totalFiles, 'Processing files...'); |
| | | 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) |
| | |
| | | *************************************************************/ |
| | | 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; |
| | | |
| | | let notification = window.getTemplate('restoreNotification'); |
| | | if (!notification) { |
| | | this.error.log( |
| | | 'No restore notification', |
| | | { |
| | | component: 'UploadManager', |
| | | src: window.location.href |
| | | } |
| | | ); |
| | | return; |
| | | } |
| | | // Group by source page |
| | | const bySource = new Map(); |
| | | pendingUploads.forEach(upload => { |
| | |
| | | bySource.get(src).push(upload); |
| | | }); |
| | | |
| | | const currentSrc = window.location.href; |
| | | let data = { |
| | | bySource: bySource, |
| | | pendingUploads: pendingUploads |
| | | }; |
| | | |
| | | |
| | | let source = bySource.size > 1 ? ` across ${bySource.size} pages` : ''; |
| | | let upload = pendingUploads.length > 1 ? 'uploads' : 'upload'; |
| | | let message = `${pendingUploads.length} ${upload} can be recovered${source}`; |
| | | |
| | | let details = notification.querySelector('.details'); |
| | | if (details) { |
| | | details.textContent = message; |
| | | } |
| | | |
| | | let i = 1; |
| | | for (const [src, uploads] of bySource) { |
| | | let template = window.getTemplate('restoreField'); |
| | | if (!template) continue; |
| | | let fieldId = this.registerField(template,false, 'recovery_'+i); |
| | | let field = this.fields.get(fieldId); |
| | | i++; |
| | | let isCurrent = src === currentSrc; |
| | | let [ |
| | | h3, |
| | | a, |
| | | grid |
| | | ] = [ |
| | | template.querySelector('h3'), |
| | | template.querySelector('h3 a'), |
| | | template.querySelector('.item-grid') |
| | | ]; |
| | | |
| | | template.open = isCurrent; |
| | | if (!isCurrent) { |
| | | [a.href, a.title,a.textContent] = |
| | | [src, 'Navigate to Page and Restore', src]; |
| | | } else { |
| | | a.remove(); |
| | | h3.textContent = 'From this page:'; |
| | | } |
| | | |
| | | let filteredGroupIds = [...new Set(uploads.map(upload => upload.group??'preview'))]; |
| | | |
| | | for (let groupId of filteredGroupIds) { |
| | | let group = (groupId === 'preview') ? true : this.stores.groups.get(groupId); |
| | | if (!group) continue; |
| | | |
| | | let groupElement = await this.createGroupElement(groupId,field.id); |
| | | let groupGrid = groupElement.querySelector('.item-grid'); |
| | | let theseUploads = uploads.filter(upload => upload.group === (groupId === 'preview') ? null : groupId); |
| | | for (const [key, value] of Object.entries(group.fields ?? {})) { |
| | | let field = groupElement.querySelector(`input[name*="${key}"]`); |
| | | if (field) field.value = value; |
| | | } |
| | | for (let upload of theseUploads) { |
| | | let item = await this.createUpload(upload.id, this.formatFile(upload), field.id); |
| | | groupGrid.append(item); |
| | | } |
| | | |
| | | grid.append(groupElement); |
| | | } |
| | | notification.querySelector('.wrap').append(template); |
| | | } |
| | | document.body.append(notification); |
| | | notification = document.querySelector('dialog.restore-uploads'); |
| | | 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: '.wrap' |
| | | wrapper: '.restore-field', |
| | | id: 'selection' |
| | | }, |
| | | items: '.item-grid.restore', |
| | | selectAll: { |
| | | bulkControls: '.selection-actions', |
| | | checkbox: '#select-all-restore', |
| | |
| | | UPLOAD METHODS |
| | | *******************************************************************************/ |
| | | async createUpload(uploadId, file, fieldId) { |
| | | let image = window.getTemplate('uploadItem'); |
| | | if (!image) return null; |
| | | |
| | | let field = this.fields.get(fieldId); |
| | | if (!field) return null; |
| | | |
| | | image.dataset.uploadId = uploadId; |
| | | let mimeType = this.getSubtypeFromMime(file.type)||'image'; |
| | | image.dataset.subtype = mimeType; |
| | | |
| | | let [featured, img, video, preview, details] = [ |
| | | image.querySelector('[name="featured"]'), |
| | | image.querySelector('img'), |
| | | image.querySelector('video'), |
| | | image.querySelector('label > span'), |
| | | image.querySelector('details') |
| | | ]; |
| | | |
| | | if (featured) featured.value = uploadId; |
| | | switch (mimeType) { |
| | | case 'image': |
| | | if (img) { |
| | | const previewUrl = this.createPreviewUrl(file); |
| | | img.src = previewUrl; |
| | | img.alt = file.name || ''; |
| | | img.dataset.previewUrl = previewUrl; |
| | | } |
| | | video?.remove(); |
| | | preview?.remove(); |
| | | break; |
| | | case 'video': |
| | | if (video){ |
| | | const previewUrl = this.createPreviewUrl(file); |
| | | video.src = previewUrl; |
| | | video.dataset.previewUrl = previewUrl; |
| | | } |
| | | img?.remove(); |
| | | preview?.remove(); |
| | | break; |
| | | case 'document': |
| | | let ext = file.name.split('.').pop()?.toLowerCase()??''; |
| | | let map = { |
| | | 'pdf': 'file-pdf', 'csv': 'file-csv', |
| | | 'doc': 'file-doc', 'docx': 'file-doc', |
| | | 'txt': 'file-txt', 'xls': 'file-xls', 'xlsx': 'file-xls' |
| | | }; |
| | | let icon = window.getIcon(map[ext]??'file'); |
| | | if (preview) { |
| | | preview.innerText = file.name; |
| | | preview.prepend(icon); |
| | | } |
| | | img?.remove(); |
| | | video?.remove(); |
| | | break; |
| | | } |
| | | |
| | | if (details) { |
| | | let template = window.getTemplate('uploadMeta'); |
| | | if (template) details.append(template); |
| | | } |
| | | |
| | | image.draggable = field.config.type !== 'single'??false; |
| | | |
| | | image.querySelectorAll('input').forEach(input => { |
| | | let id = input.id; |
| | | if (id) { |
| | | let newId = id + uploadId; |
| | | let label = input.parentNode.querySelector(`label[for="${id}"]`); |
| | | input.id = newId; |
| | | if (label) label.htmlFor = newId; |
| | | } |
| | | }); |
| | | |
| | | return image; |
| | | let data = { |
| | | uploadId: uploadId, |
| | | file: file, |
| | | field: field, |
| | | }; |
| | | return this.templates.create('uploadItem', data); |
| | | } |
| | | |
| | | getSubtypeFromURL(url) { |
| | | 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'; |
| | |
| | | if (!item) return; |
| | | |
| | | const uploadId = item.dataset.uploadId; |
| | | const attachmentId = item.dataset.id; |
| | | |
| | | if (!uploadId && !attachmentId) return; |
| | | if (!confirm('Remove this item?')) return; |
| | | await this.removeUpload(uploadId); |
| | | |
| | | if (uploadId) { |
| | | await this.removeUpload(uploadId); |
| | | } else { |
| | | const fieldId = this.getFieldIdFromElement(button); |
| | | item.remove(); |
| | | |
| | | if (fieldId) { |
| | | this.updateHiddenInput(fieldId); |
| | | this.maybeLockUploads(fieldId); |
| | | } |
| | | } |
| | | |
| | | this.a11y.announce('Item removed'); |
| | | } |
| | | |
| | | updateHiddenInput(fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field?.ui.hidden) return; |
| | | |
| | | const remaining = Array.from(field.ui.grid?.querySelectorAll(this.selectors.items.item) || []) |
| | | .map(el => el.dataset.id || el.dataset.uploadId) |
| | | .filter(Boolean); |
| | | |
| | | field.ui.hidden.value = remaining.join(','); |
| | | 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); |
| | |
| | | async removeUpload(uploadId) { |
| | | let upload = this.stores.uploads.get(uploadId); |
| | | if (!upload) return; |
| | | const fieldId = upload.field; // grab before clearing |
| | | |
| | | if (upload.group) { |
| | | let group = this.stores.groups.get(upload.group); |
| | | group.uploads = group.uploads.filter(id => id !== uploadId); |
| | |
| | | } |
| | | |
| | | await this.clearUpload(uploadId); |
| | | this.maybeLockUploads(upload.field); |
| | | this.updateHiddenInput(fieldId); |
| | | this.maybeLockUploads(fieldId); |
| | | |
| | | let handler = this.selectionHandlers.get(upload.field); |
| | | if (handler){ |
| | | let handler = this.selectionHandlers.get(fieldId); |
| | | if (handler) { |
| | | handler.deselect(uploadId); |
| | | } |
| | | |
| | |
| | | const element = this.createGroupElement(groupId, fieldId); |
| | | if (!element) return null; |
| | | |
| | | field.groupUI.grid.append(element); |
| | | 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'); |
| | |
| | | } |
| | | |
| | | createGroupElement(groupId, fieldId = null) { |
| | | let element = window.getTemplate('imageGroup'); |
| | | if (!element) return; |
| | | |
| | | element.dataset.groupId = groupId; |
| | | if (fieldId) { |
| | | element.dataset.fieldId = fieldId; |
| | | let data = { |
| | | groupId: groupId, |
| | | fieldId: fieldId, |
| | | } |
| | | |
| | | const selectAll = element.querySelector('[data-select-all]'); |
| | | if (selectAll) { |
| | | const newId = `select-all-${groupId}`; |
| | | const label = element.querySelector(`label[for="${selectAll.id}"]`); |
| | | selectAll.id = newId; |
| | | selectAll.name = newId; |
| | | if (label) label.htmlFor = newId; |
| | | } |
| | | |
| | | let fields = window.getTemplate('groupMetadata'); |
| | | let container = element.querySelector('.fields'); |
| | | if (fields && container) { |
| | | container.append(fields); |
| | | |
| | | let title = container.querySelector('[name="post_title"]'); |
| | | let excerpt = container.querySelector('[name="post_excerpt"]'); |
| | | |
| | | if (title) { |
| | | title.dataset.groupId = groupId; |
| | | title.id = `${groupId}_title`; |
| | | title.name = `${groupId}[post_title]`; |
| | | } |
| | | if (excerpt) { |
| | | title.dataset.groupId = groupId; |
| | | excerpt.id = `${groupId}_excerpt`; |
| | | excerpt.name = `${groupId}[post_excerpt]`; |
| | | } |
| | | } else { |
| | | element.querySelector('details')?.remove(); |
| | | } |
| | | |
| | | const grid = element.querySelector('.item-grid'); |
| | | if (grid) { |
| | | grid.dataset.groupId = groupId; |
| | | } |
| | | let element = this.templates.create('imageGroup', data); |
| | | |
| | | this.groups.set(groupId, { |
| | | element: element, |
| | |
| | | |
| | | handler.subscribe((event, data) => { |
| | | this.selected.set(fieldId, data.selectedItems); |
| | | this.syncSortableSelection(fieldId); |
| | | }); |
| | | |
| | | this.selectionHandlers.set(key, handler); |
| | |
| | | selectedClass: 'selected', |
| | | avoidImplicitDeselect: true, |
| | | group: { name: fieldId, pull: true, put: true }, |
| | | ghostClass: 'ghost', |
| | | chosenClass: 'chosen', |
| | | dragClass: 'dragging', |
| | | ignore: '.empty-group', |
| | | |
| | | onStart: (evt) => { |
| | | // Get the dragged item's ID |
| | |
| | | handler.select(uploadId); |
| | | } |
| | | } |
| | | |
| | | // Sync all selections to Sortable |
| | | this.syncSortableSelection(fieldId); |
| | | }, |
| | | onEnd: (evt) => this.sortableDrop(evt, fieldId), |
| | | }); |
| | |
| | | |
| | | emptyZone.addEventListener('dragover', (e) => { |
| | | e.preventDefault(); |
| | | e.stopPropagation(); |
| | | e.dataTransfer.dropEffect = 'move'; |
| | | emptyZone.classList.add('drag-over'); |
| | | }); |
| | |
| | | |
| | | emptyZone.addEventListener('drop', async (e) => { |
| | | e.preventDefault(); |
| | | e.stopPropagation(); |
| | | emptyZone.classList.remove('drag-over'); |
| | | |
| | | // Get selected items from our tracking |
| | |
| | | |
| | | const targetGroupId = dropTarget.dataset.groupId || null; |
| | | |
| | | await Promise.all( |
| | | uploadIds.map(uploadId => this.addToGroup(uploadId, targetGroupId)) |
| | | ); |
| | | |
| | | // After all moves complete, sync order from DOM |
| | | await this.handleReorder(fieldId, targetGroupId); |
| | | |
| | | this.selectionHandlers.get(fieldId)?.clearSelection(); |
| | | } |
| | | |
| | | syncSortableSelection(fieldId) { |
| | | const selectedItems = this.selected.get(fieldId) || new Set(); |
| | | |
| | | for (const [uploadId, uploadData] of this.uploads) { |
| | | const upload = this.stores.uploads.get(uploadId); |
| | | if (!upload || upload.field !== fieldId) continue; |
| | | |
| | | const element = uploadData.element; |
| | | if (!element) continue; |
| | | |
| | | const shouldBeSelected = selectedItems.has(uploadId); |
| | | |
| | | if (shouldBeSelected && !element.classList.contains('selected')) { |
| | | Sortable.utils.select(element); |
| | | } else if (!shouldBeSelected && element.classList.contains('selected')) { |
| | | Sortable.utils.deselect(element); |
| | | } |
| | | // 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) { |
| | |
| | | return; |
| | | } |
| | | |
| | | // Get current order from DOM |
| | | 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); |
| | | |
| | | if (!groupId) { |
| | | let hiddenInput = this.fields.get(fieldId)?.ui.hidden; |
| | | if (hiddenInput) { |
| | | hiddenInput.value = items.join(','); |
| | | } |
| | | 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; |