| | |
| | | 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]')??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 (manyRefs.inputs) { |
| | | manyRefs.inputs.forEach(input => { |
| | | let wrapper = input.closest('[data-field]'); |
| | | input.dataset.groupId = data.groupId; |
| | | 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() { |
| | |
| | | { name: 'field', keyPath: 'field' }, |
| | | { name: 'status', keyPath: 'status' }, |
| | | { name: 'group', keyPath: 'group' }, |
| | | { name: 'src', keyPath: 'src' } |
| | | { name: 'src', keyPath: 'src' }, |
| | | ], |
| | | }, |
| | | { |
| | |
| | | this.stores.uploads.subscribe(this.handleStores.bind(this, 'uploads')); |
| | | this.stores.groups.subscribe(this.handleStores.bind(this, 'groups')); |
| | | this.queue.subscribe((event, operation) => { |
| | | if (!['uploads', 'uploads/meta', 'uploads/groups'].includes(operation.endpoint)) { |
| | | return; |
| | | 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 |
| | | }); |
| | | } |
| | | } |
| | | |
| | | |
| | | const fieldId = operation.data instanceof FormData |
| | | ? operation.data.get('fieldId') |
| | | : operation.data?.fieldId; |
| | | if (!fieldId) { |
| | | return; |
| | | } |
| | | switch (event) { |
| | | case 'cancel-operation': |
| | | this.handleOperationCancelled(fieldId).then(()=>{}); |
| | | break; |
| | | case 'operation-status': |
| | | this.handleFieldStatus(fieldId, operation).then(()=>{}); |
| | | break; |
| | | case 'operation-completed': |
| | | this.handleOperationComplete(operation, fieldId).then(()=>{}); |
| | | break; |
| | | case 'operation-failed': |
| | | case 'operation-failed-permanent': |
| | | this.handleOperationFailed(operation, fieldId).then(()=>{}); |
| | | break; |
| | | } |
| | | }); |
| | | } |
| | | |
| | |
| | | if (event === 'data-ready') { |
| | | this.stores.ready.push(storeName); |
| | | if (this.storesReady()) { |
| | | this.checkRecovery(); |
| | | this.checkRecovery().then(() => {}); |
| | | } |
| | | } |
| | | } |
| | |
| | | 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: { |
| | |
| | | details: '.file-upload-container .progress .details', |
| | | icon: '.file-upload-container .progress .icon' |
| | | }, |
| | | selectAll: '[name="select-all-uploads"]', |
| | | selectAll: '[data-select-all]', |
| | | actions: '.selection-actions', |
| | | count: '.selection-count', |
| | | count: '.selected .info', |
| | | hidden: 'input[type="hidden"]' |
| | | }, |
| | | // groups = selectors that affect groups as a whole |
| | |
| | | total: '.group-content .group-count' |
| | | }, |
| | | items: { |
| | | item: '[data-upload-id]', |
| | | checkbox: '[name*="select-item"]', |
| | | item: '.item.upload', |
| | | checkbox: '[name="select-item"]', |
| | | featured: '[name="featured"]', |
| | | image: 'img', |
| | | details: 'details', |
| | |
| | | }; |
| | | |
| | | 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); |
| | | switch (upload.status) { |
| | | case 'local_processing': |
| | | this.notify('upload-received', { |
| | | field: field.element, |
| | | id: upload.id |
| | | }); |
| | | } |
| | | } |
| | | |
| | | |
| | | return upload; |
| | | } |
| | | |
| | |
| | | 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')){ |
| | |
| | | } |
| | | } |
| | | 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) { |
| | | const element = input.closest(this.selectors.group.fields); |
| | | if (!element) return; |
| | | handleGroupMetaChange(input) { |
| | | // Get the groupId directly from the input's data attribute |
| | | const groupId = input.dataset.groupId; |
| | | if (!groupId) return; |
| | | |
| | | const groupId = element.dataset.groupId; |
| | | const group = this.stores.groups.get(groupId); // Changed from this.groups |
| | | // Capture values immediately |
| | | 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; |
| | | |
| | | window.debouncer.schedule(`group-meta-${groupId}`, async (input, groupId) => { |
| | | let name = input.name |
| | | .replace(`${groupId}_`, '') |
| | | .replace(`${groupId}[`, '') |
| | | .replace(']', ''); |
| | | group.fields[name] = input.value; |
| | | await this.setGroup(groupId, group); |
| | | }, 300); |
| | | } |
| | | // 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); |
| | |
| | | |
| | | const fieldId = this.getFieldIdFromElement(dropZone); |
| | | if (fieldId) { |
| | | this.processFiles(fieldId, files).then(()=>{}); |
| | | this.processFiles(fieldId, files).then(()=>{ |
| | | this.updateHandlerItems(fieldId); |
| | | }); |
| | | this.a11y.announce(`${files.length} file(s) dropped for upload`); |
| | | } |
| | | } |
| | | |
| | | async queueUploads(endpoint, fieldId) { |
| | | async queueUploads(endpoint, fieldId, dependsOn = null) { |
| | | let data = new FormData(); |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | |
| | | |
| | | if (isUpload) { |
| | | data.append('mode', field.config.mode); |
| | | data.append('field_name', field.config.name); |
| | | |
| | | 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 (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' |
| | | } |
| | | |
| | | try { |
| | | return await this.queue.addToQueue(operation); |
| | | } catch (error) { |
| | |
| | | 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: group.fields??{} |
| | | fields: fields |
| | | }; |
| | | |
| | | const groupUploads = uploads.filter(u => u.group === group.id); |
| | | const groupUploads = this.getGroupUploadsInOrder(group); |
| | | |
| | | for (const upload of groupUploads) { |
| | | const file = this.formatFile(upload); |
| | | if (file) { |
| | |
| | | upload_id: upload.id, |
| | | index: uploadMap.length |
| | | }; |
| | | let uploadEl = this.uploads.get(upload.id); |
| | | if (uploadEl.ui?.featured?.checked) { |
| | | |
| | | 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); |
| | | } |
| | | } |
| | | 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: {} |
| | | }; |
| | |
| | | const file = this.formatFile(upload); |
| | | if (file) { |
| | | files.push(file); |
| | | |
| | | const imageData = { |
| | | upload_id: upload.id, |
| | | index: uploadMap.length |
| | |
| | | post.images.push(imageData); |
| | | uploadMap.push(upload.id); |
| | | } |
| | | posts.push(post); |
| | | |
| | | 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; |
| | |
| | | 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); |
| | | |
| | | let queueData = {}; |
| | | queueData[upload.attachmentId ?? upload.id] = upload.fields; |
| | | return await this.sendToQueue('uploads/meta', queueData, 'Uploading Meta', '', true); |
| | | } |
| | | |
| | | async handleOperationComplete(operation, fieldId) { |
| | | const response = operation.response; |
| | | |
| | | // Handle direct upload results (from uploads endpoint) |
| | | if (response?.data) { |
| | | const results = Array.isArray(response.data) ? response.data : Object.values(response.data); |
| | | for (const result of results) { |
| | | if (result.upload_id && result.attachment_id) { |
| | | const upload = this.stores.uploads.get(result.upload_id); |
| | | if (upload) { |
| | | upload.attachmentId = result.attachment_id; |
| | | upload.status = 'completed'; |
| | | await this.stores.uploads.save(upload); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Clear completed uploads and groups |
| | | const uploads = this.stores.uploads.filterByIndex({field: fieldId}); |
| | | const groups = this.stores.groups.filterByIndex({field: fieldId}); |
| | | if (!this.changes.has(attachmentId)) { |
| | | let object = {}; |
| | | if (isUpload) { |
| | | object['uploadId'] = attachmentId; |
| | | } else { |
| | | object['attachmentId'] = attachmentId; |
| | | } |
| | | this.changes.set(attachmentId, object); |
| | | } |
| | | |
| | | await Promise.all([ |
| | | ...uploads |
| | | .filter(upload => upload.status === 'completed') |
| | | .map(upload => this.clearUpload(upload.id)), |
| | | ...groups.map(group => this.stores.groups.delete(group.id)) |
| | | ]); |
| | | let field = e.target.closest('[data-field]'); |
| | | let name = field.dataset.field; |
| | | |
| | | this.notify('uploads-complete', { fieldId, response }); |
| | | 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: [], |
| | |
| | | if (data.config.type !== 'single') { |
| | | this.initSortable(data.id); |
| | | } |
| | | this.maybeLockUploads(data.id); |
| | | |
| | | return data.id; |
| | | } |
| | | |
| | | extractFieldConfig(fieldElement, autoUpload) { |
| | | return { |
| | | extractFieldConfig(el, autoUpload, imageMeta) { |
| | | const config = { |
| | | autoUpload: autoUpload, |
| | | destination: fieldElement.dataset.destination || 'meta', //TODO: why do we need this? |
| | | content: this.extractFieldContent(fieldElement), |
| | | mode: fieldElement.dataset.mode || 'direct', |
| | | type: fieldElement.dataset.type || 'single', |
| | | name: fieldElement.dataset.field, |
| | | itemID: this.extractFieldItemId(fieldElement)??0, |
| | | maxFiles: parseInt(fieldElement.dataset.maxFiles)??25, |
| | | subType: fieldElement.dataset.subtype?? 'image' |
| | | 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) { |
| | |
| | | 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}`; |
| | | } |
| | | |
| | |
| | | |
| | | 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) |
| | |
| | | RECOVERY |
| | | *************************************************************/ |
| | | async checkRecovery() { |
| | | const pendingUploads = this.stores.uploads.filterByIndex({status: ['local_processing', 'queued', 'uploading']}); |
| | | if (pendingUploads.length === 0) return; |
| | | |
| | | let notification = window.getTemplate('restoreNotification'); |
| | | if (!notification) { |
| | | this.error.log( |
| | | 'No restore notification', |
| | | { |
| | | component: 'UploadManager', |
| | | src: window.location.href |
| | | } |
| | | ); |
| | | return; |
| | | 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); |
| | | } |
| | | // Group by source page |
| | | const bySource = new Map(); |
| | | pendingUploads.forEach(upload => { |
| | | const src = upload.src || 'unknown'; |
| | | if (!bySource.has(src)) bySource.set(src, []); |
| | | bySource.get(src).push(upload); |
| | | }); |
| | | |
| | | const currentSrc = window.location.href; |
| | | |
| | | |
| | | let source = bySource.size > 1 ? ` across ${bySource.size} pages` : ''; |
| | | let upload = pendingUploads.length > 1 ? 'uploads' : 'upload'; |
| | | let message = `${pendingUploads.length} ${upload} can be recovered${source}`; |
| | | |
| | | let details = notification.querySelector('.details'); |
| | | if (details) { |
| | | details.textContent = message; |
| | | } |
| | | |
| | | let i = 1; |
| | | for (const [src, uploads] of bySource) { |
| | | let template = window.getTemplate('restoreField'); |
| | | if (!template) continue; |
| | | let fieldId = this.registerField(template,false, 'recovery_'+i); |
| | | let field = this.fields.get(fieldId); |
| | | i++; |
| | | let isCurrent = src === currentSrc; |
| | | let [ |
| | | h3, |
| | | a, |
| | | grid |
| | | ] = [ |
| | | template.querySelector('h3'), |
| | | template.querySelector('h3 a'), |
| | | template.querySelector('.item-grid') |
| | | ]; |
| | | |
| | | template.open = isCurrent; |
| | | if (!isCurrent) { |
| | | [a.href, a.title,a.textContent] = |
| | | [src, 'Navigate to Page and Restore', src]; |
| | | } else { |
| | | a.remove(); |
| | | h3.textContent = 'From this page:'; |
| | | } |
| | | |
| | | let filteredGroupIds = [...new Set(uploads.map(upload => upload.group??'preview'))]; |
| | | |
| | | for (let groupId of filteredGroupIds) { |
| | | let group = (groupId === 'preview') ? true : this.stores.groups.get(groupId); |
| | | if (!group) continue; |
| | | |
| | | let groupElement = await this.createGroupElement(groupId,field.id); |
| | | let groupGrid = groupElement.querySelector('.item-grid'); |
| | | let theseUploads = uploads.filter(upload => upload.group === (groupId === 'preview') ? null : groupId); |
| | | for (const [key, value] of Object.entries(group.fields ?? {})) { |
| | | let field = groupElement.querySelector(`input[name*="${key}"]`); |
| | | if (field) field.value = value; |
| | | } |
| | | for (let upload of theseUploads) { |
| | | let item = await this.createUpload(upload.id, this.formatFile(upload), field.id); |
| | | groupGrid.append(item); |
| | | } |
| | | |
| | | grid.append(groupElement); |
| | | } |
| | | notification.querySelector('.wrap').append(template); |
| | | } |
| | | document.body.append(notification); |
| | | notification = document.querySelector('dialog.restore-uploads'); |
| | | this.restoreModal = new window.jvbModal(notification); |
| | | this.restoreSelection = new window.jvbHandleSelection({ |
| | | container: notification, |
| | | wrapper: '.restore-uploads .wrap', |
| | | bulkControls: '.selection-actions', |
| | | selectAll: '#select-all-restore', |
| | | count: '.selection-count' |
| | | }); |
| | | this.restoreModal.handleOpen(); |
| | | } |
| | | //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 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 fieldId = uploads[0].field; |
| | | let field = document.querySelector(`[data-uploader="${fieldId}"]`); |
| | | if (!field) { |
| | | console.log('No field found for '+fieldId); |
| | | return; |
| | | 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) { |
| | |
| | | |
| | | 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); |
| | | 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)) { |
| | |
| | | }); |
| | | 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; |
| | | // } |
| | | |
| | | this.cleanupRestore(); |
| | | 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)); |
| | | } |
| | | |
| | | cleanupRestore() { |
| | | this.restoreModal.handleClose(); |
| | | this.restoreSelection.destroy(); |
| | | this.restoreSelection = null; |
| | | this.restoreModal.destroy(); |
| | | this.restoreModal.modal.remove(); |
| | | this.restoreModal = null; |
| | | async clearUploads(uploadIds) { |
| | | await Promise.all(uploadIds.map(id => this.clearUpload(id))); |
| | | } |
| | | /******************************************************************************* |
| | | STATUS MANAGEMENT |
| | |
| | | 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) { |
| | | 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'; |
| | |
| | | * @param button |
| | | */ |
| | | async handleRemoveItem(button) { |
| | | console.log('Handling remove upload'); |
| | | const item = button.closest(this.selectors.items.item); |
| | | if (!item) return; |
| | | |
| | | const uploadId = item.dataset.uploadId; |
| | | const attachmentId = item.dataset.id; |
| | | |
| | | if (!uploadId && !attachmentId) return; |
| | | if (!confirm('Remove this item?')) return; |
| | | 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 => { |
| | | if (Object.hasOwn(el.dataset, 'id') && el.dataset.id > 0) { |
| | | return el.dataset.id; |
| | | } |
| | | |
| | | if (Object.hasOwn(el.dataset, 'upload-id') && el.dataset.uploadId > 0) { |
| | | return el.dataset.uploadId; |
| | | } |
| | | //For timeline |
| | | return el.dataset.itemId; |
| | | }) |
| | | .filter(Boolean); |
| | | |
| | | const newValue = remaining.join(','); |
| | | if (field.ui.hidden.value === newValue) return; |
| | | |
| | | field.ui.hidden.value = newValue; |
| | | field.ui.hidden.dispatchEvent(new Event('change', { bubbles: true })); |
| | | } |
| | | async setBulkUpload(uploads, key, value) { |
| | | const promises = Array.from(uploads).map(async (upload) => { |
| | | if (typeof upload === 'string') upload = await this.stores.uploads.get(upload); |
| | | if (!upload) return; |
| | | |
| | | if (key === 'status') { |
| | | await this.setUploadStatus(upload, value); |
| | | } |
| | |
| | | } |
| | | |
| | | async setUploadStatus(upload, status) { |
| | | if (typeof upload === 'string') upload = await this.stores.uploads.get(upload); |
| | | if (!upload) return; |
| | | if (upload.progress) { |
| | | window.showProgress(upload.progress, this.getStatusProgress(status), 100, this.getStatusText(status), this.queue.icons[status]??''); |
| | | } |
| | |
| | | async removeUpload(uploadId) { |
| | | let upload = this.stores.uploads.get(uploadId); |
| | | if (!upload) return; |
| | | const fieldId = upload.field; // grab before clearing |
| | | |
| | | if (upload.group) { |
| | | let group = this.stores.groups.get(upload.group); |
| | | group.uploads = group.uploads.filter(id => id !== uploadId); |
| | | if (group.uploads.length === 0) { |
| | | await this.removeGroup(group.id, false); |
| | | } else { |
| | | await this.stores.groups.save(group); |
| | | } |
| | | } |
| | | |
| | | await this.clearUpload(uploadId); |
| | | this.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'); |
| | | if (grid) { |
| | | grid.dataset.groupId = groupId; |
| | | this.createSortableForGrid(fieldId, grid, groupId); |
| | | this.createSortable(fieldId, grid, groupId); |
| | | } |
| | | |
| | | let storedData = this.stores.groups.data.has(groupId) |
| | |
| | | } |
| | | |
| | | 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.id = `${groupId}_title`; |
| | | title.name = `${groupId}[post_title]`; |
| | | } |
| | | if (excerpt) { |
| | | excerpt.id = `${groupId}_excerpt`; |
| | | excerpt.name = `${groupId}[post_excerpt]`; |
| | | } |
| | | } else { |
| | | element.querySelector('details')?.remove(); |
| | | } |
| | | |
| | | const grid = element.querySelector('.item-grid'); |
| | | if (grid) { |
| | | grid.dataset.groupId = groupId; |
| | | } |
| | | 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; |
| | | } |
| | | |
| | |
| | | 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 (group) { |
| | | group.uploads.push(uploadId); |
| | | upload.group = groupId; |
| | | this.stores.groups.save(group); |
| | | await this.stores.groups.save(group); |
| | | } |
| | | } |
| | | |
| | | let target = (groupId) ? this.groups.get(groupId)?.ui.grid : field.ui.grid; |
| | | if (target) { |
| | | target.append(element.element) |
| | | target.append(element.element); |
| | | if (groupId) { |
| | | await this.handleReorder(upload.field, groupId); |
| | | } |
| | | } |
| | | this.stores.uploads.save(upload); |
| | | await this.stores.uploads.save(upload); |
| | | } |
| | | |
| | | handleDeleteGroup(button) { |
| | |
| | | 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(); |
| | | } |
| | | if (this.selectionHandlers.get(group.field) && element && element.element) { |
| | | this.selectionHandlers.get(group.field).removeWrapper(element.element) |
| | | } |
| | | |
| | | // Destroy the Sortable for this group |
| | | const sortableKey = this.getGroupKey(group.field, groupId); |
| | | const sortable = this.sortables.get(sortableKey); |
| | | if (sortable?.destroy) { |
| | | sortable.destroy(); |
| | | // Existing sortable cleanup |
| | | if (this.sortables.has(sortableKey)) { |
| | | const sortable = this.sortables.get(sortableKey); |
| | | if (sortable?.destroy) { |
| | | sortable.destroy(); |
| | | } |
| | | |
| | | this.sortables.delete(sortableKey); |
| | | } |
| | | |
| | | } |
| | | this.sortables.delete(sortableKey); |
| | | |
| | | if (element?.element) { |
| | | element.element.remove(); |
| | |
| | | |
| | | let uploads = this.stores.uploads.filterByIndex({field: fieldId}); |
| | | let count = uploads.length; |
| | | let max = field.config.maxFiles??25; |
| | | let max = field.config.maxFiles??0; |
| | | |
| | | field.ui.dropZone.hidden = count >= max; |
| | | field.ui.dropZone.hidden = max > 0 && count >= max; |
| | | } |
| | | /******************************************************************************* |
| | | OPERATION METHODS |
| | | *******************************************************************************/ |
| | | async handleOperationCancelled(fieldId) { |
| | | const uploads = this.stores.uploads.filterByIndex({field: fieldId}); |
| | | const groups = this.stores.groups.filterByIndex({field: fieldId}); |
| | | |
| | | await Promise.all([ |
| | | ...uploads.map(upload => this.removeUpload(upload.id)), |
| | | ...groups.map(group => this.removeGroup(group.id, false)) |
| | | ]); |
| | | this.a11y.announce('Upload Cancelled'); |
| | | } |
| | | |
| | | async handleOperationFailed(operation, fieldId) { |
| | | // Mark uploads as failed, maybe show retry UI |
| | | await this.setBulkUpload( |
| | | this.stores.uploads.filterByIndex({field: fieldId}), |
| | | 'status', |
| | | 'failed' |
| | | ); |
| | | } |
| | | |
| | | async handleFieldStatus(fieldId, operation) { |
| | | let status = operation.status; |
| | | let uploads = this.stores.uploads.filterByIndex({field: fieldId}); |
| | | await this.setBulkUpload(uploads, 'status', status); |
| | | async handleOperationCancelled(uploads) { |
| | | if (uploads.length === 0) return; |
| | | uploads.forEach(upload => { |
| | | this.removeUpload(upload); |
| | | }); |
| | | } |
| | | /******************************************************************************* |
| | | SELECTION HANDLERS |
| | |
| | | if (!this.selectionHandlers.has(key)) { |
| | | let field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | let handler = new window.jvbHandleSelection({ |
| | | container: field.element, |
| | | item: this.selectors.items.item, |
| | | count: this.selectors.fields.count, |
| | | bulkControls: this.selectors.fields.actions, |
| | | checkbox: this.selectors.items.checkbox, |
| | | selectAll: this.selectors.fields.selectAll, |
| | | wrapper: `${this.selectors.fields.preview}, ${this.selectors.group.item}`, |
| | | 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); |
| | | console.log(Array.from(this.selected)); |
| | | this.syncSortableSelection(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 |
| | | *******************************************************************************/ |
| | |
| | | selectedClass: 'selected', |
| | | avoidImplicitDeselect: true, |
| | | group: { name: fieldId, pull: true, put: true }, |
| | | ghostClass: 'ghost', |
| | | chosenClass: 'chosen', |
| | | dragClass: 'dragging', |
| | | ignore: '.empty-group', |
| | | |
| | | onStart: () => this.syncSortableSelection(fieldId), |
| | | 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), |
| | | }); |
| | | |
| | |
| | | |
| | | 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 |
| | |
| | | |
| | | async sortableDrop(evt, fieldId) { |
| | | const dropTarget = evt.to; |
| | | |
| | | const items = evt.items?.length > 0 ? Array.from(evt.items) : [evt.item]; |
| | | const uploadIds = items.map(item => item.dataset.uploadId).filter(Boolean); |
| | | |
| | | if (uploadIds.length === 0) return; |
| | | |
| | | // Determine target group from the grid's data attribute |
| | | const targetGroupId = dropTarget.dataset.groupId || null; |
| | | |
| | | await Promise.all( |
| | | uploadIds.map(uploadId => this.addToGroup(uploadId, targetGroupId)) |
| | | ); |
| | | // 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(); |
| | | } |
| | | |
| | | syncSortableSelection(fieldId) { |
| | | const selectedItems = this.selected.get(fieldId) || new Set(); |
| | | |
| | | for (const [uploadId, uploadData] of this.uploads) { |
| | | const upload = this.stores.uploads.get(uploadId); |
| | | if (!upload || upload.field !== fieldId) continue; |
| | | |
| | | const element = uploadData.element; |
| | | if (!element) continue; |
| | | |
| | | const shouldBeSelected = selectedItems.has(uploadId); |
| | | |
| | | if (shouldBeSelected && !element.classList.contains('selected')) { |
| | | Sortable.utils.select(element); |
| | | } else if (!shouldBeSelected && element.classList.contains('selected')) { |
| | | Sortable.utils.deselect(element); |
| | | } |
| | | } |
| | | } |
| | | |
| | | handleReorder(fieldId, groupId = null) { |
| | | let target = (groupId) ? this.groups.get(groupId)?.ui.grid : this.fields.get(fieldId)?.ui.grid; |
| | | let target = (groupId) |
| | | ? this.groups.get(groupId)?.ui.grid |
| | | : this.fields.get(fieldId)?.ui.grid; |
| | | |
| | | if (!target) { |
| | | console.log ('Couldn\'t Reorder items...'); |
| | | console.log('Couldn\'t Reorder items...'); |
| | | return; |
| | | } |
| | | //Get current order from DOM |
| | | let items = Array.from(target.querySelectorAll(this.selectors.items.item+':not(.ghost)')) |
| | | .map(upload => upload.dataset.uploadId) |
| | | .filter(id => id); |
| | | |
| | | |
| | | if (!groupId) { |
| | | let hiddenInput = this.fields.get(fieldId)?.ui.hidden; |
| | | if (hiddenInput) { |
| | | hiddenInput.value = items.join(','); |
| | | } |
| | | this.updateHiddenInput(fieldId); |
| | | } else { |
| | | let group = this.groups.get(groupId); |
| | | 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'); |
| | | } |
| | | /******************************************************************************* |
| | |
| | | }) |
| | | ]); |
| | | |
| | | if (this.restoreModal) { |
| | | this.cleanupRestore(); |
| | | } |
| | | |
| | | this.a11y.announce('Cache cleared for this page'); |
| | | } |
| | | |