Jake Vanderwerf
2026-01-11 181938ea32cf2c11d10d56018df22586adff7860
assets/js/concise/UploadManager.js
@@ -34,7 +34,7 @@
                  { name: 'field', keyPath: 'field' },
                  { name: 'status', keyPath: 'status' },
                  { name: 'group', keyPath: 'group' },
                  { name: 'src', keyPath: 'src' }
                  { name: 'src', keyPath: 'src' },
               ],
            },
            {
@@ -57,32 +57,23 @@
      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)) {
            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;
         }
      });
   }
@@ -94,7 +85,7 @@
      if (event === 'data-ready') {
         this.stores.ready.push(storeName);
         if (this.storesReady()) {
            this.checkRecovery();
            this.checkRecovery().then(()=>{});
         }
      }
   }
@@ -127,9 +118,9 @@
               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
@@ -150,8 +141,8 @@
            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',
@@ -295,23 +286,36 @@
         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);
@@ -348,7 +352,9 @@
      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`);
      }
   }
@@ -439,6 +445,7 @@
         },
         append: '_upload'
      }
      try {
         return await this.queue.addToQueue(operation);
      } catch (error) {
@@ -459,12 +466,17 @@
      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) {
@@ -473,10 +485,13 @@
                  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);
            }
@@ -484,8 +499,8 @@
         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: [],
@@ -495,7 +510,6 @@
         const file = this.formatFile(upload);
         if (file) {
            files.push(file);
            const imageData = {
               upload_id: upload.id,
               index: uploadMap.length
@@ -505,9 +519,42 @@
         }
         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;
@@ -544,37 +591,6 @@
      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
   *********************************************************************/
@@ -962,12 +978,16 @@
      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();
   }
@@ -1011,10 +1031,9 @@
      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)) {
@@ -1194,6 +1213,9 @@
   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);
         }
@@ -1204,6 +1226,8 @@
   }
   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]??'');
      }
@@ -1217,6 +1241,8 @@
         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);
         }
      }
@@ -1318,10 +1344,12 @@
         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]`;
         }
@@ -1338,6 +1366,8 @@
         element: element,
         ui: window.uiFromSelectors(this.selectors.group, element)
      });
      this.getSelectionHandler(fieldId)?.addWrapper(element);
      return element;
   }
@@ -1389,12 +1419,19 @@
            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);
      }
@@ -1408,15 +1445,18 @@
         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) {
@@ -1454,14 +1494,22 @@
            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();
@@ -1486,30 +1534,11 @@
   /*******************************************************************************
    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
@@ -1524,20 +1553,27 @@
      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);
@@ -1545,6 +1581,11 @@
      return this.selectionHandlers.get(key);
   }
   updateHandlerItems(fieldId) {
      let handler = this.getSelectionHandler(fieldId);
      if (!handler) return;
      handler.collectItems();
   }
   /*******************************************************************************
    SORTABLE
   *******************************************************************************/
@@ -1651,19 +1692,20 @@
   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();
   }
@@ -1688,28 +1730,34 @@
   }
   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');
   }
   /*******************************************************************************