| | |
| | | { 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) => { |
| | | console.log(event, operation); |
| | | if (!Object.hasOwn(operation, 'endpoint') || !['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)) { |
| | | 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(()=>{}); |
| | | if (operation.status === 'completed') { |
| | | uploads.forEach(upload => { |
| | | this.removeUpload(upload).then(()=>{}); |
| | | }); |
| | | } |
| | | } |
| | | |
| | | |
| | | 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(()=>{}); |
| | | } |
| | | } |
| | | } |
| | |
| | | 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', |
| | |
| | | this.queueUploadMeta(e).then(()=>{}); |
| | | } |
| | | } |
| | | 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 (before debouncer) |
| | | const inputName = input.name; |
| | | 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`); |
| | | } |
| | | } |
| | |
| | | }, |
| | | append: '_upload' |
| | | } |
| | | |
| | | try { |
| | | return await this.queue.addToQueue(operation); |
| | | } catch (error) { |
| | |
| | | let files = []; |
| | | |
| | | for (const group of groups) { |
| | | const groupElement = this.groups.get(group.id)?.element; |
| | | const fields = this.collectGroupFieldsFromDOM(groupElement, group.id); |
| | | |
| | | const post = { |
| | | images: [], |
| | | fields: group.fields??{} |
| | | fields: fields |
| | | }; |
| | | |
| | | const groupUploads = uploads.filter(u => u.group === group.id); |
| | | // Use helper to get uploads in stored order |
| | | 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); |
| | | } |
| | | |
| | | // Handle remaining uploads not in any group |
| | | const remaining = uploads.filter(u => !u.group); |
| | | |
| | | for (const upload of remaining) { |
| | | const post = { |
| | | images: [], |
| | |
| | | const file = this.formatFile(upload); |
| | | if (file) { |
| | | files.push(file); |
| | | |
| | | const imageData = { |
| | | upload_id: upload.id, |
| | | index: uploadMap.length |
| | |
| | | } |
| | | 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 await this.sendToQueue('uploads/meta', queueData, 'Uploading Meta', '', true); |
| | | } |
| | | |
| | | async handleOperationComplete(operation, fieldId) { |
| | | const response = operation.response; |
| | | |
| | | // Handle direct upload results (from uploads endpoint) |
| | | if (response?.data) { |
| | | const results = Array.isArray(response.data) ? response.data : Object.values(response.data); |
| | | for (const result of results) { |
| | | if (result.upload_id && result.attachment_id) { |
| | | const upload = this.stores.uploads.get(result.upload_id); |
| | | if (upload) { |
| | | upload.attachmentId = result.attachment_id; |
| | | upload.status = 'completed'; |
| | | await this.stores.uploads.save(upload); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Clear completed uploads and groups |
| | | const uploads = this.stores.uploads.filterByIndex({field: fieldId}); |
| | | const groups = this.stores.groups.filterByIndex({field: fieldId}); |
| | | |
| | | await Promise.all([ |
| | | ...uploads |
| | | .filter(upload => upload.status === 'completed') |
| | | .map(upload => this.clearUpload(upload.id)), |
| | | ...groups.map(group => this.stores.groups.delete(group.id)) |
| | | ]); |
| | | |
| | | this.notify('uploads-complete', { fieldId, response }); |
| | | } |
| | | /********************************************************************* |
| | | FIELD LOGIC |
| | | *********************************************************************/ |
| | |
| | | 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.restoreSelection = new window.jvbHandleSelection(notification, |
| | | { |
| | | wrapper: { |
| | | wrapper: '.wrap' |
| | | }, |
| | | selectAll: { |
| | | bulkControls: '.selection-actions', |
| | | checkbox: '#select-all-restore', |
| | | count: '.selection-count' |
| | | } |
| | | }); |
| | | this.restoreModal.handleOpen(); |
| | | } |
| | |
| | | |
| | | 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)) { |
| | |
| | | |
| | | 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]??''); |
| | | } |
| | |
| | | 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); |
| | | } |
| | | } |
| | | |
| | |
| | | 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]`; |
| | | } |
| | |
| | | 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(); |
| | | } |
| | | 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 |
| | | 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(); |
| | |
| | | /******************************************************************************* |
| | | 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.syncSortableSelection(fieldId); |
| | | }); |
| | | |
| | | this.selectionHandlers.set(key, handler); |
| | |
| | | |
| | | return this.selectionHandlers.get(key); |
| | | } |
| | | updateHandlerItems(fieldId) { |
| | | let handler = this.getSelectionHandler(fieldId); |
| | | if (!handler) return; |
| | | handler.collectItems(); |
| | | } |
| | | /******************************************************************************* |
| | | SORTABLE |
| | | *******************************************************************************/ |
| | |
| | | |
| | | 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)) |
| | | ); |
| | | |
| | | // After all moves complete, sync order from DOM |
| | | await this.handleReorder(fieldId, targetGroupId); |
| | | |
| | | this.selectionHandlers.get(fieldId)?.clearSelection(); |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | handleReorder(fieldId, groupId = null) { |
| | | let target = (groupId) ? this.groups.get(groupId)?.ui.grid : this.fields.get(fieldId)?.ui.grid; |
| | | 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)')) |
| | | |
| | | // 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(','); |
| | | } |
| | | } else { |
| | | let group = this.groups.get(groupId); |
| | | let group = this.stores.groups.get(groupId); |
| | | if (group) { |
| | | group.uploads = items; |
| | | this.stores.groups.save(group).then(()=>{}); |
| | | } |
| | | } |
| | | |
| | | this.a11y.announce('Items reordered'); |
| | | } |
| | | /******************************************************************************* |