Jake Vanderwerf
2025-10-18 b0194e10a87e16797a568d8a30d53ebecd27d8a4
assets/js/concise/UploadManager.js
@@ -4,60 +4,88 @@
      this.queue = window.jvbQueue;
      this.a11y = window.jvbA11y;
      this.error = window.jvbError;
      this.notifications = window.jvbNotifications;
      //Load Datastore
      this.initDB();
      this.fieldStore = new window.jvbStore({
         name: 'upload_fields',
         storeName: 'fieldStates',
         keyPath: 'id',
         version: 2,
      //State management
         indexes: [
            { name: 'fieldId', keyPath: 'fieldId' },
            { name: 'timestamp', keyPath: 'timestamp' },
            { name: 'content', keyPath: 'content' },
            { name: 'itemId', keyPath: 'itemId' },
            { name: 'status', keyPath: 'status' }
         ],
         stripDOMReferences: true,
         TTL: 86400000*7 // 24 hours -> 1 week
      });
      this.uploadStore = new window.jvbStore({
         name: 'uploads',
         storeName: 'uploads',
         keyPath: 'id',
         storeBlobs: true,
         indexes: [
            { name: 'fieldId', keyPath: 'fieldId' },
            { name: 'status', keyPath: 'status' },
            { name: 'groupId', keyPath: 'groupId' },
            { name: 'attachmentId', keyPath: 'attachmentId' }
         ],
      });
      // Subscribe to store events
      this.fieldStore.subscribe(this.handleFieldStoreEvent.bind(this));
      this.uploadStore.subscribe(this.handleUploadStoreEvent.bind(this));
      //Load Worker
      this.initWorker();
      // Core data structures
      this.fields = new Map();
      this.uploads = new Map();
      this.uploadBlobs = new Map();
      this.timeouts = new Map();
      this.selected = new Map();
      //Worker
      this.worker = {
         worker: null,
         timeout: null,
         tasks: new Map(),
         restart: {
            count: 0,
            max: 3,
         },
         settings: {
            timeout: 10000, //10 seconds per image
            batchSize: 1,
            maxConcurrent: 3,
            restartAfterTimeout: true
         }
      };
      //Groups!
      this.touch = {
         x: null,
         y: null
      }
      this.hasBulkContext = document.querySelector('details.uploader')!==null;
      this.isTouching = false;
      this.groups = new Map();
      this.selected = new Map();
      this.selectionHandlers = new Map();
      this.previewUrls = new Set();
      //Notification and Subscribers
      this.subscribers = new Set();
      this.settings = {
         allowedTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'],
         maxFileSize: 5242880,
         maxProcessingTime: 120000, // 2 minutes max for processing
         processingCheckInterval: 5000, // Check every 5 seconds
         smartCompression: true,
         fieldTypes: {
            'single': { maxFiles: 1, allowMultiple: false },
            'gallery': { maxFiles: 20, allowMultiple: true },
            'groupable': { maxFiles: 20, allowMultiple: true }
      // Controllers (will be initialized based on features)
      this.dragController = null;
      // Selectors
      this.selectors = {
         field: {
            field: '[data-upload-field]',
            input: 'input[type="file"]',
            hiddenValue: 'input[type="hidden"]',
            dropZone: '.file-upload-container',
            preview: '.item-grid.preview',
            progress: '.image-progress'
         },
         groups: {
            container: '.upload-group',
            grid: '.item-grid.group',
            header: '.group-header',
            selectAll: '[name="select-all-group"]',
            actions: '.group-actions',
            count: '.selection-controls .info'
         },
         items: {
            item: '[data-upload-id]',
            checkbox: '[name*="select-item"]',
            featured: '[name="featured"]',
            details: 'details'
         }
      };
      this.statusMapping = {
         'received': 'Image Received',
         'local_processing': 'Processing Image...',
@@ -74,192 +102,421 @@
   }
   async init() {
      this.initElements();
      // Load existing data
      await this.loadFields();
      await this.loadUploads();
      // Initialize fields
      this.initializeFields();
      // Set up core listeners
      this.initListeners();
      this.initCompressionWorker();
      this.queue.subscribe((event, operation) => {
         console.log('Operation Endpoint: ', operation.endpoint);
         if (operation.endpoint !== 'uploads') {
         if (operation.endpoint !== 'uploads' && operation.endpoint !== 'uploads/meta') {
            return;
         }
         const fieldId = operation.data instanceof FormData
            ? operation.data.get('fieldId')
            : operation.data.fieldId;
         switch(event) {
            case 'cancel-operation':
               this.clearField(operation.data.get('field_key'));
               if (fieldId) {
                  this.clearField(fieldId);
               }
               break;
            case 'operation-status':
               console.log('Operation Data: ',operation.data);
               const fieldId = operation.data?.field_key ||
                  (operation.data instanceof FormData ?
                     operation.data.get('field_key') : null);
               if (fieldId) {
                  console.log('Updating field status:', fieldId, operation.status);
                  this.updateFieldStatus(fieldId, operation.status);
               }
               break;
            case 'operation-complete':
               const results = operation.result?.data || [];
               results.forEach(result => {
                  const upload = this.uploads.get(result.upload_id);
                  if (upload) {
                     upload.attachmentId = result.attachment_id;
                     upload.status = 'completed';
                     this.uploads.set(upload.id, upload);
         }
      });
      await this.checkPendingUploads();
      this.scanFields();
               if (fieldId) {
                  this.cleanField(fieldId);
               }
               break;
   }
   initElements() {
      this.selectors = {
         field: {
            field: '.field.image',
            dropZone: '.file-upload-container',
            preview: '.item-grid.preview',
            hiddenValue: 'input[type="hidden"]',
            progress: {
               progress: '.progress',
               details: '.progress .details',
               fill: '.progress .fill',
               count: '.progress .count'
      });
      window.addEventListener('beforeunload', () => {
         this.cleanupAllPreviewUrls();
      });
   }
   initWorker() {
      this.worker = {
         worker: null,
         timeout: null,
         tasks: new Map(),
         restart: {
            count: 0,
            max: 3,
            },
         },
         item: {
            img: 'img',
            progress: {
               progress: '.progress',
               details: '.progress .details',
               fill: '.progress .fill',
               count: '.progress .count'
            },
            status: '.status',
            select: '[name*="select-item"]',
            actions: '.item-actions',
            featured: '[name="featured"]',
            meta: '.upload-meta'
         },
         groups: {
            container: '.item-grid.groups',
            display: '.group-display',
            selectAll: '#select-all-uploads',
            actions: '.selection-actions',
            info: '.selection-controls .info',
            count: '.selection-count',
            group: '.upload-group',
            empty: '.empty-group'
         settings: {
            timeout: 10000, //10 seconds per image
            batchSize: 1,
            maxConcurrent: 3,
            restartAfterTimeout: true
         }
      };
      this.ui = {};
   }
   scanFields() {
      document.querySelectorAll(this.selectors.field.field).forEach(uploader => {
   /**
    * Initialize all upload fields on the page
    */
   initializeFields() {
      const fields = document.querySelectorAll(this.selectors.field.field);
      fields.forEach(uploader => {
         this.registerUploader(uploader);
      });
   }
   /**
    *
    * @param {HTMLElement} uploader
    * @param {object} options
    * @param {string} options.id Uploader field ID: defaults to uploader.dataset.fieldId
    * @param {string} options.type Uploader type: defaults to uploader.dataset.type
    * @param {number} options.maxFiles Maximum files to allow: defaults to type defaults
    * @param {boolean} options.multiple Whether to allow multiple uploads
    * @param {number} options.itemID The post or term ID this is for.
    * @param {string} options.mode
    * @returns {string}
    */
   registerUploader(uploader, options = {}) {
      //Determine if this is for a post, term, content uploader, or option
      let key = uploader.dataset['uploader']??this.determineKey(uploader);
   scanFields(container) {
      const fields = container.querySelectorAll(this.selectors.field.field);
      fields.forEach(uploader => {
         this.registerUploader(uploader);
      });
   }
      uploader.dataset['uploader'] = key;
   registerUploader(uploader) {
      const fieldId = this.determineFieldId(uploader);
      const config = this.extractFieldConfig(uploader);
      if (!this.fields.has(key)) {
         let type = uploader.dataset.type;
         let typeConfig = this.settings.fieldTypes[type]??this.settings.fieldTypes['single'];
         let config = {
            key: key,
            name: uploader.dataset.field,
            ui: {},
            type: type,
            maxFiles: typeConfig.maxFiles,
            multiple: typeConfig.allowMultiple,
            content: uploader.dataset.content??uploader.closest('dialog')?.dataset.content??uploader.closest('form').dataset.save??false,
            itemID: uploader.dataset.itemID??uploader.closest('dialog')?.dataset.itemID??false,
            context: uploader.dataset.context??uploader.closest('dialog')?.dataset.context??false,
            mode: uploader.dataset.mode??'direct',
            ... options
      // Create field data structure
      const field = {
         id: fieldId,
         config: config,
         element: uploader,
         ui: this.buildFieldUI(uploader),
         uploads: new Set(),
         groups: new Set(),
         state: 'ready',
         };
         config.ui = window.uiFromSelectors(this.selectors, uploader);
         config.ui.groups.groups = new Map();
      this.fields.set(fieldId, field);
      uploader.dataset.uploader = fieldId;
      this.addFieldSelectionHandler(fieldId);
         this.selected.set(key, new Set());
         this.fields.set(key, config);
         if(config.type === 'groupable' && !this.hasGroups) {
            this.initGroupListeners();
      if (config.destination === 'post_group' && !this.dragController) {
         this.initGroupFeatures();
         }
      }
      return key;
      return fieldId;
   }
   /**
    * Builds a key from the uploader, built from the Content Type, ItemID, and FieldName
    * @param uploader
    * @returns {string}
    * Extract configuration from field element
    */
   determineKey(uploader) {
      let content = uploader.dataset.content??uploader.closest('dialog')?.dataset.content??uploader.closest('form').dataset.save??'';
      let itemID = uploader.dataset.itemID??uploader.closest('dialog')?.dataset.itemID??'';
      let field = uploader.dataset.field;
      return `${content}_${itemID}_${field}`;
   extractFieldConfig(fieldElement) {
      return {
         destination: fieldElement.dataset.destination || 'meta',
         content: fieldElement.dataset.content || null,
         mode: fieldElement.dataset.mode || 'direct',
         type: fieldElement.dataset.type || 'single',
         name: fieldElement.dataset.field,  // Field name for meta
         itemID: fieldElement.dataset.itemId || 0,  // Post/term/user ID
         maxFiles: parseInt(fieldElement.dataset.maxFiles) || 999,
         subtype: fieldElement.dataset.subtype || 'image'
      };
   }
   /**
    *
    * @param {HTMLElement} element
    * Build UI element references for a field
    */
   getFieldIdFromElement(element) {
      let field = element.closest('.field.image');
      if (!field) {
   buildFieldUI(fieldElement) {
      let UI = {
         field: fieldElement,
         input: fieldElement.querySelector(this.selectors.field.input),
         dropZone: fieldElement.querySelector(this.selectors.field.dropZone),
         preview: fieldElement.querySelector(this.selectors.field.preview),
         progress: {
            progress: fieldElement.querySelector(this.selectors.field.progress),
            bar: fieldElement.querySelector('.bar'),
            fill: fieldElement.querySelector('.fill'),
            details: fieldElement.querySelector('.details'),
            text: fieldElement.querySelector('.details .text'),
            count: fieldElement.querySelector('.details .count')
         }
      };
      let display = fieldElement.querySelector('.group-display');
      if (display) {
         UI.groups = {
            display: display,
            container: fieldElement.querySelector('.item-grid.groups'),
            empty: fieldElement.querySelector('.empty-group'),
            groups: new Map()
         };
      }
      return UI;
   }
   /**
    * Set up core event listeners
    */
   initListeners() {
      this.clickHandler = this.handleClick.bind(this);
      this.changeHandler = this.handleChange.bind(this);
      document.addEventListener('click', this.clickHandler);
      document.addEventListener('change', this.changeHandler);
      // External file drops
      this.dragEnterHandler   = this.handleExternalDragEnter.bind(this);
      this.dragLeaveHandler   = this.handleExternalDragLeave.bind(this);
      this.dragOverHandler    = this.handleExternalDragOver.bind(this);
      this.dropHandler     = this.handleExternalDrop.bind(this);
      document.addEventListener('dragenter', this.dragEnterHandler);
      document.addEventListener('dragleave', this.dragLeaveHandler);
      document.addEventListener('dragover', this.dragOverHandler);
      document.addEventListener('drop', this.dropHandler);
   }
   /**
    * Initialize group-specific features (drag & drop for rearranging)
    */
   initGroupFeatures() {
      // Initialize drag controller for rearranging items
      this.dragController = new window.jvbDragHandler({
         // What can be dragged
         draggableSelector: this.selectors.items.item,
         // Where items can be dropped
         dropTargetSelector: `${this.selectors.field.preview}, ${this.selectors.groups.grid}, .empty-group`,
         // Don't start drag on interactive elements
         ignoreSelector: 'input:not(.upload-select), button, select, textarea, details, summary, a',
         previewElement: 'img, video, .icon',
         // Extract upload ID from element
         getItemId: (element) => {
            return element.dataset.uploadId;
         },
         // Get selected items for multi-drag
         getSelectedItems: (element) => {
            const fieldId = this.getFieldIdFromElement(element);
            const uploadId = element.dataset.uploadId;
            const selected = this.getCurrentSelection(fieldId);
            if (selected && selected.includes(uploadId)) {
               return selected;
            }
            return [uploadId];
         },
         // Validate drop location
         validateDrop: (itemIds, targetElement) => {
            const targetFieldId = this.getFieldIdFromElement(targetElement);
            const itemElement = document.querySelector(`[data-upload-id="${itemIds[0]}"]`);
            const itemFieldId = this.getFieldIdFromElement(itemElement);
            return targetFieldId === itemFieldId;
         },
         // Handle successful drop
         onDrop: (itemIds, targetElement) => {
            this.handleItemDrop(itemIds, targetElement);
            targetElement.scrollIntoView({behavior:'smooth', block:'center'});
         },
         // Optional callbacks
         onDragStart: (itemIds) => {
         },
         onDragEnd: (itemIds, success) => {
            if (success) {
               // Clear selection after successful move
               const itemElement = document.querySelector(`[data-upload-id="${itemIds[0]}"]`);
               const fieldId = this.getFieldIdFromElement(itemElement);
               const handler = this.selectionHandlers.get(fieldId);
               handler?.clearSelection();
            }
         },
         // Preview options
         previewOptions: {
            multiOffset: { x: -60, y: -80 },
            singleOffset: { x: -50, y: -60 },
            showCount: true
         }
      });
   }
   /*******************************************************************************
    * EXTERNAL FILE DROP HANDLERS (for new uploads from desktop)
    *******************************************************************************/
   handleExternalDragLeave(e) {
      const dropZone = e.target.closest(this.selectors.field.dropZone);
      if (dropZone && !dropZone.contains(e.relatedTarget)) {
         dropZone.classList.remove('dragover');
      }
   }
   handleExternalDragEnter(e) {
      if (!e.dataTransfer.types.includes('Files')) {
         return;
      }
      return field.dataset.uploader??this.determineKey(field);
      const dropZone = e.target.closest(this.selectors.field.dropZone);
      if (dropZone) {
         e.preventDefault();
         dropZone.classList.add('dragover');
      }
   }
   getFieldFromElement(element) {
      let id = this.getFieldIdFromElement(element);
      return (this.fields.has(id)) ? this.fields.get(id) : false;
   handleExternalDragOver(e) {
      if (!e.dataTransfer.types.includes('Files')) return;
      const dropZone = e.target.closest(this.selectors.field.dropZone);
      if (dropZone) {
         e.preventDefault();
         e.dataTransfer.dropEffect = 'copy';
      }
   }
   getUploadFromElement(element) {
      let id = this.getUploadIdFromElement(element);
      return (this.uploads.has(id)) ? this.uploads.get(id) : false;
   handleExternalDrop(e) {
      const dropZone = e.target.closest(this.selectors.field.dropZone);
      if (!dropZone) return;
      e.preventDefault();
      dropZone.classList.remove('dragover');
      const files = Array.from(e.dataTransfer.files);
      if (files.length === 0) return;
      const fieldId = this.getFieldIdFromElement(dropZone);
      if (fieldId) {
         this.processFiles(fieldId, files);
         this.a11y.announce(`${files.length} file(s) dropped for upload`);
      } else {
         console.error('No field ID found for drop zone');
      }
   }
   getUploadIdFromElement(element) {
      let upload = element.closest('[data-upload-id]');
      return upload?.dataset.uploadId || null;
   /*******************************************************************************
    * ITEM DROP HANDLER (for rearranging existing uploads)
    *******************************************************************************/
   /**
    * Handle items being dropped (called by DragController)
    */
   handleItemDrop(itemIds, targetElement) {
      const isPreviewDrop = targetElement.classList.contains('preview');
      let actualTarget = targetElement;
      // Handle drop on empty group placeholder
      if (targetElement.classList.contains('empty-group')) {
         const fieldId = this.getFieldIdFromElement(targetElement);
         const group = this.createGroup(fieldId);
         if (!group) {
            console.error('Failed to create group');
            return;
   }
   getGroupFromElement(element) {
      let groupId = this.getGroupIdFromElement(element);
      return (this.groups.has(groupId)) ? this.groups.get(groupId) : false;
   }
   getGroupIdFromElement(element) {
      return element.dataset.groupId??element.closest('[data-group-id]')?.dataset.groupId??element.closest(':has([data-group-id])')?.querySelector('[data-group-id]')?.dataset.groupId??null;
         actualTarget = group.grid;
   }
   getModalType(field) {
      // Safety check for field.ui
      if (!field || !field.ui || !field.ui.field || !field.ui.field.field) {
         return null;
      // Move each item to target
      itemIds.forEach(uploadId => {
         if (isPreviewDrop) {
            // Moving back to preview (ungrouping)
            this.removeFromGroup(uploadId);
         } else {
            // Moving to a group
            this.addToGroup(uploadId, actualTarget);
         }
      });
      // Persist state
      const fieldId = this.getFieldIdFromElement(targetElement);
      this.schedulePersistance(fieldId);
      // Announce for accessibility
      const message = itemIds.length > 1
         ? `Moved ${itemIds.length} items`
         : 'Moved item';
      this.a11y.announce(message);
      }
      const dialog = field.ui.field.field.closest('dialog');
      if (!dialog) return null;
   /*******************************************************************************
    * CLICK HANDLERS
    *******************************************************************************/
      if (dialog.classList.contains('edit')) return 'edit';
      if (dialog.classList.contains('create')) return 'create';
      if (dialog.classList.contains('bulkEdit')) return 'bulkEdit';
   handleClick(e) {
      // File input triggers
      if (e.target.matches(this.selectors.field.dropZone) ||
         e.target.closest(this.selectors.field.dropZone)) {
         const dropZone = e.target.closest(this.selectors.field.dropZone);
         if (dropZone && !e.target.matches('input, button, a')) {
            const input = dropZone.querySelector(this.selectors.field.input);
            input?.click();
         }
      }
      return dialog.className;
      // Group actions
      const actionButton = e.target.closest('[data-action]');
      if (actionButton) {
         this.handleAction(actionButton);
      }
   }
   handleChange(e) {
      const fieldId = this.getFieldIdFromElement(e.target);
      // File input change
      if (e.target.matches(this.selectors.field.input)) {
         const fieldId = this.getFieldIdFromElement(e.target);
         const files = Array.from(e.target.files);
         if (files.length > 0 && fieldId) {
            this.processFiles(fieldId, files);
         }
      }
      // Meta field changes
      if (fieldId) {
         if (this.fields.get(fieldId).config.destination === 'post_group') {
            this.handleGroupMetaChange(e.target);
         } else {
            this.queueUploadMeta(e);
         }
      }
   }
   /********************************************************************************
    UTILITY
   ********************************************************************************/
   getCurrentSelection(fieldId) {
      let selected = [];
      for (let [key, handler] of this.selectionHandlers) {
         if ((fieldId === key || key.includes(fieldId)) && handler.selectedItems.size > 0) {
            selected = selected.concat([... handler.selectedItems]);
         }
      }
      return selected;
   }
   getSubtypeFromMime(mimeType) {
      if (mimeType.startsWith('image/')) return 'image';
      if (mimeType.startsWith('video/')) return 'video';
      return 'document';
   }
   getStatusText(status) {
@@ -270,7 +527,6 @@
      return window.getIcon(this.queue.icons[status]);
   }
   getStatusProgress(status) {
      console.log('Getting status progress for: ', status);
      switch (status) {
         case 'local_processing':
            return 28;
@@ -289,1133 +545,518 @@
      }
   }
   /******************************************************************************
    LISTENERS
   ******************************************************************************/
   initListeners() {
      this.clickHandler       = this.handleClick.bind(this);
      this.changeHandler      = this.handleChange.bind(this);
      if (this.hasBulkContext) {
         this.pasteHandler       = this.handlePaste.bind(this);
         document.addEventListener('paste', this.pasteHandler);
   getModalType(field) {
      // Return cached value if available
      if (field._cachedModalType !== undefined) {
         return field._cachedModalType;
      }
      document.addEventListener('click', this.clickHandler);
      document.addEventListener('change', this.changeHandler);
      window.addEventListener('beforeunload', this.handleBeforeUnload.bind(this));
   }
   clearListeners() {
      document.removeEventListener('click', this.clickHandler);
      document.removeEventListener('change', this.changeHandler);
      if (this.hasBulkContext) {
         document.removeEventListener('paste', this.pasteHandler);
      }
   }
   initGroupListeners() {
      this.hasGroups = true;
      this.dragStartHandler   = this.handleDragStart.bind(this);
      this.dragEndHandler  = this.handleDragEnd.bind(this);
      this.dragEnterHandler   = this.handleDragEnter.bind(this);
      this.dragOverHandler    = this.handleDragOver.bind(this);
      this.dragLeaveHandler   = this.handleDragLeave.bind(this);
      this.dropHandler     = this.handleDrop.bind(this);
      this.touchStartHandler  = this.handleTouchStart.bind(this);
      this.touchMoveHandler   = this.handleTouchMove.bind(this);
      this.touchEndHandler    = this.handleTouchEnd.bind(this);
      this.touchCancelHandler = this.handleTouchCancel.bind(this);
      document.addEventListener('dragstart', this.dragStartHandler);
      document.addEventListener('dragend', this.dragEndHandler);
      document.addEventListener('dragenter', this.dragEnterHandler);
      document.addEventListener('dragover', this.dragOverHandler);
      document.addEventListener('dragleave', this.dragLeaveHandler);
      document.addEventListener('drop', this.dropHandler);
      document.addEventListener('touchstart', this.touchStartHandler);
      document.addEventListener('touchmove', this.touchMoveHandler);
      document.addEventListener('touchend', this.touchEndHandler);
      document.addEventListener('touchcancel', this.touchCancelHandler);
   }
   clearGroupListeners() {
      document.removeEventListener('dragstart', this.dragStartHandler);
      document.removeEventListener('dragend', this.dragEndHandler);
      document.removeEventListener('dragenter', this.dragEnterHandler);
      document.removeEventListener('dragover', this.dragOverHandler);
      document.removeEventListener('dragleave', this.dragLeaveHandler);
      document.removeEventListener('drop', this.dropHandler);
      document.removeEventListener('touchstart', this.touchStartHandler);
      document.removeEventListener('touchmove', this.touchMoveHandler);
      document.removeEventListener('touchend', this.touchEndHandler);
      document.removeEventListener('touchcancel', this.touchCancelHandler);
   }
   handleClick(e) {
      if (!e.target.closest(this.selectors.field.field)) {
         return;
      }
      if (window.targetCheck(e, '.restart-uploads')) {
         e.preventDefault();
         const fieldId = this.getFieldIdFromElement(e.target);
         this.restartUploads(fieldId);
      } else if (window.targetCheck(e, '.dismiss-cache-restore')) {
         e.preventDefault();
         const notification = e.target.closest('.upload-recovery-notification');
         if (notification) notification.remove();
      } else if (window.targetCheck(e, '#select-all-uploads')) {
         e.preventDefault();
         this.handleSelectAll(e.target);
      } else if (window.targetCheck(e, '.upload-select')) {
         const isShiftClick = e.shiftKey && this.lastClickedUpload;
         if (isShiftClick) {
            e.preventDefault();
            this.handleRangeSelection(e.target, e);
         } else {
            this.updateSelection(e);
         }
      } else if (window.targetCheck(e, '.create-from-selection')) {
         e.preventDefault();
         let group = this.createGroup(this.getFieldFromElement(e.target));
         this.addSelectionToGroup(group);
      } else if (window.targetCheck(e, '.remove-selection')) {
         e.preventDefault();
         this.removeSelection(e.target);
      } else if (window.targetCheck(e, '.add-to-group, .add-selection-to-group')) {
         e.preventDefault();
         this.addSelectionToGroup(e.target);
      } else if (window.targetCheck(e, '.remove-group')) {
         e.preventDefault();
         const groupElement = e.target.closest('.upload-group');
         if (groupElement) {
            let field = this.getFieldFromElement(groupElement);
            this.removeGroup(groupElement, true);
         }
      } else if (window.targetCheck(e, '.remove')) {
         e.preventDefault();
         const uploadId = this.getUploadIdFromElement(e.target);
         const fieldId = this.getFieldIdFromElement(e.target);
         if (uploadId && fieldId) {
            this.removeUpload(fieldId, uploadId);
         }
      } else if (window.targetCheck(e, '.submit-uploads')) {
         e.preventDefault();
         const fieldId = this.getFieldIdFromElement(e.target);
         this.submitUploads(fieldId);
      } else if (window.targetCheck(e, '.retry-upload')) {
         e.preventDefault();
         const uploadId = this.getUploadIdFromElement(e.target);
         this.retryUpload(uploadId);
      }
   }
   handleChange(e) {
      if (!e.target.closest(this.selectors.field.field) || e.target.classList.contains(this.selectors.field.hiddenValue)) {
         return;
      }
      e.preventDefault();
      if (window.targetCheck(e, '[type="file"]')) {
         console.log(this.fields);
         let field = this.getFieldFromElement(e.target);
         console.log(field);
         if (!field) {
            console.warn('File change on unregistered field: ', field.key)
            return;
         }
         const files = Array.from(e.target.files);
         if (files.length === 0) return;
         this.processFiles(field.key, files);
         e.target.value = '';
      } else if (e.target.name.includes('select-')) {
         this.updateSelection(e);
      } else if (e.target.closest('.upload-meta')) {
         e.preventDefault();
         let name = e.target.name;
         let value = e.target.value;
         let upload = this.getUploadFromElement(e.target);
         upload.changes[name] = value;
         this.uploads.set(upload.id, upload);
         this.persistFieldState(upload.fieldId);
         //It's meta!
         //TODO:
         //Step 1) determine whether the images have already been sent to the server. If not, we must wait until they have been
         //Step 2) Queue the Meta changes. No need to wait, the Queue.js will handle any debouncing/timeouts
         //Ensure the dependencies have all operations stored to the field that the images were uploaded with (can be multiple)
         //Send to server for processing
      } else if (e.target.closest('.group.fields')) {
         let group = this.getGroupFromElement(e.target);
         let name = e.target.name;
         group.changes[name] = e.target.value;
         this.persistFieldState(group.fieldId);
         this.groups.set(group.id, group);
      }
   }
   handlePaste(e) {
      window.debouncer.schedule(
         'imagePaste',
         () => {
            const items = Array.from(e.clipboardData.items);
            const imageItems = items.filter(item => item.type.startsWith('image/'));
            if (imageItems.length === 0) return;
            e.preventDefault();
            const fieldId = this.getFieldIdFromElement(e.target);
            if (!fieldId) return;
            // Convert clipboard items to files
            const files = [];
            imageItems.forEach((item, index) => {
               const file = item.getAsFile();
               if (file) {
                  // Rename for clarity
                  const newFile = new File([file], `pasted_image_${index + 1}.png`, {
                     type: file.type,
                     lastModified: Date.now()
                  });
                  files.push(newFile);
               }
            });
            if (files.length > 0) {
               this.processFiles(fieldId, files);
            }
         },
         100
      );
   }
   isTouchOnFormElement(target) {
      // Check if target is a form element or inside one
      const formElements = [
         'input', 'button', 'label', 'select', 'textarea',
      ];
      return formElements.some(selector => {
         return target.matches(selector) || target.closest(selector);
      });
   }
   /**** DRAG AND TOUCH *****/
   startDragOperation(config) {
      const {
         primaryElement,
         sourceType,
         startPosition,
         event
      } = config;
      const uploadId = this.getUploadIdFromElement(primaryElement);
      const fieldId = this.getFieldIdFromElement(primaryElement);
      // Determine what items to drag
      const draggedItems = this.getDraggedItems(primaryElement);
      // Initialize drag state
      this.dragState = {
         primaryItem: uploadId,
         draggedItems: draggedItems,
         isDragging: true,
         isMultiDrag: draggedItems.length > 1,
         fieldId: fieldId,
         sourceType: sourceType,
         startTime: Date.now(),
         startPosition: startPosition,
         currentPosition: startPosition,
         currentTarget: null,
         validTarget: null,
         dragPreview: null,
         touchId: sourceType === 'touch' ? event.touches[0]?.identifier : null,
         touchMoved: false
      };
      // Create drag preview
      this.createDragPreview(primaryElement);
      // Apply dragging state
      this.applyDraggingState(true);
      const announceText = this.dragState.isMultiDrag
         ? `Started dragging ${draggedItems.length} items`
         : 'Started dragging item';
      this.a11y.announce(announceText);
      this.provideDragFeedback('start');
      return true;
   }
   updateDragOperation(position, elementUnderPointer) {
      if (!this.dragState.isDragging) return;
      const { sourceType, startPosition } = this.dragState;
      // Update position
      this.dragState.currentPosition = position;
      // Check for significant movement (touch)
      if (sourceType === 'touch' && !this.dragState.touchMoved) {
         const deltaX = Math.abs(position.x - startPosition.x);
         const deltaY = Math.abs(position.y - startPosition.y);
         if (deltaX > 10 || deltaY > 10) {
            this.dragState.touchMoved = true;
         }
      }
      // Update preview and target
      this.updateDragPreview(position);
      this.updateDropTarget(elementUnderPointer);
   }
   endDragOperation(elementUnderPointer = null) {
      if (!this.dragState.isDragging) return;
      const wasSuccessful = (this.dragState.sourceType === 'drag' || this.dragState.touchMoved) &&
         this.dragState.validTarget;
      // Process drop if valid - but only here, not in handleDrop
      if (wasSuccessful && this.dragState.validTarget) {
         this.processItemDrop({
            itemIds: this.dragState.draggedItems,
            targetElement: this.dragState.validTarget,
            fieldId: this.dragState.fieldId,
            dropType: this.dragState.isMultiDrag ? 'multiple' : 'single',
            sourceType: this.dragState.sourceType
         });
      }
      // Cleanup
      this.cleanupDragOperation();
      const announceText = wasSuccessful
         ? (this.dragState.isMultiDrag ? `Moved ${this.dragState.draggedItems.length} items` : 'Item moved')
         : 'Drag cancelled';
      this.a11y.announce(announceText);
   }
   /**
    * Shared method to process any drop operation (drag or touch)
    * @param {Object} dropData - Standardized drop data
    * @returns {boolean} Success status
    */
   processItemDrop(dropData) {
      const {
         itemIds,
         targetElement,
         fieldId,
         dropType,
         sourceType
      } = dropData;
      if (!itemIds?.length || !targetElement || !fieldId) {
         return false;
      }
      // Determine if it's a preview drop
      let isPreviewDrop = targetElement.classList.contains('item-grid') && targetElement.classList.contains('preview');
      // Handle empty group drops by creating the group element
      let actualTarget = targetElement;
      if (targetElement.classList.contains('empty-group')) {
         let group = this.createGroup(fieldId);
         actualTarget = group.querySelector('.item-grid');
         isPreviewDrop = false;
      }
      // Use existing addImageToGroup method for each item
      // This method already handles:
      // - removeImageFromCurrentLocation (cleanup of old location)
      // - Adding to new location
      // - Updating field.posts data structure
      // - Caching the data
      itemIds.forEach(uploadId => {
         this.addImageToGroup(uploadId, actualTarget, isPreviewDrop);
      });
      // Clear selections for multi-drops
      if (dropType === 'multiple') {
         const field = this.fields.get(fieldId);
         this.clearAllSelections(field);
      }
      // Announce completion
      const announceText = dropType === 'multiple'
         ? `Moved ${itemIds.length} images to ${isPreviewDrop ? 'main area' : 'group'}`
         : `Image moved to ${isPreviewDrop ? 'main area' : 'group'}`;
      this.a11y.announce(announceText);
      this.provideFeedback(sourceType, 'success', {
         count: itemIds.length,
         isMultiple: dropType === 'multiple'
      });
      return true;
   }
   clearAllSelections(field) {
      // Clear all selection checkboxes in the entire field container
      const allCheckboxes = field.container.querySelectorAll('[name*="select-item"]');
      allCheckboxes.forEach(checkbox => {
         checkbox.checked = false;
      });
      // Update the select all state
      if (field.selectAll) {
         field.selectAll.checked = false;
         const label = field.selectAll.nextElementSibling;
         if (label) {
            label.textContent = 'Select All';
         }
      }
      // Hide selection controls
      if (field.selectActions) field.selectActions.hidden = true;
      if (field.selectInfo) field.selectInfo.hidden = true;
   }
   cleanupDragOperation() {
      if (this.dragState.dragPreview) {
         this.dragState.dragPreview.remove();
      }
      this.applyDraggingState(false);
      this.clearDropTargetStates();
      // Reset state
      this.dragState.isDragging = false;
      this.dragState.dragPreview = null;
      this.dragState.draggedItems = [];
   }
   /**
    * Determine what items to drag (single or multiple selection)
    */
   getDraggedItems(element) {
      const selectedUploads = this.getSelectedUploads(element);
      const primaryUploadId = element.dataset.uploadId;
      // If we have multiple selections and primary is selected, drag all
      if (selectedUploads.length > 1 && selectedUploads.includes(primaryUploadId)) {
         return selectedUploads;
      }
      // Otherwise, just drag the primary item
      return [primaryUploadId];
   }
   /**
    * Apply/remove dragging visual state to items
    */
   applyDraggingState(isDragging) {
      this.dragState.draggedItems.forEach(uploadId => {
         const element = document.querySelector(`[data-upload-id="${uploadId}"]`);
         if (element) {
            element.classList.toggle('dragging', isDragging);
         }
      });
   }
   /**
    * Create drag preview element
    */
   createDragPreview(originalElement) {
      const { isMultiDrag, draggedItems } = this.dragState;
      if (isMultiDrag) {
         this.dragState.dragPreview = this.createMultiDragPreview(originalElement, draggedItems);
      } else {
         this.dragState.dragPreview = this.createSingleDragPreview(originalElement);
      }
      this.updateDragPreview(this.dragState.startPosition);
      document.body.appendChild(this.dragState.dragPreview);
   }
   /**
    * Create single item drag preview
    */
   createSingleDragPreview(originalElement) {
      const preview = originalElement.cloneNode(true);
      preview.dataset.uploadId = preview.dataset.uploadId+'-dragging';
      this.styleDragPreview(preview, false);
      return preview;
   }
   styleDragPreview(preview, isMulti = false) {
      preview.style.cssText = `
        position: fixed;
        z-index: 10000;
        pointer-events: none;
        opacity: 0.9;
        transform: scale(1.05);
        transition: transform 0.2s ease;
        ${isMulti ? `
            width: 120px;
            height: 120px;
            background: white;
            border-radius: 8px;
            box-shadow: 0 8px 32px rgba(0,0,0,0.3);
            padding: 4px;
        ` : `
            border-radius: 4px;
            box-shadow: 0 4px 16px rgba(0,0,0,0.2);
        `}
    `;
      // Add dragging class for additional styling
      preview.classList.add('drag-preview', 'is-dragging');
      if (isMulti) {
         preview.classList.add('multi-item');
      }
   }
   /**
    * Create multiple items drag preview
    */
   createMultiDragPreview(originalElement, draggedItems) {
      const container = document.createElement('div');
      container.className = 'drag-preview multi-item';
      // Create stacked effect with up to 3 items
      const displayCount = Math.min(draggedItems.length, 3);
      for (let i = 0; i < displayCount; i++) {
         const uploadId = draggedItems[i];
         const uploadElement = document.querySelector(`[data-upload-id="${uploadId}"]`);
         if (uploadElement) {
            const stackedItem = uploadElement.cloneNode(true);
            stackedItem.dataset.uploadId = uploadId + '_dragging';
            stackedItem.style.cssText = `
            position: absolute;
            top: ${i * 4}px;
            left: ${i * 4}px;
            width: calc(100% - ${i * 4}px);
            height: calc(100% - ${i * 4}px);
            opacity: ${1 - (i * 0.15)};
            transform: rotate(${(i - 1) * 2}deg);
            z-index: ${10 - i};
            border-radius: 4px;
            overflow: hidden;
         `;
            container.appendChild(stackedItem);
         }
      }
      // Add count badge
      if (draggedItems.length > 1) {
         const badge = this.createCountBadge(draggedItems.length);
         container.appendChild(badge);
      }
      this.styleDragPreview(container, true);
      return container;
   }
   /**
    * Update drag preview position
    */
   updateDragPreview(position) {
      if (!this.dragState.dragPreview) return;
      // Calculate offset based on preview type and source
      let offset;
      if (this.dragState.sourceType === 'touch') {
         offset = this.dragState.isMultiDrag ? { x: -60, y: -80 } : { x: -50, y: -60 };
      } else {
         offset = this.dragState.isMultiDrag ? { x: 15, y: 15 } : { x: 10, y: 10 };
      }
      const deltaX = position.x - this.dragState.startPosition.x;
      const deltaY = position.y - this.dragState.startPosition.y;
      this.dragState.dragPreview.style.transform = `translate(${deltaX + offset.x}px, ${deltaY + offset.y}px) scale(1.05)`;
   }
   /**
    * Update drop target highlighting
    */
   updateDropTarget(elementUnderPointer) {
      // Clear previous target
      if (this.dragState.currentTarget) {
         this.clearDropTargetState(this.dragState.currentTarget);
      }
      // Find valid drop target
      const validTarget = this.findValidDropTarget(elementUnderPointer);
      // Update state
      this.dragState.currentTarget = elementUnderPointer;
      this.dragState.validTarget = validTarget;
      // Apply visual feedback
      if (validTarget) {
         this.applyDropTargetState(validTarget);
         // Haptic feedback for touch
         if (this.dragState.sourceType === 'touch' && navigator.vibrate) {
            const pattern = this.dragState.isMultiDrag ? [25, 10, 25] : [25];
            navigator.vibrate(pattern);
         }
      }
   }
   /**
    * Find valid drop target from element
    */
   findValidDropTarget(element) {
      if (!element) return null;
      const postContainer = element.closest('.item-grid.group, .empty-group, .item-grid.preview');
      if (postContainer) {
         const fieldId = this.getFieldIdFromElement(postContainer);
         if (fieldId === this.dragState.fieldId) {
            return postContainer;
         }
      }
      // Safety check for field.element
      if (!field || !field.element) {
         field._cachedModalType = null;
      return null;
   }
   /**
    * Apply drop target visual state
    */
   applyDropTargetState(target) {
      target.classList.add('dragover');
      if (this.dragState.isMultiDrag) {
         target.classList.add('multi-drop');
         target.setAttribute('data-item-count', this.dragState.draggedItems.length);
      }
      const dialog = field.element.closest('dialog');
      if (!dialog) {
         field._cachedModalType = null;
         return null;
   }
   /**
    * Clear drop target state from element
    */
   clearDropTargetState(target) {
      target.classList.remove('dragover', 'multi-drop');
      target.removeAttribute('data-item-count');
   }
      let modalType = null;
      if (dialog.classList.contains('edit')) modalType = 'edit';
      else if (dialog.classList.contains('create')) modalType = 'create';
      else if (dialog.classList.contains('bulkEdit')) modalType = 'bulkEdit';
      else modalType = dialog.className;
   /**
    * Clear all drop target states
    */
   clearDropTargetStates() {
      document.querySelectorAll('.dragover').forEach(el => {
         el.classList.remove('dragover', 'multi-drop');
         el.removeAttribute('data-item-count');
      });
   }
   /**
    * Create count badge for multi-item preview
    */
   createCountBadge(count) {
      const badge = document.createElement('div');
      badge.className = 'selection-count-badge';
      badge.textContent = count.toString();
      badge.style.cssText = `
         position: absolute;
         top: -8px;
         right: -8px;
         background: var(--accent-primary);
         color: white;
         border-radius: 50%;
         width: 24px;
         height: 24px;
         display: flex;
         align-items: center;
         justify-content: center;
         font-size: 12px;
         font-weight: bold;
         box-shadow: 0 2px 8px rgba(0,0,0,0.3);
         z-index: 20;
      `;
      return badge;
   }
   /**
    * Provide feedback for drag operations
    */
   provideDragFeedback(type) {
      const hapticPatterns = {
         start: [50],
         success: this.dragState.isMultiDrag ? [50, 25, 50, 25, 50] : [50, 25, 50],
         cancel: [100]
      };
      if (this.dragState.sourceType === 'touch' && navigator.vibrate && hapticPatterns[type]) {
         navigator.vibrate(hapticPatterns[type]);
      }
   }
   /**
    * Provide consistent feedback for different input methods
    */
   provideFeedback(sourceType, feedbackType, data = {}) {
      const hapticPatterns = {
         success: data.isMultiple ? [50, 25, 50, 25, 50] : [50, 25, 50],
         error: [100, 50, 100]
      };
      if (sourceType === 'touch' && navigator.vibrate && hapticPatterns[feedbackType]) {
         navigator.vibrate(hapticPatterns[feedbackType]);
      }
   }
   clearDragoverStates() {
      document.querySelectorAll('.dragover').forEach(el => {
         el.classList.remove('dragover', 'multi-drop');
         el.removeAttribute('data-item-count');
      });
   }
   /*********
    *  DRAG HANDLERS
    ********/
   handleDragEnter(e) {
      if (!window.targetCheck(e, '.image.field')) return;
      // Only handle external files
      if (e.dataTransfer.types.includes('Files')) {
         e.preventDefault();
         const uploadContainer = e.target.closest('.file-upload-container');
         if (uploadContainer) {
            uploadContainer.classList.add('dragover');
         }
      }
   }
   handleDragLeave(e) {
      if (!window.targetCheck(e, '.image.field')) return;
      const uploadContainer = e.target.closest('.file-upload-container');
      if (uploadContainer && !uploadContainer.contains(e.relatedTarget)) {
         uploadContainer.classList.remove('dragover');
      }
   }
   handleDragStart(e) {
      if (!window.targetCheck(e, '.image.field')) return;
      const uploadItem = e.target.closest('[data-upload-id]');
      if (!uploadItem) return;
      const result = this.startDragOperation({
         primaryElement: uploadItem,
         sourceType: 'drag',
         startPosition: { x: e.clientX, y: e.clientY },
         event: e
      });
      if (result) {
         e.dataTransfer.setData('text/plain', this.dragState.primaryItem);
         e.dataTransfer.effectAllowed = 'move';
      } else {
         e.preventDefault();
      }
   }
   handleDragOver(e) {
      if (!this.dragState.isDragging) return;
      if (!window.targetCheck(e, '.image.field')) return;
      e.preventDefault();
      this.updateDragOperation({ x: e.clientX, y: e.clientY }, e.target);
   }
   handleDrop(e) {
      if (!window.targetCheck(e, '.image.field')) return;
      e.preventDefault();
      this.clearDragoverStates();
      // Handle external files (new uploads)
      const uploadContainer = e.target.closest('.file-upload-container');
      if (uploadContainer) {
         const files = Array.from(e.dataTransfer.files);
         if (files.length > 0) {
            const fieldId = this.getFieldIdFromElement(uploadContainer);
            if (fieldId) {
               this.processFiles(fieldId, files);
               this.a11y.announce(`${files.length} file(s) dropped for upload`);
            }
         }
      }
   }
   handleDragEnd(e) {
      if (!this.dragState.isDragging) return;
      // Find the element under the final drop position
      const elementUnderDrop = document.elementFromPoint(
         this.dragState.currentPosition?.x || e.clientX,
         this.dragState.currentPosition?.y || e.clientY
      );
      this.endDragOperation(elementUnderDrop);
   }
   /*********
    * TOUCH HANDLERS
    ********/
   handleTouchStart(e) {
      if (!window.targetCheck(e, '.image.field')) return;
      if (this.isTouchOnFormElement(e.target)) {
         return;
      }
      const uploadItem = e.target.closest('[data-upload-id]');
      if (!uploadItem) return;
      const touch = e.touches[0];
      const result = this.startDragOperation({
         primaryElement: uploadItem,
         sourceType: 'touch',
         startPosition: { x: touch.clientX, y: touch.clientY },
         event: e
      });
      if (result) {
         e.preventDefault(); // Prevent scrolling
      }
   }
   handleTouchMove(e) {
      if (!this.dragState.isDragging) return;
      e.preventDefault();
      const touch = e.touches[0];
      const elementUnderTouch = document.elementFromPoint(touch.clientX, touch.clientY);
      this.updateDragOperation({ x: touch.clientX, y: touch.clientY }, elementUnderTouch);
   }
   handleTouchEnd(e) {
      if (!this.dragState.isDragging) return;
      e.preventDefault();
      const touch = e.changedTouches[0];
      const elementUnderTouch = document.elementFromPoint(touch.clientX, touch.clientY);
      this.endDragOperation(elementUnderTouch);
   }
   handleTouchCancel(e) {
      if (this.dragState.isDragging) {
         this.cleanupDragOperation();
         this.a11y.announce('Drag cancelled');
      }
      // Cache the result
      field._cachedModalType = modalType;
      return modalType;
   }
   /*******************************************************************************
    QUEUE INTEGRATION
    * GROUP ACTIONS
    *******************************************************************************/
   async submitUploads(fieldId) {
      const field = this.fields.get(fieldId);
      if (!field) return;
      // Check if there are uploads to submit
      const pendingUploads = Array.from(field.uploads || [])
         .map(id => this.uploads.get(id))
         .filter(upload => upload &&
            (upload.status === 'processed' ||
               upload.status === 'processed-original'));
   handleAction(button) {
      const action = button.dataset.action;
      const fieldId = this.getFieldIdFromElement(button);
      switch(action) {
         case 'add-to-group':
            this.handleAddToGroup(button);
            break;
         case 'delete-group':
            this.handleDeleteGroup(button);
            break;
         case 'delete-upload':
         case 'remove-from-group':
            this.handleRemoveItem(button);
            break;
         case 'upload':
            //upload groups
            let field = this.fields.get(fieldId);
            field.element.closest('details').open = false;
            document.body.classList.add('uploading');
      if (pendingUploads.length === 0) {
         this.notifications.add('No uploads ready to submit', 'warning');
            this.submitUploads(fieldId);
            break;
         case 'restore':
            this.handleRestoreUploads().then(()=>{});
            break;
         case 'clear-cache':
            if (!confirm(`Save these uploads for later?`)) {
               this.cleanupStoredUploads();
            }
            this.cleanupRestore();
            break;
      }
   }
   handleAddToGroup(button) {
      const fieldElement = button.closest(this.selectors.field.field);
      const fieldId = fieldElement?.dataset.uploader;
      if (!fieldId) return;
      const selected = this.selected.get(fieldId);
      if (!selected || selected.size === 0) {
         // Create empty group
         this.createGroup(fieldId);
      } else {
         // Create group with selected items
         const group = this.createGroup(fieldId);
         if (!group) return;
         selected.forEach(uploadId => {
            this.addToGroup(uploadId, group.grid);
         });
         // Clear selection
         const handler = this.selectionHandlers.get(fieldId);
         handler?.clearSelection();
         this.a11y.announce(`Created group with ${selected.size} items`);
      }
      this.schedulePersistance(fieldId);
   }
   handleDeleteGroup(button) {
      const group = button.closest(this.selectors.groups.container);
      if (!group) return;
      const groupId = group.dataset.groupId;
      const fieldId = this.getFieldIdFromElement(group);
      if (!confirm('Delete this group? Items will be moved back to the upload area.')) {
         return;
      }
      // Queue the uploads
      try {
         await this.queueUpload(fieldId);
         this.notifications.add(`Submitting ${pendingUploads.length} upload(s)`, 'info');
      } catch (error) {
         this.error.log(error, {
            component: 'UploadManager',
            action: 'submitUploads',
            fieldId
      // Move items back to preview
      const items = group.querySelectorAll(this.selectors.items.item);
      items.forEach(item => {
         const uploadId = item.dataset.uploadId;
         this.removeFromGroup(uploadId);
         });
         this.notifications.add('Failed to submit uploads', 'error');
      }
   }
   async retryUpload(uploadId) {
      const upload = this.uploads.get(uploadId);
      if (!upload) return;
      const field = this.fields.get(upload.fieldId);
      if (!field) return;
      // Remove group
      this.deleteGroup(groupId);
      try {
         // Reset status
         this.updateUploadStatus(uploadId, 'received');
         // If we have the processed file, skip to queuing
         if (upload.processedFile) {
            this.updateUploadStatus(uploadId, 'processed');
            await this.queueUpload(upload.fieldId);
         } else if (upload.originalFile) {
            // Reprocess the file
            const reprocessed = await this.processFile(upload.fieldId, upload.originalFile);
            if (reprocessed) {
               await this.queueUpload(upload.fieldId);
            }
         } else {
            throw new Error('No file data available for retry');
      this.a11y.announce('Group deleted, items returned to upload area');
      this.schedulePersistance(fieldId);
         }
         this.notifications.add('Retrying upload...', 'info');
      } catch (error) {
         this.error.log(error, {
            component: 'UploadManager',
            action: 'retryUpload',
            uploadId
         });
         this.notifications.add('Failed to retry upload', 'error');
      }
   }
   async restartUploads(fieldId) {
      const field = this.fields.get(fieldId);
      if (!field?.uploads) return;
   handleRemoveItem(button) {
      const item = button.closest(this.selectors.items.item);
      if (!item) return;
      const failedUploads = Array.from(field.uploads)
         .map(id => this.uploads.get(id))
         .filter(upload => upload && upload.status === 'failed');
      const uploadId = item.dataset.uploadId;
      const fieldId = this.getFieldIdFromElement(item);
      if (failedUploads.length === 0) {
         this.notifications.add('No failed uploads to restart', 'info');
      if (!confirm('Remove this item?')) {
         return;
      }
      for (const upload of failedUploads) {
         await this.retryUpload(upload.id);
      this.removeUpload(fieldId, uploadId);
      this.a11y.announce('Item removed');
      this.schedulePersistance(fieldId);
      }
      this.notifications.add(`Restarting ${failedUploads.length} upload(s)`, 'info');
   }
   async queueUpload(fieldId) {
      //Further cache it, or is it already cached at this point?
      const field = this.fields.get(fieldId);
      if (!field?.uploads) return;
      const uploads = Array.from(field.uploads);
      if (uploads.length === 0) {
         return;
      }
      const data = this.prepareUploadData(field, uploads);
      this.a11y.announce('Queuing for upload');
      let img = (uploads.length === 1) ? 'image' : 'images';
      const operation = {
         endpoint: 'uploads',
         method: 'POST',
         data: data,
         title: `Uploading ${uploads.length} ${img} to server...`,
         popup: `Uploading ${uploads.length} ${img}...`,
         canMerge: false,
         headers: {
            'action_nonce': jvbSettings.dash
         },
         append: '_upload'
      }
      try {
         const operationId = await this.queue.addToQueue(operation);
         uploads.forEach(uploadId => {
            let upload = this.uploads.get(uploadId);
            if (!upload) {
               return;
            }
            upload.operationId = operationId;
            this.updateUploadStatus(uploadId, 'queued');
         });
         field.operationId = operationId;
         return operationId;
      } catch (error) {
         throw error;
      } finally {
         this.persistFieldState(field.key);
      }
   }
   prepareUploadData(field, uploads) {
      console.log('Preparing Upload:', field);
      const formData = new FormData();
      formData.append('content', field.content);
      formData.append('mode', field.mode);
      formData.append('field_name', field.name);
      formData.append('field_key', field.key);
      formData.append('field_type', field.type);
      formData.append('item_id', field.itemID);    //post, term, or user id
      formData.append('context', field.context);   //post, term, or user
      let uploadMap = [];
      uploads.forEach(uploadId => {
         let upload = this.uploads.get(uploadId);
         if (upload) {
            const fileToUpload = upload.processedFile || upload.originalFile;
            if (fileToUpload) {
               formData.append('files[]', fileToUpload);
               uploadMap.push(upload.id);
            } else {
               console.warn(`No file for upload ${uploadId}`);
            }
         } else {
            console.warn(`Upload ${uploadId} not found in uploads map`);
         }
      });
      formData.append('upload_map', uploadMap);
      console.log('Final FormData:');
      for (let pair of formData.entries()) {
         console.log(pair[0], pair[1]);
      }
      return formData;
   }
   async queueImageMeta(e) {
      const upload = this.getUploadFromElement(element);
      if (!upload) return;
      const field = this.fields.get(upload.fieldId);
      if (!field) return;
      // Collect meta data from the form
      const metaContainer = element.closest('.upload-meta');
      if (!metaContainer) return;
      const metaData = {
         title: metaContainer.querySelector('[name="title"]')?.value || '',
         alt_text: metaContainer.querySelector('[name="alt_text"]')?.value || '',
         caption: metaContainer.querySelector('[name="caption"]')?.value || '',
         description: metaContainer.querySelector('[name="description"]')?.value || ''
      };
      // Update upload meta
      upload.meta = { ...upload.meta, ...metaData };
      this.uploads.set(upload.id, upload);
      // Mark that we have meta changes
      this.hasMetaChanges = true;
      // Determine if upload has been sent to server
      const isOnServer = upload.status === 'completed' && upload.attachmentId;
      if (isOnServer) {
         // Queue immediate update
         await this.sendMetaUpdate(upload);
      } else if (upload.operationId) {
         // Wait for upload to complete, then send meta
         this.queueDependentMetaUpdate(upload);
      } else {
         // Upload hasn't been queued yet, meta will be sent with initial upload
         this.persistFieldState(field.key);
      }
   }
   /**
    * Send meta update to server
    */
   async sendMetaUpdate(upload) {
      const formData = new FormData();
      formData.append('attachment_id', upload.attachmentId);
      formData.append('title', upload.meta.title);
      formData.append('alt_text', upload.meta.alt_text);
      formData.append('caption', upload.meta.caption);
      formData.append('description', upload.meta.description);
      const operation = {
         endpoint: 'uploads/meta',
         method: 'POST',
         data: formData,
         title: `Updating metadata for ${upload.meta.originalName}`,
         canMerge: true,
         headers: {
            'action_nonce': jvbSettings.dash
         }
      };
      try {
         await this.queue.addToQueue(operation);
         this.notifications.add('Metadata updated', 'success');
      } catch (error) {
         this.error.log(error, {
            component: 'UploadManager',
            action: 'sendMetaUpdate',
            uploadId: upload.id
         });
      }
   }
   /**
    * Queue meta update that depends on upload completion
    */
   queueDependentMetaUpdate(upload) {
      const operation = {
         endpoint: 'uploads/meta',
         method: 'POST',
         dependencies: [upload.operationId],
         data: () => {
            // This function will be called when dependencies are resolved
            const formData = new FormData();
            formData.append('operation_id', upload.operationId);
            formData.append('upload_id', upload.id);
            formData.append('title', upload.meta.title);
            formData.append('alt_text', upload.meta.alt_text);
            formData.append('caption', upload.meta.caption);
            formData.append('description', upload.meta.description);
            return formData;
         },
         title: `Updating metadata after upload`,
         canMerge: true,
         headers: {
            'action_nonce': jvbSettings.dash
         }
      };
      this.queue.addToQueue(operation);
   }
   /*******************************************************************************
    IMAGE PROCESSING
    * SELECTION MANAGEMENT
    *******************************************************************************/
   /**
    * Add selection handler for a field
    */
   addFieldSelectionHandler(fieldId) {
      if (this.selectionHandlers.has(fieldId)) {
         return this.selectionHandlers.get(fieldId);
      }
      const field = this.fields.get(fieldId);
      if (!field) return;
      const container = field.ui.field;
      if (!container) return;
      const handler = new window.jvbHandleSelection({
         container: container,
         ui: {
            selectAll: container.querySelector('[name="select-all-uploads"]'),
            bulkControls: container.querySelector('.selection-actions'),
            count: container.querySelector('.selection-count')
         },
         itemSelector: '[data-upload-id]',
         checkboxSelector: '[name*="select-item"]'
      });
      // Subscribe to selection changes
      handler.subscribe((event, data) => {
         switch(event) {
            case 'item-selected':
            case 'item-deselected':
            case 'range-selected':
               this.selected.set(fieldId, data.selectedItems);
               break;
            case 'select-all':
               this.handleSelectAll(data.container, data.selected);
               break;
         }
      });
      this.selectionHandlers.set(fieldId, handler);
      return handler;
   }
   /**
    * Add selection handler for a group
    */
   addGroupSelectionHandler(fieldId, groupId) {
      const handlerKey = `${fieldId}_${groupId}`;
      if (this.selectionHandlers.has(handlerKey)) {
         return this.selectionHandlers.get(handlerKey);
      }
      const group = this.groups.get(groupId);
      if (!group) return;
      const handler = new window.jvbHandleSelection({
         container: group.element,
         ui: {
            selectAll: group.element.querySelector(this.selectors.groups.selectAll),
            bulkControls: group.element.querySelector(this.selectors.groups.actions),
            count: group.element.querySelector(this.selectors.groups.count)
         },
         itemSelector: '[data-upload-id]',
         checkboxSelector: '[name*="select-item"]'
      });
      handler.subscribe((event, data) => {
         switch(event) {
            case 'item-selected':
            case 'item-deselected':
            case 'range-selected':
               this.selected.set(fieldId, data.selectedItems);
               break;
            case 'select-all':
               this.handleSelectAll(data.container, data.selected);
               break;
         }
      });
      this.selectionHandlers.set(handlerKey, handler);
      return handler;
   }
   handleSelectAll(container, selected) {
   }
   /*******************************************************************************
    * HELPER METHODS
    *******************************************************************************/
   determineFieldId(fieldElement) {
      const content = fieldElement.dataset.content ||
         fieldElement.closest('dialog')?.dataset.content ||
         fieldElement.closest('form')?.dataset.save || '';
      const itemID = fieldElement.dataset.itemId ||
         fieldElement.closest('dialog')?.dataset.itemId || '';
      const field = fieldElement.dataset.field || '';
      return `${content}_${itemID}_${field}`;
   }
   getFromElement(element, type) {
      const map = {
         'field': { selector: this.selectors.field.field, key: 'uploader', store: this.fields },
         'upload': { selector: this.selectors.items.item, key: 'uploadId', store: this.uploads },
         'group': { selector: this.selectors.groups.container, key: 'groupId', store: this.groups }
      };
      const config = map[type];
      if (!config) return null;
      const el = element.closest(config.selector);
      if (!el) return null;
      const id = el.dataset[config.key];
      return config.store.get(id);
   }
   getFieldFromElement(el) { return this.getFromElement(el, 'field'); }
   getUploadFromElement(el) { return this.getFromElement(el, 'upload'); }
   getGroupFromElement(el) { return this.getFromElement(el, 'group'); }
   getFieldIdFromElement(el) { return this.getFromElement(el, 'field')?.id ?? null};
   getUploadIdFromElement(el) {return this.getFromElement(el, 'upload')?.id ?? null};
   getGroupIdFromElement(el) {return this.getFromElement(el, 'group')?.id ?? null};
   /*******************************************************************************
    * FILE PROCESSING
   *******************************************************************************/
   async processFiles(fieldId, files) {
      const field = this.fields.get(fieldId);
      if(!field) return;
      //Validate Files
      const validFiles = files.filter(file=>this.validateFile(file, field));
      if (validFiles.length === 0) return;
      if (!this.checkFieldLimits(fieldId, validFiles.length)) {
         // this.notify(`Cannot add ${validFiles.length} files. Field limit exceeded.`, 'warning');
         return;
      // Hide upload container, show group display
      if (field.ui.dropZone) {
         field.ui.dropZone.hidden = true;
      }
      const processedUploads = await this.processBatch(fieldId, validFiles);
      this.maybeLockUploads(fieldId);
      if (field.groupDisplay) {
         field.groupDisplay.hidden = false;
      if (field.ui.groups.display) {
         field.ui.groups.display.hidden = false;
      }
      if (processedUploads.length > 0) {
      const totalFiles = files.length;
      let processedCount = 0;
      // Show initial progress
      this.updateUploadProgress(fieldId, 0, totalFiles, 'Processing files...');
      // Initialize field uploads set if needed
      if (!field.uploads) {
         field.uploads = new Set();
      }
      // Process files
      const processPromises = Array.from(files).map(async (file, index) => {
         try {
            // Create upload ID
            const uploadId = `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
            // Create upload data
            const uploadData = {
               id: uploadId,
               attachment_id: null,
               fieldId: fieldId,
               originalFile: file,
               processedFile: null,
               preview: null,
               status: 'local_processing',
               element: null,
               location: null,
               meta: {
                  originalName: file.name,
                  size: file.size,
                  type: file.type
               }
            };
            // Create preview URL
            uploadData.preview = this.createPreviewUrl(file);
            // Process the file (resize if image)
            if (file.type.startsWith('image/')) {
               uploadData.processedFile = await this.processImage(file, field.subtype);
            } else {
               uploadData.processedFile = file;
            }
            // Store blob data separately in IndexedDB
            await this.uploadStore.saveBlob(uploadId, uploadData.processedFile || file);
            // Create DOM element
            const subtype = this.getSubtypeFromMime(file.type);
            uploadData.element = this.createUploadElement({
               ...uploadData,
               subtype: subtype
            }, field.config.destination === 'post_group');
            // Show progress on the item
            this.showUploadProgress(uploadId, true);
            this.updateUploadItemProgress(uploadId, 50, 'local_processing');
            // Add to preview grid
            if (field.ui.preview) {
               field.ui.preview.appendChild(uploadData.element);
               uploadData.location = field.ui.preview;
            }
            // Store upload
            this.uploads.set(uploadId, uploadData);
            field.uploads.add(uploadId);
            // Update progress
            processedCount++;
            this.updateUploadProgress(fieldId, processedCount, totalFiles, 'Processing files...');
            this.updateUploadItemProgress(uploadId, 100, 'processed');
            uploadData.status = 'processed';
            // Fade out item progress after a moment
            setTimeout(() => {
               this.showUploadProgress(uploadId, false);
            }, 1000);
            return uploadId;
         } catch (error) {
            console.error('Error processing file:', file.name, error);
            processedCount++;
            this.updateUploadProgress(fieldId, processedCount, totalFiles, 'Processing files...');
            return null;
         }
      });
      // Wait for all files to process
      await Promise.all(processPromises);
      this.updateFieldState(fieldId);
      // Cache the state (now without DOM references)
      await this.schedulePersistance(fieldId);
      // Queue for upload if in direct mode
      if (field.config.destination !== 'post_group') {
         await this.queueUpload(fieldId);
         // Lock uploads if max reached
         this.maybeLockUploads(fieldId);
      }
      this.hideUploadProgress(fieldId);
      this.a11y.announce(`Processed ${processedUploads.length} of ${validFiles.length} files`);
   }
   updateFieldState(fieldId) {
      const field = this.fields.get(fieldId);
      if (!field || !field.ui.field) return;
      const container = field.ui.field;
      const uploadCount = field.uploads?.size || 0;
      const hasGroups = field.ui.groups?.container?.querySelectorAll('.upload-group').length > 0;
      // Set data attributes for CSS targeting
      container.dataset.hasUploads = uploadCount > 0 ? 'true' : 'false';
      container.dataset.uploadCount = uploadCount.toString();
      container.dataset.hasGroups = hasGroups ? 'true' : 'false';
      // Update ARIA labels for accessibility
      if (field.ui.preview) {
         field.ui.preview.setAttribute('aria-label',
            `Upload preview area with ${uploadCount} item${uploadCount !== 1 ? 's' : ''}`
         );
      }
   }
   updateUploadProgress(fieldId, current, total, message) {
      const field = this.fields.get(fieldId);
      if (!field?.ui?.progress?.progress) return;
      const progress = field.ui.progress;
      const percent = total > 0 ? (current / total) * 100 : 0;
      if (progress.fill) {
         progress.fill.style.width = `${percent}%`;
      }
      if (progress.text) {
         progress.text.textContent = message;
      }
      if (progress.count) {
         progress.count.textContent = `${current}/${total}`;
      }
      progress.progress.hidden = (current === total);
   }
   updateFieldStatus(fieldId, status) {
      const field = this.fields.get(fieldId);
      if (!field) return;
      field.state = status;
      // Update UI based on status
   }
   updateUploadStatus(uploadId, status) {
      const upload = this.uploads.get(uploadId);
      if (!upload) return;
      upload.status = status;
      this.updateUploadUI(uploadId);
   }
   updateUploadUI(uploadId) {
      const upload = this.uploads.get(uploadId);
      if (!upload?.element) return;
      // Update status classes
      upload.element.className = upload.element.className.replace(/status-[\w-]+/g, '');
      upload.element.classList.add(`status-${upload.status}`);
      // Update progress if showing
      const progress = upload.element.querySelector('.progress');
      if (progress) {
         this.updateUploadItemProgress(uploadId,
            this.getStatusProgress(upload.status),
            upload.status
         );
      }
   }
   /**
    * Show/hide progress indicator on individual upload items
    */
   showUploadProgress(uploadId, show = true) {
      const upload = this.uploads.get(uploadId);
      if (!upload || !upload.element) return;
      const progressEl = upload.element.querySelector('.progress');
      if (progressEl) {
         if (show) {
            progressEl.style.removeProperty('animation');
            progressEl.hidden = false;
         } else {
            progressEl.style.animation = 'fadeOut var(--transition-base)';
            setTimeout(() => {
               progressEl.hidden = true;
            }, 300);
         }
      }
   }
   /**
    * Update individual upload progress bar
    */
   updateUploadItemProgress(uploadId, percent, status = null) {
      const upload = this.uploads.get(uploadId);
      if (!upload || !upload.element) return;
      const progressEl = upload.element.querySelector('.progress');
      if (!progressEl) return;
      const fill = progressEl.querySelector('.fill');
      const details = progressEl.querySelector('.details');
      const icon = progressEl.querySelector('.icon');
      if (fill) {
         fill.style.width = `${percent}%`;
      }
      if (status && details) {
         details.textContent = this.getStatusText(status);
      }
      if (status && icon) {
         icon.innerHTML = this.getStatusIcon(status).outerHTML;
      }
   }
   checkFieldLimits(fieldId, additionalFiles) {
      const field = this.fields.get(fieldId);
      if (!field) return false;
@@ -1423,18 +1064,9 @@
      const currentCount = field.uploads?.size || 0;
      const totalCount = currentCount + additionalFiles;
      if (totalCount > field.maxFiles) {
         this.notifications.add(
            `Cannot add ${additionalFiles} files. Max ${field.maxFiles} allowed, currently have ${currentCount}.`,
            'warning'
         );
         return false;
      }
      return totalCount <= field.maxFiles;
      return true;
   }
   generateUploadId() {
      return `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
   }
   validateFile(file, field) {
      // Type validation
@@ -1464,101 +1096,14 @@
      return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
   }
   async processBatch(fieldId, files) {
      const results = [];
      const processingQueue = [];
      const maxConcurrent = this.worker.settings.maxConcurrent;
      let total = files.length;
      for (let i = 0; i < files.length; i++) {
         this.updateUploadProgress(fieldId, i, total);
         // Wait if we've reached max concurrent processing
         if (processingQueue.length >= maxConcurrent) {
            await Promise.race(processingQueue);
   shouldProcessClientSide(file, subtype) {
      // Only process images client-side
      if (subtype === 'image' && file.type.startsWith('image/')) {
         return true;
         }
         const processPromise = this.processFile(fieldId, files[i])
            .then(upload => {
               // Remove from processing queue
               const index = processingQueue.indexOf(processPromise);
               if (index > -1) processingQueue.splice(index, 1);
               if (upload) results.push(upload);
               return upload;
            })
            .catch(error => {
               console.error(`Failed to process ${files[i].name}:`, error);
               // Remove from processing queue
               const index = processingQueue.indexOf(processPromise);
               if (index > -1) processingQueue.splice(index, 1);
               return null;
            });
         processingQueue.push(processPromise);
      }
      // Wait for remaining files
      await Promise.all(processingQueue);
      return results;
   }
   async processFile(fieldId, file) {
      const field = this.fields.get(fieldId);
      const upload = await this.setUpload(fieldId, file);
      const uploadId = upload.id;
      try {
         // Update UI immediately
         this.addImageToGroup(uploadId);
         this.updateUploadStatus(uploadId, 'local_processing');
         // Attempt to process the image
         let processedFile = null;
         let processingFailed = false;
         try {
            processedFile = await this.processImage(file, uploadId);
         } catch (error) {
            console.warn(`Processing failed for ${file.name}, using original:`, error);
            processingFailed = true;
            processedFile = file; // Use original
         }
         // Update upload with processed file
         upload.processedFile = processedFile;
         upload.processingFailed = processingFailed;
         // Update status
         this.updateUploadStatus(uploadId, 'processed');
         // Save to uploads map
         this.uploads.set(uploadId, upload);
         // Persist state
         if (field && field.key) {
            await this.persistFieldState(field.key);
         }
         const message = processingFailed
            ? `${file.name} added (original format)`
            : `${file.name} processed and ready`;
         this.a11y.announce(message);
         return upload;
      } catch (error) {
         // Clean up failed upload
         this.cleanupFailedUpload(uploadId, field.key);
         this.error.log(error, {
            component: 'UploadManager',
            action: 'processFile',
            uploadId,
            fileName: file.name
         });
         return null;
      }
      // Videos and documents go straight to server
      return false;
   }
   async processImage(file, uploadId) {
@@ -1705,7 +1250,7 @@
         };
         try {
            objectUrl = URL.createObjectURL(file);
            objectUrl = this.createPreviewUrl(file);
            img.src = objectUrl;
         } catch (error) {
            cleanup();
@@ -1833,8 +1378,6 @@
    * Restart compression worker
    */
   restartCompressionWorker() {
      console.log('Restarting compression worker...');
      // Terminate existing worker
      if (this.worker.worker) {
         this.worker.worker.terminate();
@@ -1912,7 +1455,7 @@
        `;
         const blob = new Blob([workerScript], { type: 'application/javascript' });
         this.worker.worker = new Worker(URL.createObjectURL(blob));
         this.worker.worker = new Worker(this.createPreviewUrl(blob));
      } catch (error) {
         console.warn('Failed to initialize compression worker:', error);
@@ -1949,120 +1492,62 @@
      return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0;
   }
   /**
    * Clean up failed upload
    */
   cleanupFailedUpload(uploadId, fieldId) {
      const field = this.fields.get(fieldId);
      if (field?.uploads) {
         field.uploads.delete(uploadId);
   createPreviewUrl(file) {
      const url = URL.createObjectURL(file);
      // Track for cleanup
      if (!this.previewUrls) this.previewUrls = new Set();
      this.previewUrls.add(url);
      return url;
      }
      const upload = this.uploads.get(uploadId);
      if (upload) {
         // Clean up preview URL
         if (upload.preview?.startsWith('blob:')) {
            URL.revokeObjectURL(upload.preview);
   revokePreviewUrl(url) {
      if (url?.startsWith('blob:')) {
         URL.revokeObjectURL(url);
         this.previewUrls?.delete(url);
      }
         }
         // Remove element
         upload.element?.remove();
         // Remove from uploads
         this.uploads.delete(uploadId);
      }
      // Remove from active tasks
      this.worker.tasks.delete(uploadId);
   }
   /*******************************************************************************
    UI FUNCTIONALITY
   *******************************************************************************/
   /**
    * Update upload status correctly
    */
   updateUploadStatus(uploadId, status) {
      console.log('Updating upload status for: ', uploadId);
      let upload = this.uploads.get(uploadId);
      if(!upload) {
         return;
      }
      upload.status = status;
      this.updateImageUI(upload.id);
      this.persistFieldState(upload.fieldId);
   }
   updateImageUI(uploadId) {
      console.log('Updating image UI: ', uploadId);
      const upload = this.uploads.get(uploadId);
      console.log(upload);
      if (!upload?.element) return;
      const progressEl = upload.element.querySelector('.progress');
      const itemEl = upload.element;
      console.log('Updating Upload UI:', upload);
      // Update status class on item for CSS styling
      if (itemEl) {
         itemEl.className = itemEl.className.replace(/status-[\w-]+/g, '');
         itemEl.classList.add(`status-${upload.status}`);
      }
      if (progressEl) {
         let icon = this.getStatusIcon(upload.status);
         let message = this.getStatusText(upload.status);
         let progress = this.getStatusProgress(upload.status);
         const fill = progressEl.querySelector('.fill');
         const itemIcon = progressEl.querySelector('span.icon');
         const itemMessage = progressEl.querySelector('span.details');
         if (fill) {
            fill.style.width = `${progress}%`;
         }
         if (itemMessage) itemMessage.textContent = message;
         if (itemIcon) {
            window.removeChildren(itemIcon);
            itemIcon.append(icon);
         }
         if (upload.status === 'completed') {
            setTimeout(() => {
               if (progressEl) {
                  window.fade(progressEl, false);
               }
            }, 1000);
         }
      }
   }
   /**
    * Hide the uploader drop zone if we have reached our limit
    */
   maybeLockUploads(fieldId) {
      const field = this.fields.get(fieldId);
      if (!field) return;
      if (!field?.ui?.dropZone) return;
      // Hide/show drop zone based on file count
      if (field.ui.field.dropZone) {
         field.ui.field.dropZone.hidden = field.uploads && field.uploads.size >= field.maxFiles;
      if (field.config.destination === 'post_group') {
         return;
      }
      const uploadCount = field.uploads?.size || 0;
      const maxFiles = field.config?.maxFiles || 999;
      // Hide dropzone if at max files
      field.ui.dropZone.hidden = uploadCount >= maxFiles;
      // Update field state
      field.element.classList.toggle('at-max-uploads', uploadCount >= maxFiles);
   }
   createImageElement(upload, draggable = false) {
   createUploadElement(upload, draggable = false) {
      let image = window.getTemplate('uploadItem');
      if (!image) {
         console.error('Image template not found');
         return;
      }
      image.dataset.uploadId = upload.id;
      if (upload.originalFile) {
         image.dataset.subtype = this.getSubtypeFromMime(upload.originalFile.type);
      }
      image.querySelector('[name="featured"]').value = upload.id;
      let [
         featured,
         img,
         video,
         preview,
         details
      ] = [
         image.querySelector('[name="featured"]'),
         image.querySelector('img'),
         image.querySelector('video'),
         image.querySelector('label > span'),
         image.querySelector('details')
      ];
      [
@@ -2074,6 +1559,45 @@
         upload.preview,
         upload.originalFile?.name ?? upload.meta?.originalName ?? '',
      ];
      switch (image.dataset.subtype) {
         case 'image':
            [
               img.src,
               img.alt
            ] = [
               upload.preview,
               upload.originalFile?.name ?? upload.meta?.originalName?? ''
            ];
            video.remove();
            preview.remove();
            break;
         case 'video':
            video.src = upload.preview;
            img.remove();
            preview.remove();
            break;
         case 'document':
            const fileName = upload.originalFile?.name ?? upload.meta?.originalName ?? '';
            const extension = fileName.split('.').pop()?.toLowerCase() ?? '';
            const iconMap = {
               'pdf': 'file-pdf',
               'csv': 'file-csv',
               'doc': 'file-doc',
               'docx': 'file-doc',
               'txt': 'file-txt',
               'xls': 'file-xls',
               'xlsx': 'file-xls'
            };
            const icon = window.getIcon(iconMap[extension] || 'file');
            preview.innerText = upload.originalFile.name;
            preview.prepend(icon);
            img.remove();
            video.remove();
            break;
      }
      if (details) {
         let template = window.getTemplate('uploadMeta');
         if (template){
@@ -2097,345 +1621,737 @@
      return image;
   }
   updateUploadProgress(fieldId, current, total, message) {
   /*******************************************************************************
    * QUEUE INTEGRATION
    *******************************************************************************/
   async submitUploads(fieldId) {
      const field = this.fields.get(fieldId);
      if (!field) return;
      if (!field?.uploads || field.uploads.size === 0) {
         return;
      }
      let progressBar = field.ui.field.progress.progress;
      let uploads = Array.from(field.uploads);
      if (uploads.length === 0) {
         this.error.log('No uploads to upload', {
            component: 'UploadManager',
            action: 'submitGroupedUploads',
            fieldId: fieldId
         });
         return;
      }
      // Create progress bar if it doesn't exist
      if (!progressBar) {
         progressBar = window.getTemplate('imageProgress');
      const fieldGroups = this.getFieldGroups(fieldId);
         // Insert after drop zone or at top of container
         const insertAfter = field.dropZone || field.container.firstElementChild;
         if (insertAfter) {
            insertAfter.insertAdjacentElement('afterend', progressBar);
      if (fieldGroups.length === 0) {
         this.error.log('No groups created for post_group upload', {
            component: 'UploadManager',
            action: 'submitGroupedUploads',
            fieldId: fieldId
         });
         return;
      }
      // Build posts array from groups
      const posts = [];
      const formData = new FormData();
      let uploadMap = [];
      uploads = uploads.map((upload) => {
         return this.uploads.get(upload);
      });
      fieldGroups.forEach((group, groupIndex) => {
         const post = {
            images: [],
            fields: {}
         };
         for (let [name, value] of Object.entries(group.changes)) {
            post.fields[name] = value;
         }
         let groupUploads = uploads.filter((upload) => {
            return upload['groupId'] === group.id;
         });
         groupUploads.forEach((upload) => {
            if (upload) {
               const fileToUpload = upload.processedFile || upload.originalFile;
               if (fileToUpload) {
                  formData.append('files[]', fileToUpload);
                  const imageData = {
                     upload_id: upload.id,
                     index: uploadMap.length
                  };
                  post.images.push(imageData);
                  uploadMap.push(upload.id);
               }
            }
         });
         // Add images for this group
         // group.uploads.forEach(uploadId => {
         //    const upload = this.uploads.get(uploadId);
         //    if (upload) {
         //       const fileToUpload = upload.processedFile || upload.originalFile;
         //       if (fileToUpload) {
         //          formData.append('files[]', fileToUpload);
         //
         //          const imageData = {
         //             upload_id: upload.id,
         //             index: uploadMap.length
         //          };
         //
         //          // Check if this is the featured image
         //          const radioInput = upload.element?.querySelector('[name="featured"]');
         //          if (radioInput?.checked) {
         //             post.fields.featured = upload.id;
         //          }
         //
         //          post.images.push(imageData);
         //          uploadMap.push(upload.id);
         //       }
         //    }
         // });
         posts.push(post);
      });
      //Each remaining upload (without a groupId) becomes its own post
      let remainingUploads = uploads.filter((upload) => {
         return !Object.hasOwn(upload, 'groupId');
      });
      remainingUploads.forEach((upload) => {
         if (upload) {
            const post = {
               images: [],
               fields: {}
            };
            const fileToUpload = upload.processedFile || upload.originalFile;
            if (fileToUpload) {
               formData.append('files[]', fileToUpload);
               const imageData = {
                  upload_id: upload.id,
                  index: uploadMap.length
               };
               post.images.push(imageData);
               uploadMap.push(upload.id);
            }
            posts.push(post);
         }
      });
      // Add metadata to FormData
      formData.append('content', field.config.content);
      formData.append('user', field.config.itemID); // Assuming itemID is user ID
      formData.append('posts', JSON.stringify(posts));
      formData.append('upload_ids', JSON.stringify(uploadMap));
      const operation = {
         endpoint: 'uploads/groups',
         method: 'POST',
         data: formData,
         title: `Creating ${posts.length} ${field.config.content}${posts.length > 1 ? 's' : ''} from uploads...`,
         popup: `Creating ${posts.length} post${posts.length > 1 ? 's' : ''}...`,
         canMerge: false,
         headers: {
            'action_nonce': jvbSettings.dash
         },
         append: '_upload',
      };
      try {
         const operationId = await this.queue.addToQueue(operation);
         uploads.forEach(uploadId => {
            let upload = this.uploads.get(uploadId);
            if (upload) {
               upload.operationId = operationId;
               this.updateUploadStatus(uploadId, 'queued');
            }
         });
         field.operationId = operationId;
         this.a11y.announce(`Creating ${posts.length} post${posts.length > 1 ? 's' : ''} from your uploads`);
         return operationId;
      } catch (error) {
         this.error.log(error, {
            component: 'UploadManager',
            action: 'submitGroupedUploads',
            fieldId: fieldId
         });
         throw error;
      } finally {
         this.schedulePersistance(field.id);
      }
   }
   async queueUpload(fieldId) {
      const field = this.fields.get(fieldId);
      if (!field?.uploads) return;
      const uploads = Array.from(field.uploads);
      if (uploads.length === 0) {
         return;
      }
      const data = this.prepareUploadData(field, uploads);
      this.a11y.announce('Queuing for upload');
      let img = (uploads.length === 1) ? 'file' : 'files';
      const operation = {
         endpoint: 'uploads',
         method: 'POST',
         data: data,
         title: `Uploading ${uploads.length} ${img} to server...`,
         popup: `Uploading ${uploads.length} ${img}...`,
         canMerge: false,
         headers: {
            'action_nonce': jvbSettings.dash
         },
         append: '_upload'
      }
      try {
         const operationId = await this.queue.addToQueue(operation);
         uploads.forEach(uploadId => {
            let upload = this.uploads.get(uploadId);
            if (!upload) {
               return;
            }
            upload.operationId = operationId;
            this.updateUploadStatus(uploadId, 'queued');
         });
         field.operationId = operationId;
         return operationId;
      } catch (error) {
         throw error;
      } finally {
         this.schedulePersistance(field.id);
      }
   }
   prepareUploadData(field, uploads) {
      const formData = new FormData();
      formData.append('content', field.config.content);
      formData.append('mode', field.config.mode);
      formData.append('field_name', field.config.name);
      formData.append('fieldId', field.id);
      formData.append('field_type', field.config.type);
      formData.append('subtype', field.config.subtype);
      formData.append('item_id', field.config.itemID);      //post, term, or user id
      formData.append('destination', field.config.destination || 'meta'); //meta, post, post_group
      let uploadMap = [];
      const fieldGroups = this.getFieldGroups(field.id);
      if (field.config.destination === 'post_group' && fieldGroups.length > 0) {
         // User has created groups
         let groups = [];
         let titles = [];
         let featuredImages = [];
         fieldGroups.forEach(group => {
            let groupUploadIndices = [];
            let featuredIndex = null;
            group.uploads.forEach(uploadId => {
               let upload = this.uploads.get(uploadId);
               if (upload) {
                  const fileToUpload = upload.processedFile || upload.originalFile;
                  if (fileToUpload) {
                     formData.append('files[]', fileToUpload);
                     const fileIndex = uploadMap.length;
                     uploadMap.push(upload.id);
                     groupUploadIndices.push(upload.id);
                     // Check if this is the featured image
                     const radioInput = upload.element?.querySelector('[name="featured"]');
                     if (radioInput?.checked) {
                        featuredIndex = upload.id;
                     }
                  }
               }
            });
            groups.push(groupUploadIndices);
            titles.push(group.title || '');
            featuredImages.push(featuredIndex);
         });
         formData.append('groups', JSON.stringify(groups));
         formData.append('group_titles', JSON.stringify(titles));
         formData.append('featured_images', JSON.stringify(featuredImages));
         } else {
            field.container.prepend(progressBar);
         // No groups - just append all files
         uploads.forEach(uploadId => {
            let upload = this.uploads.get(uploadId);
            if (upload) {
               const fileToUpload = upload.processedFile || upload.originalFile;
               if (fileToUpload) {
                  formData.append('files[]', fileToUpload);
                  uploadMap.push(upload.id);
         }
      }
         });
      }
      formData.append('upload_ids', JSON.stringify(uploadMap));
      // console.log('Final FormData:');
      // for (let pair of formData.entries()) {
      //    console.log(pair[0], pair[1]);
      // }
      return formData;
   }
      // Update progress bar
      const progressPercent = total > 0 ? Math.round((current / total) * 100) : 0;
      const progressFill = field.ui.field.progress.fill;
      const progressMessage = field.ui.field.progress.details;
      const progressCount = field.ui.field.progress.count;
   getFieldGroups(fieldId) {
      const groups = [];
      if (progressFill) {
         progressFill.style.width = `${progressPercent}%`;
      }
      if (progressMessage) {
         progressMessage.textContent = message;
      }
      if (progressCount) {
         progressCount.textContent = `${current}/${total}`;
      }
      // Add completion styling
      if (current === total) {
         progressBar.classList.add('completed');
      }
   }
   hideUploadProgress(fieldId) {
      this.groups.forEach((groupData, groupId) => {
         if (groupData.fieldId === fieldId) {
      const field = this.fields.get(fieldId);
            const groupElement = field?.ui?.groups?.groups?.get(groupId);
            groups.push({
               id: groupId,
               uploads: Array.from(groupData.uploads || new Set()),
               changes: groupData.changes || {},
               element: groupElement || null
            });
         }
      });
      return groups;
   }
   async queueUploadMeta(e) {
      const upload = this.getUploadFromElement(e.target);
      if (!upload) return;
      const field = this.fields.get(upload.fieldId);
      if (!field) return;
      const progressBar = field.ui.field.progress.progress;
      if (progressBar) {
         window.fade(progressBar, false);
      const container = e.target.closest('.upload-meta');
      if (!container) return;
      let data = {};
      data[e.target.name] = e.target.value;
      upload.meta = {
         ...upload.meta,
         ... data
      };
      let queueData = {};
      //If there is an attachment ID, use that: else, use our generated upload id
      queueData[upload.attachmentId??upload.id] = upload.meta;
      const operation = {
         endpoint:   'uploads/meta',
         method:     'POST',
         data:    queueData,
         title:      `Updating meta`,
         canMerge:   true,
         headers: {
            'action_nonce': jvbSettings.dash
         }
      };
      try {
         await this.queue.addToQueue(operation);
      } catch (error) {
         this.error.log(error, {
            component: 'UploadManager',
            action: 'sendMetaUpdate',
            uploadId: upload.id
         });
      }
   }
   /*******************************************************************************
    INDEXEDDB CACHE FUNCTIONALITY
    * GROUP MANAGEMENT
   *******************************************************************************/
   async initDB() {
      if (!('indexedDB' in window)) return;
      const request = indexedDB.open(`jvb_uploads_db`, 1);
      request.onupgradeneeded = (e) => {
         const db = e.target.result;
         if (!db.objectStoreNames.contains('fieldStates')) {
            const store = db.createObjectStore('fieldStates', { keyPath: 'fieldId' });
            store.createIndex('timestamp', 'timestamp', { unique: false });
            store.createIndex('content', 'content', { unique: false });
            store.createIndex('itemId', 'itemId', { unique: false });
   createGroup(fieldKey, groupId = null) {
      const field = this.fields.get(fieldKey);
      if (!field) {
         console.error('Field not found:', fieldKey);
         return null;
         }
         // Blob storage remains separate for performance
         if (!db.objectStoreNames.contains('uploadBlobs')) {
            db.createObjectStore('uploadBlobs', { keyPath: 'uploadId' });
      if (!groupId) {
         groupId = `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
         }
      };
      request.onsuccess = (e) => {
         this.db = e.target.result;
         this.loadFields();
      };
      const groupElement = this.createGroupElement(groupId, fieldKey);
      if (!groupElement) {
         console.error('Failed to create group element');
         return null;
      }
      request.onerror = (e) => {
         console.error('IndexedDB error:', e);
      // Store in field UI Map
      if (!field.ui.groups) {
         field.ui.groups = {
            groups: new Map(),
            container: null,
            empty: null,
            display: null
      };
   }
   async loadFields() {
      if (!this.db) return;
      field.ui.groups.groups.set(groupId, groupElement);
      return new Promise((resolve) => {
         const tx = this.db.transaction(['fieldStates', 'uploadBlobs'], 'readonly');
         const fieldStates = tx.objectStore('fieldStates');
         const blobStore = tx.objectStore('uploadBlobs');
         const request = fieldStates.getAll();
         request.onsuccess = (e) => {
            e.target.result.forEach(field => {
               let uploads = field.uploads;
               let uploadIds = uploads.map(upload => upload.id);
               field.uploads = new Set(uploadIds);
               this.fields.set(field.key, field);
               uploads.forEach(upload => {
                  this.uploads.set(upload.id, upload);
               });
            });
            this.notify('uploads-loaded', { items: Array.from(this.uploads.values()) });
            resolve();
         };
         const blobRequest = blobStore.getAll();
         blobRequest.onsuccess = (e) => {
            e.target.result.forEach(item => {
               this.uploadBlobs.set(item.id, item);
            });
            this.notify('blobs-loaded', { items: Array.from(this.uploadBlobs.values()) });
            resolve();
         };
      });
      // Insert into DOM
      if (field.ui.groups.container && field.ui.groups.empty) {
         field.ui.groups.container.insertBefore(groupElement, field.ui.groups.empty);
      } else if (field.ui.groups.container) {
         field.ui.groups.container.appendChild(groupElement);
   }
   getUpload(uploadId) {
      return this.uploads.get(uploadId);
   }
   clearField(fieldId) {
      let uploads = Array.from(this.fields.uploads);
      uploads.forEach(upload => {
         this.uploads.delete(upload);
      });
      this.fields.delete(fieldId);
      if (this.db) {
         const tx = this.db.transaction(['fieldStates', 'uploadBlobs'], 'readwrite');
         tx.objectStore('fieldStates').delete(fieldId);
         uploads.forEach(upload => {
            tx.objectStore('uploadBlobs').delete(upload);
         });
      }
   }
   updateFieldStatus(fieldId, status) {
      const field = this.fields.get(fieldId);
      if (!field) return;
      field.uploads.forEach(upload => {
         console.log('Attempting to set upload to status: ', status);
         this.updateUploadStatus(upload, status);
      });
      // Update UI based on status
      const container = field.ui.field.field;
      if (container) {
         container.dataset.uploadStatus = status;
         // Show/hide relevant UI elements
         const submitBtn = container.querySelector('.submit-uploads');
         if (submitBtn) {
            submitBtn.disabled = status === 'uploading' || status === 'processing';
         }
      }
   }
   /**
    * Handle successful upload completion
    */
   handleUploadComplete(operation) {
      const response = operation.response;
      if (!response?.uploads) return;
      // Map server IDs to uploads
      response.uploads.forEach(serverUpload => {
         const upload = this.uploads.get(serverUpload.upload_id);
         if (upload) {
            upload.attachmentId = serverUpload.attachment_id;
            this.updateUploadStatus(serverUpload.upload_id, 'completed');
            this.uploads.set(upload.id, upload);
            // Clear from cache since it's now on server
            this.clearUpload(upload.id);
         }
      });
      // Persist updated field state
      const fieldKey = operation.data.get('field_key');
      if (fieldKey) {
         this.persistFieldState(fieldKey);
      }
   }
   /**
    * Clear individual upload from cache after successful server upload
    */
   async clearUpload(uploadId) {
      const upload = this.uploads.get(uploadId);
      if (!upload) return;
      // Clean up preview URL
      if (upload.preview?.startsWith('blob:')) {
         URL.revokeObjectURL(upload.preview);
      }
      this.persistFieldState(upload.fieldId);
      // Remove from memory
      this.uploads.delete(uploadId);
      this.uploadBlobs.delete(uploadId);
      // Remove from IndexedDB
      if (this.db) {
         const tx = this.db.transaction(['uploadBlobs'], 'readwrite');
         await tx.objectStore('uploadBlobs').delete(uploadId);
      }
   }
   /**
    * Store upload with DataStore integration
    */
   async setUpload(fieldId, file, uploadId = null) {
      if (!uploadId) {
         uploadId = this.generateUploadId();
      }
      const upload = {
         id: uploadId,
         fieldId: fieldId,
         groupId: null,
         originalFile: file,
         processedFile: null,
         status: 'received',
         progress: { percent: 0, message: 'Received...' },
         preview: URL.createObjectURL(file),
         createdAt: Date.now(),
         meta: {
            title: '',
            alt_text: '',
            caption: '',
            originalName: file.name,
            originalType: file.type,
            originalSize: file.size
         },
      // Create group object
      const group = {
         id: groupId,
         fieldId: fieldKey,
         element: groupElement,
         grid: groupElement.querySelector('.item-grid.group'),
         uploads: new Set(),
         changes: {}
      };
      // Add to field
      const field = this.fields.get(fieldId);
      // Store group
      this.groups.set(groupId, group);
      // Initialize selection handler for this group
      this.addGroupSelectionHandler(fieldKey, groupId);
      // Persist state
      this.schedulePersistance(fieldKey);
      return group;
   }
   createGroupElement(groupId, fieldId) {
      let groupElement = window.getTemplate('imageGroup');
      if (!groupElement) return;
      groupElement.dataset.groupId = groupId;
      groupElement.dataset.fieldId = fieldId;
      let fields = window.getTemplate('groupMetadata');
      const fieldsContainer = groupElement.querySelector('.fields');
      if (fieldsContainer && fields) {
         fieldsContainer.append(fields);
         // Set unique IDs and names for form fields
         const titleInput = fieldsContainer.querySelector('[name="post_title"]');
         const excerptInput = fieldsContainer.querySelector('[name="post_excerpt"]');
         if (titleInput) {
            titleInput.id = `${groupId}_title`;
            titleInput.name = `${groupId}[post_title]`;
         }
         if (excerptInput) {
            excerptInput.id = `${groupId}_excerpt`;
            excerptInput.name = `${groupId}[post_excerpt]`;
         }
         let field = this.fields.get(fieldId);
         if (field.config.content !== '') {
            let summary = groupElement.querySelector('summary');
            summary.textContent = field.config.content + ' Fields';
         }
      } else {
         groupElement.querySelector('details').remove();
      }
      const gridContainer = groupElement.querySelector('.item-grid.group');
      if (gridContainer) {
         gridContainer.dataset.groupId = groupId;
      }
      return groupElement;
   }
   deleteGroup(groupId, confirm = true) {
      let group = this.groups.get(groupId);
      if (!group) {
         return;
      }
      let keepUploads = true;
      if (confirm && group.uploads && group.uploads.size > 0) {
         keepUploads = !window.confirm('Delete uploads in group?');
      }
      if (confirm && keepUploads) {
         // Move any remaining uploads back to preview
         if (group.uploads && group.uploads.size > 0) {
            Array.from(group.uploads).forEach(uploadId => {
               this.addImageToGroup(uploadId, null, false);
            });
         }
      }
      // Remove from groups Map
      this.groups.delete(groupId);
      // Remove DOM element
      let groupElement = group.element;
      if (groupElement) {
         groupElement.remove();
         this.a11y.announce('Group removed');
      }
      this.schedulePersistance(group.fieldId);
   }
   addToGroup(uploadId, target = null, persist = true) {
      let upload = this.uploads.get(uploadId);
      if(!upload) {
         return;
      }
      let field = this.fields.get(upload.fieldId);
      if (!field) {
         console.error(`Field ${fieldId} not found`);
         return null;
      }
      if (!field.uploads) field.uploads = new Set();
      field.uploads.add(uploadId);
      upload.element = this.createImageElement(upload, field.type==='groupable');
      upload.ui = window.uiFromSelectors(this.selectors.item, upload.element);
      // Store in memory
      this.uploads.set(uploadId, upload);
      this.updateImageUI(uploadId);
      // Persist to DataStore
      await this.persistFieldState(fieldId);
      return upload;
         return;
   }
   getFieldUploads(fieldId, stripElements) {
      //Already in the Preview Grid, or already in the group we're moving to
      if ((!target && upload.location === field.ui.preview) || target === upload.location) {
         return;
      }
      // Remove from previous location
      if (upload.location) {
         let groupId = upload.location.dataset.groupId;
         if (groupId) {
            let group = this.groups.get(groupId);
            if (group && group.uploads) {
               group.uploads.delete(uploadId);
               if (group.uploads.size === 0) {
                  this.deleteGroup(groupId);
               }
            }
         }
      }
      const checkbox = upload.element.querySelector('[name*="select-item"]');
      if (checkbox) {
         checkbox.checked = false;
      }
      let featured = upload.element.querySelector('[name="featured"]');
      featured.hidden = !target;
      //If no target, it's going to the preview grid
      if (!target) {
         target = field.ui.preview;
         upload.groupId = null;
      } else if (!target.classList.contains('item-grid') || !target.classList.contains('preview')) {
         // It's a group target
         let groupId = target.dataset.groupId;
         featured.name = groupId+'_'+featured.name;
         let group = this.groups.get(groupId);
         if (!group) {
            group = this.createGroup(upload.fieldId);
            target = group.grid;
            groupId = group.id;
         }
         if (group) {
            group.uploads.add(uploadId);
            upload.groupId = groupId;
         }
      }
      upload.location = target;
      target.append(upload.element);
      if (persist) {
         this.schedulePersistance(field.id);
      }
   }
   removeFromGroup(uploadId) {
      const upload = this.uploads.get(uploadId);
      if (!upload) return;
      const field = this.fields.get(upload.fieldId);
      if (!field) return;
      // Remove from current group if in one
      if (upload.groupId) {
         const group = this.groups.get(upload.groupId);
         if (group?.uploads) {
            group.uploads.delete(uploadId);
            // Delete empty group
            if (group.uploads.size === 0) {
               this.deleteGroup(upload.groupId, false);
            }
         }
         upload.groupId = null;
      }
      // Move back to preview
      if (field.ui?.preview) {
         field.ui.preview.appendChild(upload.element);
         upload.location = field.ui.preview;
      }
      // Hide featured radio
      const featured = upload.element.querySelector('[name="featured"]');
      if (featured) {
         featured.hidden = true;
         featured.checked = false;
      }
   }
   removeUpload(fieldId, uploadId) {
      const field = this.fields.get(fieldId);
      console.log('Got field uploads: ', field);
      if (!field?.uploads) return [];
      const upload = this.uploads.get(uploadId);
      if (!field || !upload) return;
      // Remove from field
      field.uploads?.delete(uploadId);
      // Remove from group if grouped
      if (upload.groupId) {
         const group = this.groups.get(upload.groupId);
         if (group && group.uploads) {
            group.uploads.delete(uploadId);
            if (group.uploads.size === 0) {
               this.removeGroup(upload.groupId);
            }
         }
      }
      // Clean up element
      upload.element?.remove();
      // Clean up memory
      this.clearUpload(uploadId);
      // Update field state after removal
      this.updateFieldState(fieldId);
      // Update UI
      this.maybeLockUploads(fieldId);
      const handler = this.selectionHandlers.get(field.id);
      if (handler) {
         handler.deselect(uploadId);
      }
      this.a11y.announce('Upload removed');
   }
   /*******************************************************************************
    * STATE MANAGEMENT
    *******************************************************************************/
   schedulePersistance(fieldId) {
      const key = `persist_${fieldId}`;
      window.debouncer.schedule(
         key,
         () => this.persistFieldState(fieldId),
         1000
      );
   }
   async persistFieldState(fieldId) {
      const field = this.fields.get(fieldId);
      if (!field) return;
      // Convert Sets to Arrays for storage
      const fieldData = {
         ...field,
         id: fieldId, // Use as primary key
         fieldId: fieldId,
         uploads: Array.from(field.uploads || []).map(uploadId => {
            return this.uploads.get(uploadId);;
         }),
         groups: Array.from(this.groups.entries())
            .filter(([id, data]) => data.fieldId === fieldId && data.uploads && data.uploads.size > 0)
            .map(([id, data]) => ({
               id: data.id,
               uploads: Array.from(data.uploads),
               changes: data.changes || {}
            })),
         // Context for restoration
         context: {
            url: this.normalizeUrl(window.location.href),
            fullUrl: window.location.href,
            modalType: this.getModalType(field),
            formId: field.formId,
            fieldSelector: `.field.upload[data-field="${field.config.name}"]`
         },
         timestamp: Date.now()
      };
      // Save to store
      await this.fieldStore.save(fieldData);
   }
   normalizeUrl(url) {
      try {
         const urlObj = new URL(url);
         // Return just the origin + pathname (no query string or hash)
         return urlObj.origin + urlObj.pathname;
      } catch (e) {
         return url;
      }
   }
   /**
    * Get uploads for a field, optionally cleaned for storage
    * @param {string} fieldId
    * @param {boolean} clean - Remove DOM references for IndexedDB storage
    * @returns {Array}
    */
   getFieldUploads(fieldId, clean = false) {
      const field = this.fields.get(fieldId);
      if (!field || !field.uploads) return [];
      return Array.from(field.uploads)
         .map(id => {
            let upload = this.uploads.get(id);
         .map(uploadId => {
            const upload = this.uploads.get(uploadId);
            if (!upload) return null;
            if (stripElements) {
               // Create a clean copy without DOM references
               const { element, ui, ...cleanUpload } = upload;
               upload = cleanUpload;
            if (clean) {
               // Return cleaned version without DOM references or blob URLs
               return {
                  id: upload.id,
                  fieldId: upload.fieldId,
                  status: upload.status,
                  // DON'T include preview (blob URL)
                  // DON'T include originalFile or processedFile (in blob storage)
                  attachmentId: upload.attachmentId,
                  operationId: upload.operationId,
                  groupId: upload.groupId || null,
                  changes: upload.changes || {}, // ← ADD: Include changes
                  meta: {
                     originalName: upload.meta?.originalName || upload.originalFile?.name,
                     size: upload.meta?.size || upload.originalFile?.size,
                     type: upload.meta?.type || upload.originalFile?.type,
                     title: upload.meta?.title,
                     alt: upload.meta?.alt,
                     caption: upload.meta?.caption
            }
               };
            }
            // Return full upload object
            return upload;
         })
         .filter(Boolean);
   }
   /**
    * Persist upload to DataStore
    */
   async persistFieldState(fieldId) {
      if (!this.db) return;
      const field = this.fields.get(fieldId);
      if (!field) return;
      // Create clean field config without UI references
      const { ui, container, dropZone, previewGrid, selectAll, selectActions, selectInfo, selectCount, groupDisplay, ...cleanConfig } = field;
      const fieldState = {
         fieldId: fieldId,
         timestamp: Date.now(),
         config: {
            key: cleanConfig.key,
            id: cleanConfig.id,
            name: cleanConfig.name,
            type: cleanConfig.type,
            content: cleanConfig.content,
            itemID: cleanConfig.itemID,
            context: cleanConfig.context,
            mode: cleanConfig.mode,
            maxFiles: cleanConfig.maxFiles,
            multiple: cleanConfig.multiple
         },
         // Recovery context
         context: {
            url: window.location.href,
            modalType: this.getModalType(field),
            formId: field.formId
         },
         // Uploads with their group associations (cleaned)
         uploads: this.getFieldUploads(fieldId, true),
         // Groups structure (ensure these are also cleaned)
         groups: Array.from(this.groups.entries())
            .filter(([id, data]) => data.fieldId === fieldId && data.uploads.size > 0)
            .map(([id, data]) => ({
               id: data.id,
               uploads: Array.from(data.uploads),
               meta: data.meta,
               changes: data.changes
            }))
      };
      const tx = this.db.transaction(['fieldStates'], 'readwrite');
      await tx.objectStore('fieldStates').put(fieldState);
   }
   /*******************************************************************************
    RESTORE FUNCTIONALITY
   *******************************************************************************/
   async checkPendingUploads() {
   async checkForStoredUploads() {
      if (!this.db) return;
      const tx = this.db.transaction(['fieldStates'], 'readonly');
@@ -2446,12 +2362,29 @@
         request.onsuccess = () => resolve(request.result);
      });
      //
      // allFieldStates.forEach(field => {
      //    console.log(`Field ${field.fieldId} has ${field.uploads.length} uploads:`);
      //    field.uploads.forEach((upload, idx) => {
      //       console.log(`  Upload ${idx}:`, {
      //          id: upload.id,
      //          status: upload.status,
      //          operationId: upload.operationId,
      //          hasOperationId: !!upload.operationId
      //       });
      //    });
      // });
      // Filter for pending uploads (not yet sent to server)
      const pendingFields = allFieldStates.filter(field =>
         field.uploads.some(upload =>
            upload.status === 'processing' ||
            // If no operationId, it hasn't been sent to server yet
            !upload.operationId &&
            // And it's been processed locally
            (upload.status === 'completed' ||
            upload.status === 'processed' ||
            upload.status === 'pending'
               upload.status === 'local_processing' ||
               upload.status === 'processed-original')
         )
      );
@@ -2461,132 +2394,359 @@
      this.showRecoveryNotification(pendingFields);
   }
   showRecoveryNotification(pendingFields) {
   async handleRestoreUploads() {
      let notification = document.querySelector('dialog.restore-uploads');
      if (!notification) {
         return;
      }
      const selectedUploads = this.getSelectedRestorationUploads(notification);
      if (selectedUploads.length === 0) {
         return;
      }
      await this.restoreSelectedUploads(selectedUploads);
      this.cleanupRestore();
   }
   getSelectedRestorationUploads(notificationEl) {
      let selected = [];
      const checkboxes = notificationEl.querySelectorAll('[type=checkbox]:checked');
      checkboxes.forEach(checkbox => {
         const item = checkbox.closest('.item');
         if (item) {
            selected.push({
               uploadId: item.dataset.uploadId,
               fieldId: item.dataset.fieldId
            });
         }
      });
      return selected;
   }
   handleGroupMetaChange(input) {
      let group = this.getGroupFromElement(input);
      if (!group) {
         return;
      }
      if (!Object.hasOwn(group, 'changes')) {
         group.changes = {};
      }
      let name = input.name;
      if (name.includes('group')) {
         let replace = group.id+'_';
         let replace2 = group.id+'[';
         name = name.replace(replace, '').replace(replace2,'').replace(']', '');
      }
      group.changes[`${name}`] = input.value;
      this.groups.set(group.id, group);
      this.schedulePersistance(group.fieldId);
   }
   /*******************************************************************************
    * RESTORING UPLOADS
    *******************************************************************************/
   async showRecoveryNotification(pendingFields) {
      const totalUploads = pendingFields.reduce((sum, field) => sum + field.uploads.length, 0);
      const totalGroups = pendingFields.reduce((sum, field) =>
         sum + (field.groups?.length || 0), 0);
      let notification = window.getTemplate('restoreNotification');
      [
         notification.querySelector('.restore-details').textContent,
      ] = [
         `${totalUploads} upload(s) from ${pendingFields.length} field(s) can be recovered.`
      ];
      pendingFields.forEach(field => {
         console.log(field);
         let template = window.getTemplate('restoreField');
         field.uploads.forEach(upload => {
            let uploadItem = window.getTemplate('restoreItem');
            [
               uploadItem.querySelector('img').src
            ] = [
               upload.preview
            ];
            template.append(uploadItem);
         });
         notification.append(template);
      });
      // Add event handlers
      notification.querySelector('[data-action="restore"]').addEventListener('click', () => {
         this.restoreFieldStates(pendingFields);
         notification.remove();
      });
      notification.querySelector('[data-action="dismiss"]').addEventListener('click', () => {
         this.notifications.add('Uploads saved for later restoration', 'info');
         notification.remove();
      });
      notification.querySelector('[data-action="clear"]').addEventListener('click', () => {
         this.clearCachedUploads(pendingFields);
         notification.remove();
      });
      document.body.appendChild(notification);
      if (!notification) {
         console.error('Restore notification template not found');
         return;
   }
   async restoreFieldStates(fieldStates) {
      // Group by URL
      const byUrl = new Map();
      fieldStates.forEach(field => {
         if (!byUrl.has(field.context.url)) {
            byUrl.set(field.context.url, []);
      // Build appropriate message
      let message;
      if (totalGroups > 0) {
         let group = totalGroups > 1 ? 'groups' : 'group';
         let upload = totalUploads > 1 ? 'uploads' : 'upload';
         message = `${totalGroups} ${group} with ${totalUploads} ${upload} can be restored.`;
      } else {
         message = `${totalUploads} upload(s) from ${pendingFields.length} field(s) can be recovered.`;
         }
         byUrl.get(field.context.url).push(field);
      const detailsEl = notification.querySelector('.restore-details');
      if (detailsEl) {
         detailsEl.textContent = message;
      }
      // Build the restoration preview
      for (const field of pendingFields) {
         let fieldTemplate = window.getTemplate('restoreField');
         if (!fieldTemplate) continue;
         // Set field name/title
         const titleEl = fieldTemplate.querySelector('h3');
         if (titleEl) {
            titleEl.textContent = field.config.name || 'Unnamed Field';
         }
         const itemGrid = fieldTemplate.querySelector('.item-grid.restore');
         // Process each upload
         for (const upload of field.uploads) {
            let uploadItem = window.getTemplate('uploadItem');
            if (!uploadItem) continue;
            //
            //    const imgEl = uploadItem.querySelector('img');
            //    const placeholderEl = uploadItem.querySelector('.image-placeholder');
            //
            const blobData = await this.uploadStore.getBlob(upload.id);
            if (blobData) {
               try {
                  // Create new blob URL from stored data
                  const blob = new Blob([blobData.data], { type: blobData.type });
                  const previewUrl = this.createPreviewUrl(blob);
                  let [
                     featured,
                     img,
                     video,
                     preview,
                     details
                  ] = [
                     uploadItem.querySelector('[name="featured"]'),
                     uploadItem.querySelector('img'),
                     uploadItem.querySelector('video'),
                     uploadItem.querySelector('label > span'),
                     uploadItem.querySelector('details')
                  ];
                  uploadItem.dataset.uploadId = upload.id;
                  uploadItem.dataset.fieldId = field.id;
                  let subtype = this.getSubtypeFromMime(blobData.type);
                  uploadItem.dataset.subtype = subtype;
                  switch (subtype) {
                     case 'image':
                        [
                           img.src,
                           img.alt
                        ] = [
                           previewUrl,
                           upload.originalFile?.name ?? upload.meta?.originalName?? ''
                        ];
                        video.remove();
                        preview.remove();
                        break;
                     case 'video':
                        video.src = previewUrl;
                        img.remove();
                        preview.remove();
                        break;
                     case 'document':
                        let extension = '';
                        let icon;
                        switch (extension) {
                           case 'pdf':
                              icon = window.getIcon('file-pdf');
                              break;
                           case 'csv':
                              icon = window.getIcon('file-csv');
                              break;
                           case 'doc':
                              icon = window.getIcon('file-doc');
                              break;
                           case 'txt':
                              icon = window.getIcon('file-txt');
                              break;
                           case 'xls':
                              icon = window.getIcon('file-xls');
                              break;
                           default:
                              icon = window.getIcon('file');
                              break;
                        }
                        preview.innerText = upload.originalFile.name;
                        preview.prepend(icon);
                        img.remove();
                        video.remove();
                        break;
                  }
                  // Store URL for cleanup later
                  uploadItem.dataset.previewUrl = previewUrl;
               } catch (error) {
                  console.warn('Failed to create preview for upload:', upload.id, error);
               }
            }
            // Set upload metadata
            const nameEl = uploadItem.querySelector('summary span');
            if (nameEl) {
               nameEl.textContent = upload.meta?.originalName || 'Unknown file';
            }
            const metaEl = uploadItem.querySelector('details');
            if (metaEl && upload.meta) {
               metaEl.textContent = `${this.formatBytes(upload.meta.size)} • ${upload.meta.type}`;
            }
            // Update input IDs safely
            uploadItem.querySelectorAll('input').forEach(input => {
               let id = input.id;
               if (id) {
                  let newId = id + upload.id;
                  let label = input.parentNode.querySelector(`label[for="${id}"]`);
                  input.id = newId;
                  if (label) {
                     label.htmlFor = newId;
                  }
               }
      });
      // If all on current page, restore directly
      if (byUrl.size === 1 && byUrl.has(window.location.href)) {
         for (const fieldState of fieldStates) {
            if (itemGrid) {
               itemGrid.appendChild(uploadItem);
            }
         }
         notification.querySelector('.wrap').appendChild(itemGrid);
      }
      document.querySelector('.field.upload').appendChild(notification);
      notification = document.querySelector('dialog.restore-uploads');
      this.restoreModal = new window.jvbModal(notification);
      this.restoreSelection = new window.jvbHandleSelection({
         container: notification,
         ui: {
            selectAll: notification.querySelector('#select-all-restore'),
            count: notification.querySelector('.selection-count'),
         },
      });
      this.restoreModal.handleOpen();
   }
   async restoreSelectedUploads(selectedUploads) {
      // Group by field
      const byField = new Map();
      selectedUploads.forEach(item => {
         if (!byField.has(item.fieldId)) {
            byField.set(item.fieldId, []);
         }
         byField.get(item.fieldId).push(item.uploadId);
      });
      // Get full field states from IndexedDB
      if (!this.db) {
         // this.notifications.add('Cannot restore: Database not available', 'error');
         return;
      }
      const tx = this.db.transaction(['fieldStates'], 'readonly');
      const store = tx.objectStore('fieldStates');
      for (const [fieldId, uploadIds] of byField.entries()) {
         const request = store.get(fieldId);
         const fieldState = await new Promise(resolve => {
            request.onsuccess = () => resolve(request.result);
            request.onerror = () => resolve(null);
         });
         if (fieldState) {
            // Filter to only selected uploads
            fieldState.uploads = fieldState.uploads.filter(u => uploadIds.includes(u.id));
            await this.restoreField(fieldState);
         }
         this.notifications.add(`Restored ${fieldStates.length} field(s)`, 'success');
      } else {
         // Store intent to restore and navigate
         sessionStorage.setItem('jvb_restore_uploads', JSON.stringify(fieldStates));
      }
         // Navigate to first URL
         const firstUrl = byUrl.keys().next().value;
         if (window.location.href !== firstUrl) {
            window.location.href = firstUrl;
         }
      }
      // this.notifications.add(`Restored ${selectedUploads.length} upload(s)`, 'success');
   }
   async restoreField(fieldState) {
      const { config, context, uploads, groups } = fieldState;
      const { config, context, uploads, groups, id } = fieldState;  // ← Use 'id'
      // If in a modal, open it first
      if (context.modalType) {
         await this.openModalForRestore(context);
      }
      // Find the field element
      const fieldElement = document.querySelector(
         `.field.image[data-field-id="${config.id}"]`
      );
      // Find field element
      let fieldElement = document.querySelector(`.field.upload[data-field="${config.name}"]`);
      if (!fieldElement) {
         console.warn(`Field ${config.id} not found for restoration`);
         const uploaderKey = `${config.content}_${config.itemID}_${config.name}`;
         fieldElement = document.querySelector(`.field.upload[data-uploader="${uploaderKey}"]`);
      }
      if (!fieldElement) {
         console.warn(`Field ${config.name} not found for restoration`, config);
         return;
      }
      // Register the field
      const fieldKey = this.registerUploader(fieldElement, config);
      // Register the field if not already registered
      let fieldKey = fieldElement.dataset.uploader;
      if (!fieldKey || !this.fields.has(fieldKey)) {
         fieldKey = this.registerUploader(fieldElement, config);
      }
      const field = this.fields.get(fieldKey);
      if (!field) {
         console.error('Failed to register field for restoration');
         return;
      }
      // Merge saved state back into field
      field.state = fieldState.state || 'ready';
      // Rebuild UI references
      field.ui = this.buildFieldUI(fieldElement);
      if (field.ui.groups?.display) {
         field.ui.groups.display.hidden = false;
      }
      // Restore groups
      if (groups && groups.length > 0) {
         await this.restoreGroups(fieldKey, groups);
      }
      // Restore uploads
      for (const uploadData of uploads) {
         await this.restoreUpload(field, uploadData);
      }
      // Restore groups
      if (groups && groups.length > 0) {
         await this.restoreGroups(field, groups, uploads);
      }
      // Update UI
      this.updateFieldState(fieldKey);
      this.maybeLockUploads(fieldKey);
      // Queue for upload if needed
      if (config.mode === 'direct') {
      if (config.mode === 'direct' && config.destination !== 'post_group') {
         await this.queueUpload(fieldKey);
      }
   }
   async restoreUpload(field, uploadData) {
      // Reconstruct the file from blob data
      const blobData = await this.getBlobData(uploadData.id);
      let file = null;
      // Try to get blob data from IndexedDB
      const blobData = await this.uploadStore.getBlob(uploadData.id);
      if (blobData) {
         file = new File(
         const file = blobData.data instanceof File
            ? blobData.data
            : new File(
            [blobData.data],
            blobData.name,
            { type: blobData.type, lastModified: blobData.lastModified }
         );
         uploadData.originalFile = file;
         uploadData.processedFile = file;
         uploadData.preview = this.createPreviewUrl(file);
      } else {
         console.warn('Blob data not found for upload:', uploadData.id);
         return; // Skip this upload if we can't restore the file
      }
      // Add to field
@@ -2594,61 +2754,59 @@
      field.uploads.add(uploadData.id);
      // Recreate DOM element
      uploadData.element = this.createImageElement(uploadData, field.type === 'groupable');
      const subtype = this.getSubtypeFromMime(uploadData.originalFile.type);
      uploadData.element = this.createUploadElement({
         ...uploadData,
         subtype: subtype
      }, field.config.destination === 'post_group');
      // Restore to correct location
      const location = uploadData.groupId
         ? field.ui.groups.groups.get(uploadData.groupId)
         : field.ui.field.preview;
      let location;
      if (uploadData.groupId && field.ui.groups.groups.has(uploadData.groupId)) {
         location = field.ui.groups.groups.get(uploadData.groupId).querySelector('.item-grid');
      } else {
         location = field.ui.preview;
      }
      if (location) {
         location.append(uploadData.element);
         location.appendChild(uploadData.element);
         uploadData.location = location;
      }
      // Store in memory
      this.uploads.set(uploadData.id, uploadData);
      if (uploadData.groupId) {
         const group = this.groups.get(uploadData.groupId);
         if (group && group.uploads) {
            group.uploads.add(uploadData.id);
         }
      }
   }
   async restoreGroups(field, groups, uploads) {
   async restoreGroups(fieldKey, groups) {
      for (const groupData of groups) {
         // Create group element
         const groupElement = this.createGroupElement(groupData.id, field.key);
         field.ui.groups.groups.set(groupData.id, groupElement);
         field.ui.groups.container.insertBefore(groupElement, field.ui.groups.empty);
         // Use createGroup which properly initializes EVERYTHING including selection handlers
         const group = this.createGroup(fieldKey, groupData.id);
         // Create group Set
         const groupSet = new Set(groupData.uploadIds);
         this.groups.set(groupData.id, groupSet);
         // Restore group metadata
         if (group) {
            // Update the group metadata from saved state
         if (groupData.meta) {
            this.groupsMeta.set(groupData.id, groupData.meta);
            // TODO: Populate meta fields in groupElement
               group.meta = { ...groupData.meta };
            }
            if (groupData.changes) {
               group.changes = { ...groupData.changes };
         }
         // Move uploads to group
         groupData.uploadIds.forEach(uploadId => {
            const upload = uploads.find(u => u.id === uploadId);
            if (upload && upload.element) {
               groupElement.querySelector('.item-grid').append(upload.element);
               upload.location = groupElement.querySelector('.item-grid');
               upload.groupId = groupData.id;
            }
         });
            // If you saved group titles, restore them
            if (groupData.title) {
               const titleInput = group.element.querySelector('[name*="post_title"]');
               if (titleInput) {
                  titleInput.value = groupData.title;
      }
   }
   async getBlobData(uploadId) {
      if (!this.db) return null;
      const tx = this.db.transaction(['uploadBlobs'], 'readonly');
      const request = tx.objectStore('uploadBlobs').get(uploadId);
      return new Promise(resolve => {
         request.onsuccess = () => resolve(request.result);
         request.onerror = () => resolve(null);
      });
         }
      }
   }
   async openModalForRestore(context) {
@@ -2678,367 +2836,62 @@
      }
   }
   async clearCachedUploads(fieldStates) {
      if (!this.db) return;
      const tx = this.db.transaction(['fieldStates', 'uploadBlobs'], 'readwrite');
      for (const field of fieldStates) {
         // Delete field state
         await tx.objectStore('fieldStates').delete(field.fieldId);
         // Delete all associated blobs
         for (const upload of field.uploads) {
            await tx.objectStore('uploadBlobs').delete(upload.id);
            // Clean up preview URLs
            if (upload.preview?.startsWith('blob:')) {
               URL.revokeObjectURL(upload.preview);
            }
         }
      }
      this.notifications.add('Cached uploads cleared', 'info');
   }
// Check for restoration intent on page load
   async checkRestorationIntent() {
      const restoreData = sessionStorage.getItem('jvb_restore_uploads');
      if (!restoreData) return;
      const fieldStates = JSON.parse(restoreData);
      const currentUrlFields = fieldStates.filter(f => f.context.url === window.location.href);
      if (currentUrlFields.length > 0) {
         for (const fieldState of currentUrlFields) {
            await this.restoreField(fieldState);
         }
         // Remove restored fields from session storage
         const remaining = fieldStates.filter(f => f.context.url !== window.location.href);
         if (remaining.length > 0) {
            sessionStorage.setItem('jvb_restore_uploads', JSON.stringify(remaining));
         } else {
            sessionStorage.removeItem('jvb_restore_uploads');
         }
         this.notifications.add(`Restored ${currentUrlFields.length} field(s)`, 'success');
      }
   }
   /*******************************************************************************
    GROUP FUNCTIONALITY
    Includes selection, dragging, and grouping logic
    INDEXEDDB CACHE FUNCTIONALITY
   *******************************************************************************/
   /**
    *
    * @param {string} uploadId as defined by setUpload
    * @param {HTMLElement|null} target The target location
    * @param {boolean} persist whethet to cache this change
    */
   addImageToGroup(uploadId, target = null, persist = true) {
      let upload = this.getUpload(uploadId);
      if(!upload) {
         return;
      }
      let field = this.fields.get(upload.fieldId);
      if (!field) {
         return;
      }
      //Already in the Preview Grid, or already in the group we're moving to
      if (!target && upload.location === field.ui.field.preview || target === upload.location) {
         return;
      }
   handleFieldStoreEvent(event, data) {
      switch(event) {
         case 'data-loaded':
      if (upload.location) {
         let groupId = upload.location.dataset.groupId;
         if (groupId) {
            let group = this.groups.get(groupId);
            if (group) {
               group.delete(uploadId);
               if (group.size === 0) {
                  this.removeGroup(groupId);
               }
            }
            break;
         case 'item-saved':
            console.log(`Field state saved: ${data.key}`);
            break;
         }
      }
      upload.element.querySelector('[name="featured"]').hidden = !target;
      //If no target, it's going to the preview grid
      if (!target) {
         target = field.ui.field.preview;
   handleUploadStoreEvent(event, data) {
      switch(event) {
         case 'data-loaded':
            this.checkForStoredUploads();
            break;
         case 'item-saved':
            this.showSaveIndicator(data.key);
            break;
      }
   }
   async saveUpload(upload) {
      // Handle blob data separately
      if (upload.file instanceof File || upload.file instanceof Blob) {
         await this.uploadStore.saveBlob(upload.id, upload.file);
         // Don't store the file in the main store
         const { file, originalFile, ...cleanUpload } = upload;
         await this.uploadStore.save(cleanUpload);
      } else {
         let groupId = target.dataset.groupId;
         let group = this.groups.get(groupId);
         if (!group) {
            group = this.createGroup(upload.fieldId);
         }
         group.uploads.add(uploadId);
      }
      target.append(upload.element);
      if (persist) {
         this.persistFieldState(field.key);
         await this.uploadStore.save(upload);
      }
   }
   addSelectionToGroup(target) {
      let field = this.getFieldFromElement(target);
      if (!field) {
         return;
      }
      if (this.selected.get(field.key).size === 0) {
         return;
      }
      let group = this.getGroupFromElement(target);
      if (!group) {
         group = this.createGroup(field.key);
      }
   async loadFields() {
      // Load all field states from the store
      const fields = await this.fieldStore.getAll();
      Array.from(this.selected).forEach(uploadId => {
         this.addImageToGroup(uploadId, group.grid, false);
      });
      this.persistFieldState(group.fieldId);
      fields.forEach(field => {
         // Reconstruct upload sets
         if (field.uploads && Array.isArray(field.uploads)) {
            field.uploads = new Set(field.uploads.map(u => u.id));
   }
   /**
    * Remove an empty group from the field
    * @param {string} groupId - The group to remove
    * @param {boolean} confirm - ask for confirmation
    */
   removeGroup(groupId, confirm = false) {
      let group = this.groups.get(groupId);
      if (!group) {
         return;
      }
      if (confirm) {
         if(!window.confirm('This will delete this group. Any uploads in this group will return to the main grid. Are you sure?')){
            return;
         }
      }
      if (group.uploads.size > 0) {
         Array.from(group.uploads).forEach(upload => {
            this.addImageToGroup(upload);
         this.fields.set(field.fieldId, field);
         });
      }
      let groupElement = group.element;
      // Remove DOM element
      if (groupElement) {
         window.fade(groupElement, false);
         this.a11y.announce('Empty group removed');
      }
      this.persistFieldState(group.fieldId);
   }
   createGroup(fieldId) {
      let field = this.fields.get(fieldId);
      if(!field) {
         return;
      }
      let index = field.ui.groups.size;
      field.ui.groups.groups.set(`group-${index}`, this.createGroupElement(`group-${index}`, fieldId));
      let group = field.ui.groups.groups.get(`group-${index}`);
      field.ui.groups.container.insertAfter(group, field.ui.groups.empty);
      let groupConfig = {
         fieldId: field.key,
         id: `group-${index}`,
         element: group,
         grid: group.querySelector('.item-grid'),
         uploads: new Set(),
         meta: {
            post_title: '',
            post_excerpt: '',
         },
         changes: {},
      };
      this.groups.set(`group-${index}`, groupConfig);
      return groupConfig;
   }
   createGroupElement(groupId, fieldId) {
      let post = window.getTemplate('imageGroup');
      if (!post) {
         return;
      }
      post.dataset.groupId = groupId;
      post.dataset.fieldId = fieldId;
      let fields = window.getTemplate('groupMetaData');
      post.querySelector('.fields')?.append(fields);
      return post;
   }
   /**
    * Handle select all functionality
    */
   handleSelectAll(element, checked = null) {
      const field = this.getFieldFromElement(element);
      if (!field) return;
      // Use element's checked state if not provided
      if (checked === null) {
         checked = element.checked;
      }
      const target = field.previewGrid;
      const previewItems = target.querySelectorAll('[data-upload-id]') || [];
      previewItems.forEach(item => {
         const checkbox = item.querySelector('[name*="select-item"]');
         if (checkbox) {
            checkbox.checked = checked;
         }
      });
      this.updateSelectAll(element);
      this.a11y.announce(checked ? 'All uploads selected' : 'All uploads deselected');
      // Clear last clicked since we're selecting/deselecting all
      this.lastClickedUpload = null;
   }
   updateSelection(e) {
      let field = this.getFieldFromElement(e.target);
      let upload = this.getUploadFromElement(e.target);
      if (!field || ! upload) {
         console.log('No field or upload found...');
         return;
      }
      this.lastClickedUpload = upload.id;
      let action = e.target.checked;
      if (action) {
         this.selected.get(field.key).add(upload.id);
      } else {
         this.selected.get(field.key).delete(upload.id);
      }
   }
   updateSelectAll(element) {
      const field = this.getFieldFromElement(element);
      if (!field) return;
      const selected = this.getSelectedUploads(element);
      if (selected.length > 0 ) {
         field.selectActions.hidden = false;
         field.selectInfo.hidden = false;
         field.selectCount.textContent = `${selected.length}`;
      } else {
         field.selectActions.hidden = true;
         field.selectInfo.hidden = true;
      }
      let selectAll = selected.length === field.container.querySelectorAll('.item-grid.preview .upload-item').length;
      field.selectAll.checked = selectAll;
      field.selectAll.nextElementSibling.textContent = (selectAll) ? 'Clear Selection' : 'Select All';
   }
   getSelectedUploads(element) {
      let field = this.getFieldFromElement(element);
      if (!field) {
         return;
      }
      return Array.from(this.selected.get(field.key)??[]);
   }
   handleRangeSelection(currentElement, event) {
      const field = this.getFieldFromElement(currentElement);
      if (!field) return;
      const currentUploadId = this.getUploadIdFromElement(currentElement);
      if (!currentUploadId || !this.lastClickedUpload) return;
      // Get all upload items in the preview grid
      const container = currentElement.closest('.item-grid');
      const allItems = Array.from(container.querySelectorAll('[data-upload-id]'));
      // Find indices of first and current items
      const firstIndex = allItems.findIndex(item =>
         item.dataset.uploadId === this.lastClickedUpload
      );
      const currentIndex = allItems.findIndex(item =>
         item.dataset.uploadId === currentUploadId
      );
      if (firstIndex === -1 || currentIndex === -1) return;
      // Determine range (handle both directions)
      const startIndex = Math.min(firstIndex, currentIndex);
      const endIndex = Math.max(firstIndex, currentIndex);
      // Select all items in range (including the clicked one!)
      for (let i = startIndex; i <= endIndex; i++) {
         const item = allItems[i];
         const checkbox = item.querySelector('[name*="select-item"]');
         if (checkbox) {
            checkbox.checked = true;
         }
      }
      currentElement.checked = true;
      // Update selection UI
      this.updateSelectAll(currentElement);
      // Announce the range selection
      const selectedCount = endIndex - startIndex + 1;
      this.a11y.announce(`Selected ${selectedCount} items in range`);
      // Update the last clicked item to the current one
      this.lastClickedUpload = currentUploadId;
   }
   removeSelection(button) {
      let fieldId = this.getFieldIdFromElement(button);
      const selectedUploads = this.getSelectedUploads(button);
      if (selectedUploads.length === 0) {
         this.notify('No uploads selected', 'warning');
         return;
      }
      selectedUploads.forEach(upload => {
         this.removeUpload(fieldId, upload);
   async loadUploads() {
      const uploads = await this.uploadStore.getAll();
      uploads.forEach(upload => {
         this.uploads.set(upload.id, upload);
      });
   }
   removeUpload(fieldId, uploadId) {
      const field = this.fields.get(fieldId);
      const upload = this.uploads.get(uploadId);
      if (!field || !upload) return;
      // Remove from field
      field.uploads?.delete(uploadId);
      // Remove from group if grouped
      if (upload.groupId) {
         const group = this.groups.get(upload.groupId);
         group?.delete(uploadId);
      }
      // Clean up element
      upload.element?.remove();
      // Clean up memory
      this.clearUpload(uploadId);
      // Update UI
      this.maybeLockUploads(fieldId);
      this.updateSelectAll(field.ui.field.field);
      this.a11y.announce('Upload removed');
   }
   /**************************************************************************
    META
    Handled separately, in case it is edited in the middle of processing images
   **************************************************************************/
   /**************************************************************************
    SUBSCRIBERS
   **************************************************************************/
@@ -3053,35 +2906,111 @@
   notify(event, data) {
      this.subscribers.forEach(cb => cb(event, data));
   }
   /*******************************************************************************
    * CLEANUP
    *******************************************************************************/
   handleBeforeUnload(e) {
      // Check for any uploads in processing or pending state
      const unsavedUploads = Array.from(this.uploads.values()).filter(upload =>
         upload.status === 'processing' ||
         upload.status === 'pending' ||
         upload.status === 'uploading'
      );
   destroy() {
      // Remove core listeners
      document.removeEventListener('click', this.clickHandler);
      document.removeEventListener('change', this.changeHandler);
      document.removeEventListener('dragenter', this.dragEnterHandler);
      document.removeEventListener('dragleave', this.dragLeaveHandler);
      document.removeEventListener('dragover', this.dragOverHandler);
      document.removeEventListener('drop', this.dropHandler);
      if (unsavedUploads.length > 0) {
         const message = 'You have uploads in progress. Are you sure you want to leave?';
         e.preventDefault();
         e.returnValue = message;
         return message;
      // Destroy drag controller
      if (this.dragController) {
         this.dragController.destroy();
      }
   }
   /**************************************************************************
    CLEANUP
   **************************************************************************/
   cleanup() {
      this.clearListeners();
      if (this.hasGroups) {
         this.clearGroupListeners();
      }
      this.compressionWorker = null;
      // Destroy selection handlers
      this.selectionHandlers.forEach(handler => handler.destroy());
      this.selectionHandlers.clear();
      this.cleanupAllPreviewUrls();
      // Clear data
      this.fields.clear();
      this.uploads.clear();
      this.groups.clear();
      this.selected.clear();
      this.subscribers.clear();
   }
   cleanupRestore() {
      this.restoreModal.handleClose();
      this.restoreSelection.destroy();
      this.restoreSelection = null;
      this.restoreModal.destroy();
      this.restoreModal.modal.remove();
      this.restoreModal = null;
}
   async cleanupStoredUploads() {
      this.fieldStore.clear();
      this.uploadStore.clear();
   }
   /**
    * Clear all uploads for a field and cleanup resources
    */
   async clearField(fieldId) {
      // Clear from stores
      await this.fieldStore.delete(fieldId);
      // Clear related uploads
      const field = this.fields.get(fieldId);
      if (field?.uploads) {
         for (const uploadId of field.uploads) {
            await this.uploadStore.delete(uploadId);
         }
      }
      // Clear from memory
      this.fields.delete(fieldId);
   }
   async clearUpload(uploadId, persist = true) {
      const upload = this.uploads.get(uploadId);
      if (!upload) return;
      // Clean up preview URL using helper
      this.revokePreviewUrl(upload.preview);
      // Clean up element preview URL
      if (upload.element) {
         const previewUrl = upload.element.dataset.previewUrl;
         this.revokePreviewUrl(previewUrl);
         delete upload.element.dataset.previewUrl;
      }
      if (persist) {
         await this.schedulePersistance(upload.fieldId);
      }
      // Remove from memory
      this.uploads.delete(uploadId);
      // Remove from IndexedDB
      this.uploadStore.delete(uploadId);
      this.uploadStore.delete(uploadId, 'blobs');
   }
   cleanupAllPreviewUrls() {
      if (this.previewUrls) {
         this.previewUrls.forEach(url => {
            try {
               URL.revokeObjectURL(url);
            } catch (e) {
               // Ignore errors during cleanup
            }
         });
         this.previewUrls.clear();
      }
   }
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
   window.jvbUploads = new UploadManager();
});