Jake Vanderwerf
2026-05-12 457c329237f97069063e641b10f384a52d584f21
assets/js/concise/UploadManager.js
@@ -1,102 +1,1470 @@
/**
 * UploadManager - Refactored for clarity
 *
 * Architecture:
 * - DataStores (fieldStore, uploadStore) = Recovery cache only, cleared after successful upload
 * - Maps (uploadElements, fieldElements) = Runtime DOM references
 * - Upload data flows: File → Process → Queue → Server → Clean up stores
 */
class UploadManager {
   constructor() {
      // Load dependencies
      this.queue = window.jvbQueue;
      this.a11y = window.jvbA11y;
      this.queue = window.jvbQueue;
      this.error = window.jvbError;
      this.fieldStoreReady = false;
      this.uploadStoreReady = false;
      this.hasCheckedForUploads = false;
      const {fields, uploads} = window.jvbStore.register(
      this.templates = window.jvbTemplates;
      this.subscribers = new Set();
      this.initStores();
      this.initWorker();
      //Maps for DOM references
      this.fields = new Map();
      this.uploads = new Map();
      this.groups = new Map();
      this.selected = new Map();
      this.selectionHandlers = new Map();
      this.sortables = new Map();
      this.changes = new Map();
      this.previewUrls = new Set();
      this.initElements();
      this.initListeners();
      this.defineTemplates();
   }
   defineTemplates() {
      const T = this.templates;
      const images = this;
      T.define('uploadItem', {
         refs: {
            select: '[name="select-item"]',
            featured: '[name="featured"]',
            img: 'img',
            video: 'video',
            file: 'label > span',
            details: 'details',
            alt: '[name="image-alt-text"]',
            title: '[name="image-title"]',
            description: '[name="image-caption"]',
         },
         manyRefs: {
            inputs: 'input, select, textarea',
         },
         setup({el, refs, manyRefs, data}) {
            const isNewUpload = Object.hasOwn(data, 'file');
            let mimeType;
            let url;
            let alt;
            let previewUrl = false;
            if (isNewUpload) {
               el.dataset.uploadId = data.uploadId;
               mimeType = images.getSubtypeFromMime(data.file.type)||'image';
               url = (mimeType !== 'document') ? images.createPreviewUrl(data.file) : false;
               previewUrl = url;
               alt = data.file.name||'';
            } else {
               el.dataset.id = data.id;
               mimeType = images.getSubtypeFromURL(data.medium??data.src);
               url = data.medium??data.src;
               alt = data['image-alt-text']??'';
            }
            el.dataset.subtype = mimeType;
            if (refs.featured) {
               refs.featured.value = data.uploadId;
            }
            switch (mimeType) {
               case 'image':
                  if (refs.img) {
                     refs.img.src = url;
                     refs.img.alt = alt;
                     if (previewUrl) refs.img.dataset.previewUrl = previewUrl;
                  }
                  if (refs.video) refs.video.remove();
                  if (refs.file) refs.file.remove();
                  break;
               case 'video':
                  if (refs.video) {
                     refs.video.src = url;
                     refs.video.alt = alt;
                     if (previewUrl) refs.video.dataset.previewUrl = previewUrl;
                  }
                  if (refs.img) refs.img.remove();
                  if (refs.file) refs.file.remove();
                  break;
               case 'document':
                  if (refs.preview) {
                     let ext = data.file.name.split('.').pop()?.toLowerCase()??'';
                     let map = {
                        'pdf': 'file-pdf', 'csv': 'file-csv',
                        'doc': 'file-doc', 'docx': 'file-doc',
                        'txt': 'file-txt', 'xls': 'file-xls', 'xlsx': 'file-xls'
                     };
                     let icon = window.getIcon(map[ext]??'file');
                     refs.preview.innerText = data.file.name??data.title;
                     refs.preview.prepend(icon);
                  }
                  if (refs.img) refs.img.remove();
                  if (refs.video) refs.video.remove();
                  break;
            }
            if (refs.details) {
               if (Object.hasOwn(data, 'field') && Object.hasOwn(data.field,'config') && Object.hasOwn(data.field.config, 'showMeta') && !data.field.config.showMeta) {
                  refs.details.remove();
               } else {
                  if(Object.hasOwn(data, 'id')) {
                     refs.details.dataset.attachmentId = data.id;
                  } else if (Object.hasOwn(data, 'uploadId')) {
                     refs.details.dataset.uploadId = data.uploadId;
                  }
                  refs.details.setAttribute('data-ignore', '');
                  if (mimeType !== 'image' && refs.alt) {
                     refs.alt.closest('.field')?.remove();
                  } else if (Object.hasOwn(data, 'image-alt-text') && refs.alt) {
                     refs.alt.value = data['image-alt-text'];
                  }
                  if ((Object.hasOwn(data, 'title') || Object.hasOwn(data, 'file')) && refs.title) {
                     refs.title.value = data.title||data.file.name;
                  }
                  if (Object.hasOwn(data, 'image-caption') && refs.description) {
                     refs.description.value = data['image-caption'];
                  }
               }
            }
            el.draggable = el.dataset.mode !== 'single';
            if (manyRefs.inputs) {
               for (let input of manyRefs.inputs) {
                  let wrapper = input.closest('[data-field]')??input.closest('.radio-button')??el;
                  window.prefixInput(input, `${data.id??data.uploadId}-`, wrapper);
               }
            }
         }
      });
      T.define('imageGroup', {
         refs: {
            selectAll: '[data-select-all]',
            fields: '.fields',
            details: 'details',
            grid: '.item-grid',
         },
         setup({el, refs, manyRefs, data}) {
            el.dataset.groupId = data.groupId;
            if (refs.selectAll) {
               let wrapper = refs.selectAll.closest('.field');
               window.prefixInput(refs.selectAll, `select-all-${data.groupId}`, wrapper,true);
            }
            let fields = T.create('groupMetadata', {groupId: data.groupId});
            if (fields) {
               refs.fields.append(fields);
            } else {
               refs.details.remove();
            }
            if (refs.grid) {
               refs.grid.dataset.groupId = data.groupId;
            }
         }
      });
      T.define('groupMetadata', {
         manyRefs: {
            inputs: 'input,textarea,select'
         },
         setup({el, refs, manyRefs, data}) {
            if (manyRefs.inputs) {
               manyRefs.inputs.forEach(input => {
                  let wrapper = input.closest('[data-field]');
                  input.dataset.groupId = data.groupId;
                  window.prefixInput(input, `${data.groupId}-`, wrapper);
               });
            }
         }
      });
      T.define('restoreNotification', {
         refs: {
            details: '.details',
            wrap: '.wrap',
         },
         setup({el, refs, manyRefs, data}) {
            if (refs.details) {
               let source = data.bySource.size > 1 ? ` across ${data.bySource.size} pages` : '';
               let upload = data.pendingUploads.length > 1 ? 'uploads' : 'upload';
               refs.details.textContent = `${data.pendingUploads.length} ${upload} can be recovered${source}`;
            }
            if (!refs.wrap) {
               console.warn('No wrap element in template');
               return;
            }
            let i = 1;
            for (const [src, uploads] of data.bySource) {
               let data = {
                  index: i,
                  isCurrent: src === window.location.href,
                  src: src,
                  uploads: uploads
               };
               refs.wrap.append(T.create('restoreField', data));
               i++;
            }
         }
      });
      T.define('restoreField', {
         refs: {
            h3: 'h3',
            a: 'h3 a',
            grid: '.item-grid'
         },
         async setup({el, refs, manyRefs, data}) {
            let fieldId = images.registerField(el, false, false, `recovery_${data.index}`);
            if (data.isCurrent) {
               el.open = true;
               refs.a?.remove();
               if (refs.h3) {
                  refs.h3.textContent = 'From this page:';
               }
            } else {
               if (refs.a) {
                  refs.a.href = data.src;
                  refs.a.title = 'Navigate to page and restore';
                  refs.a.textContent = data.src;
               }
            }
            let filtered = [... new Set(data.uploads.map(upload => upload.group??'preview'))];
            for (let groupId of filtered) {
               let group = (groupId === 'preview') ? true : images.stores.groups.get(groupId);
               if (!group) continue;
               let element = await images.createGroupElement(groupId, fieldId);
               let groupGrid = element.querySelector('.item-grid');
               let groupUploads = data.uploads.filter(upload => upload.group === (groupId === 'preview') ? null : groupId);
               for (const [key,  value] of Object.entries(group.fields??{})) {
                  let field = element.querySelector(`input[name*="${key}"]`);
                  if (field) field.value = value;
               }
               for (let upload of groupUploads) {
                  let item = await images.createUpload(upload.id, images.formatFile(upload), fieldId);
                  groupGrid.append(item);
               }
               refs.grid.append(element);
            }
         }
      });
   }
   initStores() {
      const {uploads, groups} = window.jvbStore.register(
         'uploads',
         [
            {
               storeName: 'fields',
               keyPath: 'id',
               indexes: [
                  { name: 'fieldId', keyPath: 'fieldId' },
                  { name: 'timestamp', keyPath: 'timestamp' },
                  { name: 'content', keyPath: 'content' },
                  { name: 'itemId', keyPath: 'itemId' },
                  { name: 'status', keyPath: 'status' }
               ],
               TTL: 7 * 24 * 60 * 60 * 1000, // 1 week
               delayFetch: true
            },
            {
               storeName: 'uploads',
               keyPath: 'id',
               storeBlobs: true,
               indexes: [
                  { name: 'fieldId', keyPath: 'fieldId' },
                  { name: 'field', keyPath: 'field' },
                  { name: 'status', keyPath: 'status' },
                  { name: 'groupId', keyPath: 'groupId' },
                  { name: 'attachmentId', keyPath: 'attachmentId' }
                  { name: 'group', keyPath: 'group' },
                  { name: 'src', keyPath: 'src' },
               ],
               delayFetch: true
            },
            {
               storeName: 'groups',
               keyPath: 'id',
               indexes: [
                  { name: 'field', keyPath: 'field' },
                  { name: 'src', keyPath: 'src' }
               ]
            }
         ]
      );
      this.fieldStore = fields;
      this.uploadStore = uploads;
      window.jvbUploadBlobs = this.uploadStore;
      this.stores = {
         uploads: uploads,
         groups: groups,
         ready: []
      };
      // Subscribe to store events
      this.fieldStore.subscribe(this.handleFieldStoreEvent.bind(this));
      this.uploadStore.subscribe(this.handleUploadStoreEvent.bind(this));
      this.stores.uploads.subscribe(this.handleStores.bind(this, 'uploads'));
      this.stores.groups.subscribe(this.handleStores.bind(this, 'groups'));
      this.queue.subscribe((event, operation) => {
         if ((event === 'operation-status' || event === 'cancel-operation')
            && ['image_upload', 'video_upload', 'document_upload'].includes(operation.type)) {
            let uploadIds = [];
      // RUNTIME DATA - DOM references and ephemeral state
      this.uploadElements = new Map();  // uploadId → { element, preview, location }
      this.fieldElements = new Map();   // fieldId → { element, ui, config }
      this.groupElements = new Map();   // groupId → { element, grid, fieldId }
            if (operation.data) {
               // Handle FormData
               if (operation.data instanceof FormData) {
                  const dataObj = this.stores.uploads.formDataToObject(operation.data);
                  uploadIds = dataObj['upload_ids'] || [];
               }
               // Handle regular object
               else {
                  uploadIds = operation.data['upload_ids'] || [];
               }
            }
      // Selection and UI state
      this.selected = new Map();
      this.selectionHandlers = new Map();
      this.previewUrls = new Set();
      this.sortableInstances = new Map();
            // If not in data, check result (for completed operations from backend)
            if (uploadIds.length === 0 && operation.result && operation.result.upload_ids) {
               uploadIds = operation.result.upload_ids;
            }
      // Worker for image processing
      this.initWorker();
            // Still no upload_ids? Log warning and bail
            if (!uploadIds || uploadIds.length === 0) {
               console.warn('[UploadManager] No upload_ids found for operation:', {
                  id: operation.id,
                  type: operation.type,
                  status: operation.status,
                  hasData: !!operation.data,
                  hasResult: !!operation.result
               });
               return;
            }
      // Notification subscribers
      this.subscribers = new Set();
            // Handle cancellation
            if (event === 'cancel-operation') {
               return this.handleOperationCancelled(uploadIds);
            }
      // Selectors
            // Update upload status based on operation status
            this.setBulkUpload(uploadIds, 'status', operation.status).then(() => {
               // Log for debugging
               console.log(`[UploadManager] Updated ${uploadIds.length} uploads to status: ${operation.status}`);
            });
            // Handle completion
            if (operation.status === 'completed') {
               // For group uploads, mark as processed but keep for reference
               if (operation.type === 'process_upload_groups') {
                  uploadIds.forEach(uploadId => {
                     this.setBulkUpload([uploadId], 'serverProcessed', true).then(() => {});
                  });
                  // Log created posts if available
                  if (operation.result && operation.result.created_posts) {
                     console.log('[UploadManager] Created posts:', operation.result.created_posts);
                  }
                  // Remove uploads after a delay to allow UI to update
                  setTimeout(() => {
                     uploadIds.forEach(uploadId => {
                        this.removeUpload(uploadId).then(() => {});
                     });
                  }, 2000);
               }
               // For direct uploads, remove immediately
               else {
                  uploadIds.forEach(uploadId => {
                     this.removeUpload(uploadId).then(() => {});
                  });
               }
            }
            // Handle failures
            if (operation.status === 'failed' || operation.status === 'failed_permanent') {
               console.error('[UploadManager] Operation failed:', {
                  id: operation.id,
                  type: operation.type,
                  uploadIds: uploadIds,
                  error: operation.error_message
               });
            }
         }
      });
   }
   storesReady() {
      return this.stores.ready.length === 2;
   }
   handleStores(storeName, event) {
      if (event === 'data-ready') {
         this.stores.ready.push(storeName);
         if (this.storesReady()) {
            this.checkRecovery().then(() => {});
         }
      }
   }
   initWorker() {
      this.worker = null;
      this.workerState = {
         worker: null,
         tasks: new Map(),
         restart: { count: 0, max: 3 },
         settings: {
            timeout: 3000,
            maxConcurrent: 3,
            restartAfterTimeout: true
         }
      };
   }
   initElements() {
      this.selectors = {
         field: {
         fields: {
            field: '[data-upload-field]',
            input: 'input[type="file"]',
            dropZone: '.file-upload-container',
            preview: '.item-grid.preview',
            progress: '.image-progress'
            dropZone: '.file-upload-wrapper',
            preview: '.preview-wrap',
            grid: '.item-grid.preview',
            progress: {
               progress: '.file-upload-container .progress',
               fill: '.file-upload-container .progress .fill',
               details: '.file-upload-container .progress .details',
               icon: '.file-upload-container .progress .icon'
            },
            selectAll: '[data-select-all]',
            actions: '.selection-actions',
            count: '.selected .info',
            hidden: 'input[type="hidden"]'
         },
         // groups = selectors that affect groups as a whole
         groups: {
            container: '.upload-group',
            grid: '.item-grid.group',
            header: '.group-header',
            container: '.group-display',
            grid: '.item-grid.groups',
            empty: '.empty-group',
            header: '.sidebar .header',
         },
         // group = selectors that affect individual groups
         group: {
            item: '.upload-group',
            actions: '.selection-actions',
            selectAll: '[name="select-all-group"]',
            actions: '.group-actions',
            count: '.selection-controls .info'
            count: '.group-header .info',
            fields: 'details .fields',
            grid: '.item-grid.group',
            total: '.group-content .group-count'
         },
         items: {
            item: '[data-upload-id]',
            checkbox: '[name*="select-item"]',
            item: '.item.upload',
            checkbox: '[name="select-item"]',
            featured: '[name="featured"]',
            details: 'details'
            image: 'img',
            details: 'details',
            progress: {
               progress: '.progress',
               fill: '.fill',
               details: '.details',
               icon: '.icon'
            }
         }
      };
   }
   initListeners() {
      this.clickHandler = this.handleClick.bind(this);
      this.changeHandler = this.handleChange.bind(this);
      this.dragEnterHandler = this.handleDragEnter.bind(this);
      this.dragLeaveHandler = this.handleDragLeave.bind(this);
      this.dragOverHandler = this.handleDragOver.bind(this);
      this.dropHandler = this.handleDrop.bind(this);
      document.addEventListener('click', this.clickHandler);
      document.addEventListener('change', this.changeHandler);
      document.addEventListener('dragenter', this.dragEnterHandler);
      document.addEventListener('dragleave', this.dragLeaveHandler);
      document.addEventListener('dragover', this.dragOverHandler);
      document.addEventListener('drop', this.dropHandler);
      window.addEventListener('beforeunload', () => {
         this.cleanupAllPreviewUrls();
      });
   }
   async setUpload(uploadId, data) {
      const defaults = {
         id: uploadId,
         attachment: null,
         group: null,
         field: null,
         src: window.location.href,
         blob: null,
         status: 'local_processing',
         operationId: null,
         fields: {}
      };
      const upload = { ...defaults, ...data };
      Object.preventExtensions(upload);
      await this.stores.uploads.save(upload);
      if (this.fields.has(upload.field)) {
         let field = this.fields.get(upload.field);
         switch (upload.status) {
            case 'local_processing':
               this.notify('upload-received', {
                  field: field.element,
                  id: upload.id
               });
         }
      }
      return upload;
   }
   /*********************************************************************
    UTILITY
   *********************************************************************/
   createPreviewUrl(file) {
      const url = URL.createObjectURL(file);
      this.previewUrls.add(url);
      return url;
   }
   revokePreviewUrl(url) {
      if (url?.startsWith('blob:')) {
         URL.revokeObjectURL(url);
         this.previewUrls.delete(url);
      }
   }
   formatFile(upload) {
      if (!upload.blob) return null;
      return new File([upload.blob], upload.fields.originalName || 'file', {
         type: upload.fields.type || upload.blob.type,
         lastModified: upload.fields.lastModified || Date.now()
      });
   }
   /*********************************************************************
    LISTENERS
   *********************************************************************/
   handleClick(e) {
      if (!window.targetCheck(e, this.selectors.fields.field)) return;
      //Open the file input if it's a dropzone
      let dropZone = window.targetCheck(e, this.selectors.fields.dropZone);
      if (dropZone && !e.target.matches('input, button, a')){
         dropZone.querySelector(this.selectors.fields.input)?.click();
      }
      //Handle action buttons
      const button = window.targetCheck(e, '[data-action]');
      if (button) this.handleAction(button);
   }
      handleAction(button) {
         const action = button.dataset.action;
         const fieldId = this.getFieldIdFromElement(button);
         switch (action) {
            case 'add-to-group':
               this.handleAddToGroup(fieldId).then(()=>{});
               break;
            case 'delete-group':
               this.handleDeleteGroup(button);
               break;
            case 'delete-upload':
            case 'remove-from-group':
               this.handleRemoveItem(button).then(()=>{});
               break;
            case 'upload':
               this.queueUploads('uploads/groups',fieldId).then(()=>{});
               break;
            case 'restore':
               this.handleRestoreSelected().then(()=>{});
               break;
            case 'restore-all':
               this.handleRestoreAll().then(()=>{});
               break;
            case 'clear-cache':
               this.handleClearCache().then(()=>{});
               break;
         }
      }
   handleChange(e) {
      let fieldId = this.getFieldIdFromElement(e.target);
      if (!fieldId) {
         let isMeta = e.target.closest('[data-upload-id], [data-attachment-id]');
         if (isMeta) {
            this.queueUploadMeta(e);
         }
         return;
      }
      if (e.target.matches(this.selectors.fields.input)) {
         const files = Array.from(e.target.files);
         if (files.length > 0) this.processFiles(fieldId, files).then(()=>{});
         return;
      }
      // Skip selection-related inputs
      if (e.target.matches(this.selectors.items.checkbox) ||
         e.target.matches(this.selectors.items.featured) ||
         e.target.matches('[name*="select-"]')) {
         return;
      }
      let field = this.fields.get(fieldId);
      if (field.config.destination === 'post_group') {
         this.handleGroupMetaChange(e.target);
      } else {
         this.queueUploadMeta(e);
      }
   }
   handleGroupMetaChange(input) {
      // Get the groupId directly from the input's data attribute
      const groupId = input.dataset.groupId;
      if (!groupId) return;
      // Capture values immediately
      const inputName = input.name;
      if (!inputName) return;
      const inputValue = input.value;
      // Extract the field name from the input name
      // Names are like "groupId[post_title]" or "groupId_post_title"
      const name = inputName
         .replace(`${groupId}[`, '')
         .replace(`${groupId}_`, '')
         .replace(']', '');
      // Schedule the save with captured values
      window.debouncer.schedule(`group-meta-${groupId}-${name}`, async () => {
         const group = this.stores.groups.get(groupId);
         if (!group) return;
         // Initialize fields object if it doesn't exist
         if (!group.fields) {
            group.fields = {};
         }
         group.fields[name] = inputValue;
         await this.setGroup(groupId, group);
      }, 300);
   }
   handleDragEnter(e) {
      if (!e.dataTransfer.types.includes('Files')) return;
      const dropZone = e.target.closest(this.selectors.fields.dropZone);
      if (dropZone) {
         e.preventDefault();
         dropZone.classList.add('dragover');
      }
   }
   handleDragLeave(e) {
      const dropZone = e.target.closest(this.selectors.fields.dropZone);
      if (dropZone && !dropZone.contains(e.relatedTarget)) {
         dropZone.classList.remove('dragover');
      }
   }
   handleDragOver(e) {
      if (!e.dataTransfer.types.includes('Files')) return;
      const dropZone = e.target.closest(this.selectors.fields.dropZone);
      if (dropZone) {
         e.preventDefault();
         e.dataTransfer.dropEffect = 'copy';
      }
   }
   handleDrop(e) {
      const dropZone = e.target.closest(this.selectors.fields.dropZone);
      if (!dropZone) return;
      e.preventDefault();
      dropZone.classList.remove('dragover');
      dropZone.classList.add('uploading');
      const files = Array.from(e.dataTransfer.files);
      if (files.length === 0) return;
      const fieldId = this.getFieldIdFromElement(dropZone);
      if (fieldId) {
         this.processFiles(fieldId, files).then(()=>{
            this.updateHandlerItems(fieldId);
         });
         this.a11y.announce(`${files.length} file(s) dropped for upload`);
      }
   }
   async queueUploads(endpoint, fieldId, dependsOn = null) {
      let data = new FormData();
      const field = this.fields.get(fieldId);
      if (!field) return;
      let uploads = this.stores.uploads.filterByIndex({field: fieldId});
      if (uploads.length === 0) return;
      const [ isUpload, isGroups] =
         [ endpoint === 'uploads', endpoint === 'uploads/groups'];
      data.append('fieldId', field.id);
      data.append('content', field.config.content);
      if (isUpload) {
         data.append('mode', field.config.mode);
         data.append('field_name', field.config.repeaterPath || field.config.name);
         data.append('fieldId', field.id);
         data.append('field_type', field.config.type);
         data.append('subtype', field.config.subtype);
         data.append('item_id', field.config.itemID);
         data.append('destination', field.config.destination);
         if (dependsOn) {
            data.append('depends_on', dependsOn);
         }
      }
      let posts, uploadMap, files;
      if (isGroups) {
         ({posts, uploadMap, files} = this.collectGroups(fieldId));
      } else if (isUpload) {
         ({uploadMap, files} = this.collectUploads(fieldId));
      }
      if (isGroups) {
         data.append('posts', JSON.stringify(posts));
      }
      files.forEach(file => {
         data.append('files[]', file);
      });
      data.append('upload_ids', JSON.stringify(uploadMap));
      let title, popup;
      if (isUpload) {
         title = `Uploading ${uploads.length} file${uploads.length>1?'s':''} to server...`;
         popup = `Uploading ${uploads.length} file${uploads.length>1?'s':''}...`;
      } else if (isGroups) {
         title = `Creating ${posts.length} ${field.config.content}${posts.length > 1 ? 's' : ''} from uploads...`;
         popup = `Creating ${posts.length} post${posts.length>1?'s':''}...`;
      }
      await this.setBulkUpload(uploads, 'status', 'queued');
      let operationId = this.sendToQueue(endpoint, data, title, popup);
      if (endpoint === 'uploads/groups') {
         let details = field.element.closest('details');
         if (details) {
            details.open = false;
         }
         this.notify('groups_uploaded', {
            fieldId: fieldId,
            posts: posts,
            content: field.config.content,
         });
      }
      if (operationId) {
         field.operationId = operationId;
         await this.setBulkUpload(uploads, 'operationId', operationId);
         await this.setBulkUpload(uploads, 'status', 'uploading');
         await this.setBulkGroup(fieldId, 'operationId', operationId);
         this.fields.set(field.id, field);
         this.notify('sent-to-queue', {
            field: field,
            operation: operationId,
         });
      } else {
         await this.setBulkUpload(uploads, 'status', 'failed');
      }
      return operationId;
   }
   async sendToQueue(endpoint, data, title = '', popup = '', mergable = false) {
      if (popup === '') {
         popup = title;
      }
      const operation = {
         endpoint: endpoint,
         method: 'POST',
         data: data,
         title: title,
         popup: popup,
         canMerge: mergable,
         sendNow: endpoint === 'uploads/groups',
         headers: {
            'X-Action-Nonce': window.auth.getNonce('dash')
         },
         append: '_upload'
      }
      try {
         return await this.queue.addToQueue(operation);
      } catch (error) {
         this.error.log(error, {
            component: 'UploadManager',
            action: 'sentToQueue'
         });
         return false;
      }
   }
   collectGroups(fieldId) {
      let uploads = this.stores.uploads.filterByIndex({field: fieldId});
      let groups = this.stores.groups.filterByIndex({field: fieldId});
      let posts = [];
      let uploadMap = [];
      let files = [];
      const validGroups = groups.filter(group => {
         const groupUploads = this.getGroupUploadsInOrder(group);
         return groupUploads.length > 0 && groupUploads.some(u => this.formatFile(u));
      });
      for (const group of validGroups) {
         const groupElement = this.groups.get(group.id)?.element;
         const fields = this.collectGroupFieldsFromDOM(groupElement, group.id);
         const post = {
            groupId: group.id,
            images: [],
            fields: fields
         };
         const groupUploads = this.getGroupUploadsInOrder(group);
         for (const upload of groupUploads) {
            const file = this.formatFile(upload);
            if (file) {
               files.push(file);
               const imageData = {
                  upload_id: upload.id,
                  index: uploadMap.length
               };
               const uploadEl = this.uploads.get(upload.id);
               const featuredInput = uploadEl?.element?.querySelector(`input[name="${group.id}_featured"]`);
               if (featuredInput?.checked) {
                  post.fields.featured = upload.id;
               }
               post.images.push(imageData);
               uploadMap.push(upload.id);
            }
         }
         if (post.images.length > 0) {
            posts.push(post);
         }
      }
      // Handle remaining uploads not in any group
      const remaining = uploads.filter(u => !u.group);
      for (const upload of remaining) {
         const post = {
            groupId: window.generateID('group'),
            images: [],
            fields: {}
         };
         const file = this.formatFile(upload);
         if (file) {
            files.push(file);
            const imageData = {
               upload_id: upload.id,
               index: uploadMap.length
            };
            post.images.push(imageData);
            uploadMap.push(upload.id);
         }
         if (post.images.length > 0) {
            posts.push(post);
         }
      }
      return {posts, uploadMap, files};
   }
   getGroupUploadsInOrder(group) {
      if (!group.uploads || group.uploads.length === 0) return [];
      return group.uploads
         .map(uploadId => this.stores.uploads.get(uploadId))
         .filter(Boolean); // Remove any that don't exist
   }
   collectGroupFieldsFromDOM(groupElement, groupId) {
      if (!groupElement) return {};
      const fields = {};
      const inputs = groupElement.querySelectorAll('input, textarea, select');
      inputs.forEach(input => {
         // Extract field name from input name like "groupId[post_title]"
         const name = input.name
            .replace(`${groupId}[`, '')
            .replace(`${groupId}_`, '')
            .replace(']', '');
         // Skip system fields like featured, select-all
         if (['featured', 'select-all'].some(skip => name.includes(skip))) return;
         if (input.value) {
            fields[name] = input.value;
         }
      });
      return fields;
   }
   collectUploads(fieldId) {
      let uploads = this.stores.uploads.filterByIndex({field: fieldId});
      if (uploads.length === 0) return;
      let uploadMap = [];
      let files = [];
      for (const upload of uploads) {
         const file = this.formatFile(upload);
         if (file) {
            files.push(file);
            uploadMap.push(upload.id);
         }
      }
      return { uploadMap, files };
   }
   queueUploadMeta(e) {
      let attachmentId = e.target.closest('[data-attachment-id]')?.dataset.attachmentId;
      let isUpload = false;
      if (!attachmentId) {
         attachmentId = e.target.closest('[data-upload-id]')?.dataset.uploadId;
         isUpload = true;
         if (!attachmentId) return;
      }
      if (!this.changes.has(attachmentId)) {
         let object = {};
         if (isUpload) {
            object['uploadId'] = attachmentId;
         } else {
            object['attachmentId'] = attachmentId;
         }
         this.changes.set(attachmentId, object);
      }
      let field = e.target.closest('[data-field]');
      let name = field.dataset.field;
      this.changes.get(attachmentId)[name] = e.target.value;
      this.scheduleSave();
   }
   scheduleSave() {
      window.debouncer.schedule(
         `upload-meta`,
         async () => {
            if (this.changes.size > 0) {
               let items = {};
               for (let [id, meta] of this.changes.entries()) {
                  console.log(id, meta);
                  items[id] = meta;
               }
               let data = {
                  user: window.auth.getUser(),
                  items: items
               };
               await this.sendToQueue('uploads/meta', data, 'Uploading Meta', 'Uploading Meta', true);
               this.changes.clear();
            }
         },
         2000
      );
   }
   /*********************************************************************
    FIELD LOGIC
   *********************************************************************/
   scanFields(container, autoUpload = true, imageMeta = true) {
      const fields = container.querySelectorAll(this.selectors.fields.field);
      fields.forEach(uploader => this.registerField(uploader, autoUpload, imageMeta));
   }
   registerField(element, autoUpload = true, imageMeta = true, id = null) {
      const data = {
         element: element,
         id: (id) ? id : this.determineFieldId(element),
         config: this.extractFieldConfig(element, autoUpload, imageMeta),
         uploads: new Set(),
         operationId: null,
         groups: [],
         ui: window.uiFromSelectors(this.selectors.fields, element),
         groupUI: window.uiFromSelectors(this.selectors.groups, element)
      };
      this.fields.set(data.id, data);
      element.dataset.uploader = data.id;
      this.getSelectionHandler(data.id);
      if (data.config.type !== 'single') {
         this.initSortable(data.id);
      }
      this.maybeLockUploads(data.id);
      return data.id;
   }
   extractFieldConfig(el, autoUpload, imageMeta) {
      const config = {
         autoUpload: autoUpload,
         showMeta: imageMeta,
         destination: el.dataset.destination || 'meta',
         content: this.extractFieldContent(el),
         mode: el.dataset.mode || 'direct',
         type: el.dataset.type || 'single',
         name: el.dataset.field,
         itemID: this.extractFieldItemId(el) ?? 0,
         maxFiles: ('max-files' in el.dataset) ? parseInt(el.dataset.maxFiles) : 0,
         subType: el.dataset.subtype ?? 'image',
         repeaterPath: null
      };
      const repeaterRow = el.closest('[data-index]');
      const repeater = repeaterRow?.closest('[data-field][data-repeater-id]');
      if (repeater && repeaterRow) {
         config.repeaterPath = `${repeater.dataset.field}:${repeaterRow.dataset.index}:${config.name}`;
      }
      return config;
   }
   extractFieldContent(fieldElement) {
      return fieldElement.dataset.content ||
         fieldElement.closest('dialog')?.dataset.content ||
         fieldElement.closest('form')?.dataset.save || null;
   }
   extractFieldItemId(fieldElement) {
      return fieldElement.dataset.itemId ||
      fieldElement.closest('dialog')?.dataset.itemId || null;
   }
   determineFieldId(fieldElement) {
      let content = this.extractFieldContent(fieldElement);
      content = (content === null) ? '' : content+'_';
      let itemID = this.extractFieldItemId(fieldElement);
      itemID = (itemID === null) ? '' : itemID+'_';
      const field = fieldElement.dataset.field || '';
      // If inside a repeater row, include repeater name + index for uniqueness
      const repeaterRow = fieldElement.closest('[data-index]');
      const repeater = repeaterRow?.closest('[data-field][data-repeater-id]');
      if (repeater && repeaterRow) {
         return `${content}${itemID}${repeater.dataset.field}_${repeaterRow.dataset.index}_${field}`;
      }
      return `${content}${itemID}${field}`;
   }
   getFieldIdFromElement(el) {
      const field = el.closest(this.selectors.fields.field);
      return field?.dataset.uploader || null;
   }
   updateFieldProgress(fieldId, current, total, message) {
      const field = this.fields.get(fieldId);
      if (!field) return;
      window.showProgress(field.ui.progress,current, total, message);
   }
   /*********************************************************************
    IMAGE PROCESSING FILE PROCESSING
   *********************************************************************/
   getWorker() {
      if (!this.workerState.worker && typeof OffscreenCanvas !== 'undefined') {
         this.workerState.worker = new Worker('worker.js');
         this.workerState.worker.onmessage = (e) => this.handleWorkerMessage(e);
         this.workerState.worker.onerror = (e) => this.handleWorkerError(e);
      }
      return this.workerState.worker;
   }
   handleWorkerMessage(e) {
      const { id, blob } = e.data;
      const task = this.workerState.tasks.get(id);
      if (task) {
         clearTimeout(task.timeoutId);
         task.resolve(blob);
         this.workerState.tasks.delete(id);
      }
   }
   handleWorkerError(e) {
      // Reject all pending tasks
      this.workerState.tasks.forEach(task => {
         clearTimeout(task.timeoutId);
         task.reject(e);
      });
      this.workerState.tasks.clear();
      this.restartWorker();
   }
   restartWorker() {
      if (this.workerState.worker) {
         this.workerState.worker.terminate();
         this.workerState.worker = null;
      }
      this.workerState.restart.count++;
   }
   async processImages(files, maxWidth = 2200, maxHeight = 2200){
      const results = [];
      const queue = [...files];
      const concurrency = this.workerState.settings.maxConcurrent;
      const processNext = async () => {
         while (queue.length > 0) {
            const entry = queue.shift();
            const blob = await this.processImage(entry.file, maxWidth, maxHeight);
            results.push({ uploadId: entry.uploadId, blob: blob });
         }
      };
      this.statusMapping = {
      await Promise.all(
         Array.from({length: Math.min(concurrency, files.length)}, () => processNext())
      );
      return results;
   }
   async processImage(file, maxWidth = 2200, maxHeight = 2200, timeout = 3000){
      if (typeof OffscreenCanvas=== 'undefined') {
         return this.resizeImage(file,maxWidth,maxHeight);
      }
      try {
         return await this.withTimeout(
            this.workerImage(file, maxWidth, maxHeight),
            timeout
         );
      } catch (e) {
         return this.resizeImage(file, maxWidth, maxHeight);
      }
   }
   withTimeout(promise, ms) {
      return Promise.race([
         promise,
         new Promise((_, reject) =>
            setTimeout(() => reject(new Error('Timeout')), ms)
         )
      ]);
   }
   async workerImage(file, maxWidth = 2200, maxHeight = 2200) {
      const { settings, restart } = this.workerState;
      if (restart.count >= restart.max) {
         throw new Error('Worker max restarts exceeded');
      }
      const bitmap = await createImageBitmap(file);
      let { width, height } = bitmap;
      if (width > maxWidth || height > maxHeight) {
         const ratio = Math.min(maxWidth / width, maxHeight / height);
         width = Math.round(width * ratio);
         height = Math.round(height * ratio);
      }
      const worker = this.getWorker();
      const id = crypto.randomUUID();
      return new Promise((resolve, reject) => {
         const timeoutId = setTimeout(() => {
            this.workerState.tasks.delete(id);
            if (settings.restartAfterTimeout) {
               this.restartWorker();
            }
            reject(new Error('Timeout'));
         }, settings.timeout);
         this.workerState.tasks.set(id, { resolve, reject, timeoutId });
         worker.postMessage(
            { id, imageBitmap: bitmap, width, height, type: file.type, quality: 0.9 },
            [bitmap]
         );
      });
   }
   resizeImage(file, maxWidth, maxHeight) {
      return new Promise((resolve) => {
         const img = new Image();
         img.onload = () => {
            URL.revokeObjectURL(img.src);
            // Calculate new dimensions keeping aspect ratio
            let { width, height } = img;
            if (width > maxWidth || height > maxHeight) {
               const ratio = Math.min(maxWidth / width, maxHeight / height);
               width = Math.round(width * ratio);
               height = Math.round(height * ratio);
            }
            // Draw to canvas at new size
            const canvas = document.createElement('canvas');
            canvas.width = width;
            canvas.height = height;
            canvas.getContext('2d').drawImage(img, 0, 0, width, height);
            // Export as blob for upload
            canvas.toBlob(resolve, file.type, 0.9);
         };
         img.src = URL.createObjectURL(file);
      });
   }
   async processFiles(fieldId, files) {
      let field = this.fields.get(fieldId);
      if (!field) return;
      if (field.groupUI.container) {
         field.groupUI.container.hidden = false;
      }
      const totalFiles = files.length;
      let processed = 0;
      this.updateFieldProgress(fieldId, 0, totalFiles, 'Processing files...');
      // Create upload records for all files first
      const uploadEntries = await Promise.all(
         files.map(async (file) => {
            const uploadId = window.generateID('upload');
            const upload = await this.setUpload(uploadId, {
               id: uploadId,
               field: fieldId,
               status: 'local_processing',
               // blob: null,
               fields: {
                  originalName: file.name,
                  originalSize: file.size,
                  type: file.type,
                  lastModified: file.lastModified
               }
            });
            const element = await this.createUpload(uploadId, file, fieldId);
            this.uploads.set(uploadId, {
               element: element,
               ui: window.uiFromSelectors(this.selectors.items, element)
            });
            await this.addToGroup(uploadId, null);
            return { uploadId, upload, file };
         })
      );
      // Batch process images with concurrency control
      const imageEntries = uploadEntries.filter(e => e.file.type.startsWith('image/'));
      const otherEntries = uploadEntries.filter(e => !e.file.type.startsWith('image/'));
      // Process images in batches
      const processedImages = await this.processImages(
         imageEntries.map(e => ({ file: e.file, uploadId: e.uploadId }))
      );
      // Update image uploads with processed blobs
      for (const { uploadId, blob } of processedImages) {
         const entry = imageEntries.find(e => e.uploadId === uploadId);
         if (entry) {
            entry.upload.blob = blob;
            entry.upload.fields.size = blob.size;
            entry.upload.status = 'queued';
            await this.setUpload(uploadId, entry.upload);
            processed++;
            this.updateFieldProgress(fieldId, processed, totalFiles, 'Processing files...');
         }
      }
      // Handle non-image files (no processing needed)
      for (const { uploadId, upload, file } of otherEntries) {
         upload.blob = file;
         upload.status = 'queued';
         await this.setUpload(uploadId, upload);
         processed++;
         this.updateFieldProgress(fieldId, processed, totalFiles, 'Processing files...');
      }
      this.maybeLockUploads(fieldId);
      if (field.config.autoUpload && field.config.destination !== 'post_group') {
         await this.queueUploads('uploads', fieldId);
      }
   }
   /*************************************************************
    RECOVERY
   *************************************************************/
   async checkRecovery() {
      const allGroups = Array.from(this.stores.groups.data.values());
      for (const group of allGroups) {
         const hasUploads = this.stores.uploads.filterByIndex({ group: group.id }).length > 0;
         if (!hasUploads) await this.stores.groups.delete(group.id);
      }
   }
   //TODO: Old method of checkRecovery. All recovery logic has moved to the FormController.js
   // async checkRecovery() {
   //    const pendingUploads = this.stores.uploads.filterByIndex({status: ['local_processing', 'queued', 'uploading']});
   //    const allGroups = Array.from(this.stores.groups.data.values());
   //    for (const group of allGroups) {
   //       const hasUploads = this.stores.uploads.filterByIndex({group: group.id}).length > 0;
   //       if (!hasUploads) {
   //          await this.stores.groups.delete(group.id);
   //       }
   //    }
   //    if (pendingUploads.length === 0) return;
   //
   //    // Group by source page
   //    const bySource = new Map();
   //    pendingUploads.forEach(upload => {
   //       const src = upload.src || 'unknown';
   //       if (!bySource.has(src)) bySource.set(src, []);
   //       bySource.get(src).push(upload);
   //    });
   //
   //    let data = {
   //       bySource: bySource,
   //       pendingUploads: pendingUploads
   //    };
   //
   //    document.body.append(this.templates.create('restoreNotification', data));
   //    let notification = document.querySelector('dialog.restore-uploads');
   //    this.restoreModal = new window.jvbModal(notification);
   //    this.restoreSelection = new window.jvbHandleSelection(notification,
   //       {
   //          wrapper: {
   //             wrapper: '.restore-field',
   //             id: 'selection'
   //          },
   //          items: '.item-grid.restore',
   //          selectAll: {
   //             bulkControls: '.selection-actions',
   //             checkbox: '#select-all-restore',
   //             count: '.selection-count'
   //          }
   //    });
   //    this.restoreModal.handleOpen();
   // }
   // async handleRestoreSelected() {
   //    if (!this.restoreSelection) return;
   //
   //    let selected = Array.from(this.restoreSelection.selectedItems);
   //    if (selected.length === 0) {
   //       return;
   //    }
   //
   //    await this.restoreSelectedUploads(selected);
   // }
   // async handleRestoreAll() {
   //    if (!this.restoreModal) return;
   //    const allUploads = Array.from(this.restoreModal.modal.querySelectorAll('.item.upload')).map(item => item.dataset.uploadId);
   //
   //    await this.restoreSelectedUploads(allUploads);
   // }
   //
   async restoreSelectedUploads(selectedUploads) {
      let currentPage = window.location.href;
      let uploads = Array.from(this.stores.uploads.data.values()).filter(
         upload => selectedUploads.includes(upload.id) && upload.src === currentPage
      );
      let groups = [... new Set(uploads.map(upload => upload.group))].filter(Boolean);
      let fieldId = uploads[0].field;
      let field = document.querySelector(`[data-uploader="${fieldId}"]`);
      if (!field) {
         if ('crudManager' in window && fieldId.startsWith(window.crudManager.content)) {
            let [content, itemId, fieldName] = fieldId.split('_');
            if (parseInt(itemId) > 0) {
               window.crudManager.openEditModal(itemId);
               field = document.querySelector(`[data-uploader="${fieldId}"]`);
            } else {
               console.log('No field found for '+fieldId);
               return;
            }
         } else {
            console.log('No field found for '+fieldId);
            return;
         }
      }
      let fieldData = this.fields.get(fieldId);
      if (fieldData.groupUI.container) {
         fieldData.groupUI.container.hidden = false;
      }
      let usedIds = [];
      for (let gr of groups) {
         let group = this.stores.groups.get(gr);
         await this.createGroup(fieldId, gr);
         let element = this.groups.get(gr);
         let theseUploads = uploads.filter(upload => upload.group === gr);
         if (group && this.groups.has(gr)) {
            let fields = group.fields;
            for (const [key, value] of Object.entries(fields)) {
               let fi = element.element.querySelector(`input[name*="${key}"]`);
               if (fi) {
                  fi.value = value;
               }
            }
         }else {
            //Couldn't restore the group for some reason, just add it to the main preview grid instead
            gr = null;
         }
         for (let upload of theseUploads) {
            let item = await this.createUpload(upload.id, this.formatFile(upload), fieldId);
            this.uploads.set(upload.id, {
               element: item,
               ui: window.uiFromSelectors(this.selectors.items, item)
            });
            await this.addToGroup(upload.id, gr);
            usedIds.push(upload.id);
         }
      }
      let remaining = uploads.filter(upload => !usedIds.includes(upload.id));
      for (let upload of remaining) {
         let item = await this.createUpload(upload.id, this.formatFile(upload), fieldId);
         this.uploads.set(upload.id, {
            element: item,
            ui: window.uiFromSelectors(this.selectors.items, item)
         });
         await this.addToGroup(upload.id, null);
      }
   }
   //
   // cleanupRestore() {
   //    this.restoreModal.handleClose();
   //    this.restoreSelection.destroy();
   //    this.restoreSelection = null;
   //    this.restoreModal.destroy();
   //    this.restoreModal.modal.remove();
   //    this.restoreModal = null;
   // }
   async restoreUploads(uploadIds) {
      const uploads = uploadIds.map(id => this.stores.uploads.get(id)).filter(Boolean);
      if (uploads.length === 0) return;
      await this.restoreSelectedUploads(uploads.map(u => u.id));
   }
   async clearUploads(uploadIds) {
      await Promise.all(uploadIds.map(id => this.clearUpload(id)));
   }
   /*******************************************************************************
    STATUS MANAGEMENT
   *******************************************************************************/
   getStatusText(status) {
      let map = {
         'received': 'Image Received',
         'local_processing': 'Processing Image...',
         'queued': 'Waiting to upload...',
@@ -108,2520 +1476,10 @@
         'failed_permanent': 'Upload failed permanently'
      };
      this.init();
   }
   async init() {
      this.initializeFields();
      this.initListeners();
      // Queue integration - handle completion/failure
      this.queue.subscribe((event, operation) => {
         if (!['uploads', 'uploads/meta', 'uploads/groups'].includes(operation.endpoint)) {
            return;
         }
         const fieldId = operation.data instanceof FormData
            ? operation.data.get('fieldId')
            : operation.data?.fieldId;
         switch(event) {
            case 'cancel-operation':
               if (fieldId) this.handleOperationCancelled(fieldId);
               break;
            case 'operation-status':
               if (fieldId) this.updateFieldStatus(fieldId, operation.status);
               break;
            case 'operation-complete':
               this.handleOperationComplete(operation, fieldId);
               break;
            case 'operation-failed':
            case 'operation-failed-permanent':
               this.handleOperationFailed(operation, fieldId);
               break;
         }
      });
      window.addEventListener('beforeunload', () => {
         this.cleanupAllPreviewUrls();
      });
   }
   initWorker() {
      this.worker = {
         worker: null,
         timeout: null,
         tasks: new Map(),
         restart: { count: 0, max: 3 },
         settings: {
            timeout: 10000,
            batchSize: 1,
            maxConcurrent: 3,
            restartAfterTimeout: true
         }
      };
   }
   /*******************************************************************************
    * FIELD MANAGEMENT
    *******************************************************************************/
   initializeFields() {
      const fields = document.querySelectorAll(this.selectors.field.field);
      fields.forEach(uploader => this.registerUploader(uploader));
   }
   scanFields(container) {
      const fields = container.querySelectorAll(this.selectors.field.field);
      fields.forEach(uploader => this.registerUploader(uploader));
   }
   registerUploader(uploader) {
      const fieldId = this.determineFieldId(uploader);
      const config = this.extractFieldConfig(uploader);
      const ui = this.buildFieldUI(uploader);
      // Store field data with Sets for runtime
      const fieldData = {
         id: fieldId,
         config: config,
         uploads: new Set(),
         groups: [],
         state: 'ready',
         timestamp: Date.now()
      };
      // Save to store (will convert Sets to Arrays automatically)
      this.fieldStore.save(fieldData);
      // Store DOM references separately
      this.fieldElements.set(fieldId, { element: uploader, ui, config });
      uploader.dataset.uploader = fieldId;
      this.addFieldSelectionHandler(fieldId);
      if (config.type !== 'single') {
         this.initSortable(fieldId);
      }
      return fieldId;
   }
   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,
         itemID: fieldElement.dataset.itemId || 0,
         maxFiles: parseInt(fieldElement.dataset.maxFiles) || 999,
         subtype: fieldElement.dataset.subtype || 'image'
      };
   }
   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;
   }
   /*******************************************************************************
    * SORTABLE INITIALIZATION
    *******************************************************************************/
   initSortable(fieldId) {
      if (!window.Sortable) return;
      // Mount MultiDrag plugin once
      if (!Sortable._multiDragMounted && Sortable.MultiDrag) {
         Sortable.mount(new Sortable.MultiDrag());
         Sortable._multiDragMounted = true;
      }
      const fieldEl = this.fieldElements.get(fieldId);
      if (!fieldEl) return;
      // Initialize sortable on all existing grids
      const grids = fieldEl.element.querySelectorAll('.item-grid.preview, .item-grid.group');
      grids.forEach(grid => {
         const groupId = grid.classList.contains('group')
            ? grid.closest('.upload-group')?.dataset.groupId
            : null;
         this.createSortableForGrid(grid, fieldId, groupId);
      });
      // Special handler for empty-group
      const emptyGroup = fieldEl.element.querySelector('.empty-group');
      if (emptyGroup && !emptyGroup.sortableInstance) {
         emptyGroup.sortableInstance = new Sortable(emptyGroup, {
            animation: 150,
            draggable: '.item',
            multiDrag: true,
            selectedClass: 'selected-for-drag',
            avoidImplicitDeselect: true,
            group: { name: fieldId, pull: false, put: true },
            ghostClass: 'sortable-ghost',
            chosenClass: 'sortable-chosen',
            dragClass: 'sortable-drag',
            onEnd: (evt) => this.handleDrop(evt, fieldId)
         });
      }
   }
   syncSortableSelection(fieldId, selectedItems) {
      // Update Sortable's selection state to match checkboxes
      this.sortableInstances.forEach((instance, key) => {
         if (key.startsWith(fieldId)) {
            const grid = instance.el;
            const items = grid.querySelectorAll('.item');
            items.forEach(item => {
               const uploadId = item.dataset.uploadId;
               const shouldBeSelected = selectedItems.has(uploadId);
               if (shouldBeSelected) {
                  Sortable.utils.select(item);
               } else {
                  Sortable.utils.deselect(item);
               }
            });
         }
      });
   }
   handleDrop(evt, fieldId) {
      const dropTarget = evt.to;
      const sourceTarget = evt.from;
      const items = evt.items?.length > 0 ? evt.items : [evt.item];
      const uploadIds = items.map(item => item.dataset.uploadId);
      // Determine drop target type
      const targetType = this.getDropTargetType(dropTarget);
      switch (targetType) {
         case 'empty-group':
            this.handleDropToEmptyGroup(items, uploadIds, fieldId);
            break;
         case 'preview':
            this.handleDropToPreview(items, uploadIds, fieldId);
            break;
         case 'group':
            this.handleDropToGroup(items, uploadIds, dropTarget, sourceTarget, fieldId);
            break;
         default:
            // Fallback: return to preview
            this.handleDropToPreview(items, uploadIds, fieldId);
            break;
      }
      // Update UI
      this.updateSortableState(dropTarget);
      if (sourceTarget !== dropTarget) {
         this.updateSortableState(sourceTarget);
      }
   }
   /**
    * Determine what type of drop target this is
    */
   getDropTargetType(target) {
      if (target.classList.contains('empty-group')) {
         return 'empty-group';
      }
      if (target.classList.contains('preview')) {
         return 'preview';
      }
      if (target.classList.contains('group')) {
         return 'group';
      }
      return 'unknown';
   }
   /**
    * Handle drop to group: add to existing group
    */
   handleDropToGroup(items, uploadIds, dropTarget, sourceTarget, fieldId) {
      try {
         // If same container, it's just a reorder
         if (dropTarget === sourceTarget) {
            this.handleReorder({ to: dropTarget, items: items });
            return;
         }
         // Moving to different group
         uploadIds.forEach(uploadId => {
            this.addToGroup(uploadId, dropTarget, false);
         });
         this.schedulePersistance(fieldId);
         const message = items.length > 1
            ? `Moved ${items.length} items to group`
            : 'Moved item to group';
         this.a11y.announce(message);
         // Clear selection
         const handler = this.selectionHandlers.get(fieldId);
         handler?.clearSelection();
      }  catch (error) {
         this.handleDropError(items, fieldId, error);
      }
   }
   /**
    * Handle drop to preview: remove from groups
    */
   handleDropToPreview(items, uploadIds, fieldId) {
      try {
         uploadIds.forEach(uploadId => {
            this.removeFromGroup(uploadId);
         });
         this.schedulePersistance(fieldId);
         const message = items.length > 1
            ? `Moved ${items.length} items to preview`
            : 'Moved item to preview';
         this.a11y.announce(message);
         // Clear selection
         const handler = this.selectionHandlers.get(fieldId);
         handler?.clearSelection();
      } catch (error) {
         this.handleDropError(items, fieldId, error);
      }
   }
   /**
    * Handle drop errors consistently
    */
   handleDropError(items, fieldId, error, message = 'An error occurred') {
      console.error('Drop error:', error);
      // Return items to preview as fallback
      const fieldEl = this.fieldElements.get(fieldId);
      if (fieldEl?.ui?.preview) {
         items.forEach(item => fieldEl.ui.preview.appendChild(item));
      }
      this.a11y.announce(`${message}. Items returned to preview.`);
   }
   /**
    * Handle drop to group: add to existing group
    */
   handleDropToEmptyGroup(items, uploadIds, fieldId) {
      try {
         const group = this.createGroup(fieldId);
         if (!group) {
            this.handleDropError(items, fieldId, new Error('Group creation failed'), 'Failed to create group');
            return;
         }
         // Move items to new group
         items.forEach((item, index) => {
            group.grid.appendChild(item);
            this.addToGroup(uploadIds[index], group.grid, false);
         });
         this.schedulePersistance(fieldId);
         const message = items.length > 1
            ? `Created group with ${items.length} items`
            : 'Created group with item';
         this.a11y.announce(message);
         // Clear selection after move
         const handler = this.selectionHandlers.get(fieldId);
         handler?.clearSelection();
      } catch (error) {
         this.handleDropError(items, fieldId, error);
      }
   }
   /**
    * Update sortable enabled/disabled state based on item count
    */
   updateSortableState(grid) {
      const sortable = grid?.sortableInstance;
      if (!sortable) return;
      // const hasItems = grid.querySelectorAll('.item').length > 0;
      sortable.option('disabled', false);
   }
   /**
    * Refresh sortable for a field (call after adding/removing items dynamically)
    */
   refreshSortable(fieldId) {
      const fieldEl = this.fieldElements.get(fieldId);
      if (!fieldEl) return;
      const grids = fieldEl.element.querySelectorAll('.item-grid.preview, .item-grid.group');
      grids.forEach(grid => this.updateSortableState(grid));
   }
   handleReorder(evt) {
      const grid = evt.to;
      const fieldWrapper = grid.closest('.field, .upload');
      if (!fieldWrapper) return;
      const movedItems = evt.items && evt.items.length > 0 ? evt.items : [evt.item];
      // Get current order from DOM
      let items = Array.from(grid.querySelectorAll('.item:not(.sortable-ghost):not(.sortable-clone)'))
         .map(upload => upload.dataset.uploadId)
         .filter(id => id);
      // Update hidden input (for form submission)
      let hiddenInput = fieldWrapper.querySelector('input[type="hidden"]');
      if (hiddenInput && items.length > 0) {
         hiddenInput.value = items.join(',');
      }
      // Update fieldState with new order
      const fieldId = this.getFieldIdFromElement(grid);
      if (fieldId) {
         const fieldData = this.getFieldData(fieldId);
         // If reordering within a group, update that group's uploads array
         if (grid.classList.contains('group')) {
            const groupId = grid.dataset.groupId;
            const group = fieldData?.groups?.find(g => g.id === groupId);
            if (group) {
               group.uploads = items; // Update order
            }
         }
         // If reordering in preview, the order is implicit by DOM position
         // (we don't store preview order separately)
         this.schedulePersistance(fieldId);
      }
      this.a11y.announce('Item reordered');
      fieldWrapper.dispatchEvent(new CustomEvent('jvb-items-reordered', {
         detail: {
            from: evt.from,
            to: evt.to,
            oldIndex: evt.oldIndex,
            newIndex: evt.newIndex,
            items: items
         },
         bubbles: true
      }));
   }
   /*******************************************************************************
    * FILE DROP HANDLERS
    *******************************************************************************/
   initListeners() {
      this.clickHandler = this.handleClick.bind(this);
      this.changeHandler = this.handleChange.bind(this);
      document.addEventListener('click', this.clickHandler);
      document.addEventListener('change', this.changeHandler);
      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);
   }
   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;
      const dropZone = e.target.closest(this.selectors.field.dropZone);
      if (dropZone) {
         e.preventDefault();
         dropZone.classList.add('dragover');
      }
   }
   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';
      }
   }
   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`);
      }
   }
   /*******************************************************************************
    * CLICK & CHANGE HANDLERS
    *******************************************************************************/
   handleClick(e) {
      // Trigger file input
      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();
         }
      }
      // 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 files = Array.from(e.target.files);
         if (files.length > 0 && fieldId) {
            this.processFiles(fieldId, files);
         }
      }
      // Meta field changes
      if (fieldId) {
         const fieldData = this.getFieldData(fieldId);
         if (fieldData?.config.destination === 'post_group') {
            this.handleGroupMetaChange(e.target);
         } else {
            this.queueUploadMeta(e);
         }
      }
   }
   /*******************************************************************************
    * FILE PROCESSING
    *******************************************************************************/
   async processFiles(fieldId, files) {
      const fieldData = this.getFieldData(fieldId);
      const fieldEl = this.fieldElements.get(fieldId);
      if (!fieldData || !fieldEl) return;
      // Show group display, hide upload zone
      if (fieldEl.ui.dropZone) {
         fieldEl.ui.dropZone.hidden = true;
      }
      if (fieldEl.ui.groups?.display) {
         fieldEl.ui.groups.display.hidden = false;
      }
      const totalFiles = files.length;
      let processedCount = 0;
      this.updateUploadProgress(fieldId, 0, totalFiles, 'Processing files...');
      const processPromises = Array.from(files).map(async (file) => {
         try {
            const uploadId = `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
            // Create initial upload data
            const uploadData = {
               id: uploadId,
               attachmentId: null,
               fieldId: fieldId,
               status: 'local_processing',
               groupId: null,
               meta: {
                  originalName: file.name,
                  size: file.size,
                  type: file.type
               }
            };
            // Save initial data
            await this.uploadStore.save(uploadData);
            // Process file
            const preview = this.createPreviewUrl(file);
            const processedFile = file.type.startsWith('image/')
               ? await this.processImage(file, fieldData.config.subtype)
               : file;
            // Show progress
            this.showUploadProgress(uploadId, true);
            this.updateUploadItemProgress(uploadId, 50, 'local_processing');
            // Store blob data (this updates the existing uploadData)
            await this.saveBlobData(uploadId, processedFile || file);
            // Create DOM element
            const subtype = this.getSubtypeFromMime(file.type);
            const element = this.createUploadElement({
               id: uploadId,
               preview: preview,
               meta: uploadData.meta,
               subtype: subtype
            }, fieldData.config.destination === 'post_group');
            // Add to preview grid
            if (fieldEl.ui.preview) {
               fieldEl.ui.preview.appendChild(element);
               // Store runtime element data
               this.uploadElements.set(uploadId, {
                  element: element,
                  preview: preview,
                  location: fieldEl.ui.preview
               });
            }
            // Update status (gets existing data with blobData intact)
            const storedUpload = this.uploadStore.get(uploadId);
            if (storedUpload) {
               storedUpload.status = 'processed';
               await this.uploadStore.save(storedUpload);
            }
            // Add to field
            fieldData.uploads.add(uploadId);
            await this.saveFieldData(fieldData);
            // Update progress
            processedCount++;
            this.updateUploadProgress(fieldId, processedCount, totalFiles, 'Processing files...');
            this.updateUploadItemProgress(uploadId, 100, 'processed');
            // Fade out progress
            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;
         }
      });
      await Promise.all(processPromises);
      this.updateFieldState(fieldId);
      this.refreshSortable(fieldId);
      // Queue for upload if in direct mode
      if (fieldData.config.destination !== 'post_group') {
         await this.queueUpload(fieldId);
         this.maybeLockUploads(fieldId);
      }
   }
   /*******************************************************************************
    * IMAGE PROCESSING
    *******************************************************************************/
   async processImage(file, uploadId) {
      const timeout = this.worker.settings.timeout;
      return new Promise((resolve, reject) => {
         let timeoutId;
         let taskCompleted = false;
         timeoutId = setTimeout(() => {
            if (!taskCompleted) {
               taskCompleted = true;
               this.worker.tasks.delete(uploadId);
               if (this.worker.settings.restartAfterTimeout) {
                  this.restartCompressionWorker();
               }
               reject(new Error(`Processing timeout for ${file.name}`));
            }
         }, timeout);
         this.worker.tasks.set(uploadId, { file, timeoutId });
         this.handleProcess(file, uploadId)
            .then(result => {
               if (!taskCompleted) {
                  taskCompleted = true;
                  clearTimeout(timeoutId);
                  this.worker.tasks.delete(uploadId);
                  resolve(result);
               }
            })
            .catch(error => {
               if (!taskCompleted) {
                  taskCompleted = true;
                  clearTimeout(timeoutId);
                  this.worker.tasks.delete(uploadId);
                  reject(error);
               }
            });
      });
   }
   async handleProcess(file, uploadId) {
      if (!file.type.startsWith('image/')) {
         return file;
      }
      const maxDimension = this.getMaxDimension();
      const quality = 0.85;
      if (this.shouldUseWorker(file)) {
         try {
            if (!this.worker.worker) {
               this.initCompressionWorker();
            }
            if (this.worker.worker) {
               return await this.processWithWorker(file, uploadId, maxDimension, quality);
            }
         } catch (error) {
            console.warn('Worker processing failed, falling back to main thread:', error);
         }
      }
      return await this.processOnMainThread(file, maxDimension, quality);
   }
   async processOnMainThread(file, maxDimension, quality) {
      return new Promise((resolve, reject) => {
         const img = new Image();
         const canvas = document.createElement('canvas');
         const ctx = canvas.getContext('2d');
         let objectUrl = null;
         const cleanup = () => {
            img.onload = null;
            img.onerror = null;
            if (objectUrl) {
               URL.revokeObjectURL(objectUrl);
               objectUrl = null;
            }
            canvas.width = 1;
            canvas.height = 1;
            ctx.clearRect(0, 0, 1, 1);
         };
         img.onload = () => {
            try {
               const { width, height } = this.calculateOptimalDimensions(img, maxDimension);
               canvas.width = width;
               canvas.height = height;
               ctx.imageSmoothingEnabled = true;
               ctx.imageSmoothingQuality = 'high';
               ctx.drawImage(img, 0, 0, width, height);
               const outputFormat = this.getOptimalFormat(file);
               const outputQuality = this.getOptimalQuality(file, quality);
               canvas.toBlob(
                  (blob) => {
                     cleanup();
                     if (blob) {
                        const processedFile = new File(
                           [blob],
                           this.getProcessedFileName(file, outputFormat),
                           { type: outputFormat, lastModified: Date.now() }
                        );
                        resolve(processedFile);
                     } else {
                        reject(new Error('Canvas toBlob failed'));
                     }
                  },
                  outputFormat,
                  outputQuality
               );
            } catch (error) {
               cleanup();
               reject(new Error(`Canvas processing failed: ${error.message}`));
            }
         };
         img.onerror = () => {
            cleanup();
            reject(new Error(`Failed to load image: ${file.name}`));
         };
         try {
            objectUrl = this.createPreviewUrl(file);
            img.src = objectUrl;
         } catch (error) {
            cleanup();
            reject(new Error(`Failed to create object URL: ${error.message}`));
         }
      });
   }
   getOptimalFormat(file) {
      if (file.type === 'image/gif' || file.type === 'image/svg+xml') {
         return file.type;
      }
      return this.supportsWebP() ? 'image/webp' : 'image/jpeg';
   }
   getOptimalQuality(file, requestedQuality) {
      if (file.size < 500 * 1024) return Math.max(requestedQuality, 0.9);
      if (file.size < 2 * 1024 * 1024) return requestedQuality;
      return Math.min(requestedQuality, 0.8);
   }
   getProcessedFileName(originalFile, outputFormat) {
      const baseName = originalFile.name.replace(/\.[^/.]+$/, '');
      const extensions = {
         'image/webp': '.webp',
         'image/jpeg': '.jpg',
         'image/png': '.png',
         'image/gif': '.gif'
      };
      return baseName + (extensions[outputFormat] || '.jpg');
   }
   getMaxDimension() {
      const screenWidth = window.screen.width;
      const devicePixelRatio = window.devicePixelRatio || 1;
      if (screenWidth * devicePixelRatio > 2560) return 2400;
      if (screenWidth * devicePixelRatio > 1920) return 1920;
      return 1200;
   }
   shouldUseWorker(file) {
      return this.worker.worker &&
         file.size > 1024 * 1024 &&
         typeof OffscreenCanvas !== 'undefined';
   }
   async processWithWorker(file, uploadId, maxDimension, quality) {
      return new Promise((resolve, reject) => {
         if (!this.worker.worker) {
            reject(new Error('Worker not available'));
            return;
         }
         const messageId = `${uploadId}_${Date.now()}`;
         const messageHandler = (e) => {
            if (e.data.messageId !== messageId) return;
            this.worker.worker.removeEventListener('message', messageHandler);
            this.worker.worker.removeEventListener('error', errorHandler);
            if (e.data.success) {
               const processedFile = new File(
                  [e.data.blob],
                  this.getProcessedFileName(file, e.data.format || 'image/webp'),
                  { type: e.data.format || 'image/webp', lastModified: Date.now() }
               );
               resolve(processedFile);
            } else {
               reject(new Error(e.data.error || 'Worker processing failed'));
            }
         };
         const errorHandler = (error) => {
            this.worker.worker.removeEventListener('message', messageHandler);
            this.worker.worker.removeEventListener('error', errorHandler);
            reject(new Error(`Worker error: ${error.message}`));
         };
         this.worker.worker.addEventListener('message', messageHandler);
         this.worker.worker.addEventListener('error', errorHandler);
         this.worker.worker.postMessage({
            messageId,
            file,
            maxDimension,
            quality,
            outputFormat: this.getOptimalFormat(file)
         });
      });
   }
   restartCompressionWorker() {
      if (this.worker.worker) {
         this.worker.worker.terminate();
         this.worker.worker = null;
      }
      this.worker.tasks.clear();
      if (this.worker.restart.count >= this.worker.restart.max) {
         console.error('Max worker restarts reached, disabling worker');
         return;
      }
      this.worker.restart.count++;
      this.initCompressionWorker();
   }
   initCompressionWorker() {
      if (this.worker.worker || typeof Worker === 'undefined') return;
      try {
         const workerScript = `
            self.onmessage = async function(e) {
               const { messageId, file, maxDimension, quality, outputFormat } = e.data;
               try {
                  const bitmap = await createImageBitmap(file);
                  const scale = Math.min(maxDimension / bitmap.width, maxDimension / bitmap.height, 1);
                  const width = Math.round(bitmap.width * scale);
                  const height = Math.round(bitmap.height * scale);
                  const canvas = new OffscreenCanvas(width, height);
                  const ctx = canvas.getContext('2d');
                  ctx.imageSmoothingEnabled = true;
                  ctx.imageSmoothingQuality = 'high';
                  ctx.drawImage(bitmap, 0, 0, width, height);
                  bitmap.close();
                  const blob = await canvas.convertToBlob({ type: outputFormat, quality: quality });
                  self.postMessage({ messageId, success: true, blob: blob, format: outputFormat });
               } catch (error) {
                  self.postMessage({ messageId, success: false, error: error.message });
               }
            };
         `;
         const blob = new Blob([workerScript], { type: 'application/javascript' });
         this.worker.worker = new Worker(this.createPreviewUrl(blob));
      } catch (error) {
         console.warn('Failed to initialize compression worker:', error);
         this.worker.worker = null;
      }
   }
   calculateOptimalDimensions(img, maxDimension) {
      let { width, height } = img;
      if (width <= maxDimension && height <= maxDimension) {
         return { width, height };
      }
      const scale = Math.min(maxDimension / width, maxDimension / height);
      return {
         width: Math.round(width * scale),
         height: Math.round(height * scale)
      };
   }
   supportsWebP() {
      const canvas = document.createElement('canvas');
      return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0;
   }
   createPreviewUrl(file) {
      const url = URL.createObjectURL(file);
      if (!this.previewUrls) this.previewUrls = new Set();
      this.previewUrls.add(url);
      return url;
   }
   revokePreviewUrl(url) {
      if (url?.startsWith('blob:')) {
         URL.revokeObjectURL(url);
         this.previewUrls?.delete(url);
      }
   }
   /*******************************************************************************
    * QUEUE INTEGRATION
    *******************************************************************************/
   async submitUploads(fieldId) {
      const fieldData = this.getFieldData(fieldId);
      const fieldEl = this.fieldElements.get(fieldId);
      if (!fieldData?.uploads || fieldData.uploads.size === 0) {
         return;
      }
      let uploadIds = Array.from(fieldData.uploads);
      if (uploadIds.length === 0) {
         this.error.log('No uploads to upload', {
            component: 'UploadManager',
            action: 'submitGroupedUploads',
            fieldId: fieldId
         });
         return;
      }
      const fieldGroups = this.getFieldGroups(fieldId);
      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 = [];
      // Process each group
      for (const group of fieldGroups) {
         const post = {
            images: [],
            fields: {}
         };
         // Add group metadata
         for (let [name, value] of Object.entries(group.changes)) {
            post.fields[name] = value;
         }
         // Get uploads for this group
         const groupUploadIds = uploadIds.filter(uploadId => {
            const upload = this.uploadStore.get(uploadId);
            return upload?.groupId === group.id;
         });
         // Add files for this group
         for (const uploadId of groupUploadIds) {
            const file = await this.getBlobData(uploadId);
            if (file) {
               formData.append('files[]', file);
               const imageData = {
                  upload_id: uploadId,
                  index: uploadMap.length
               };
               // Check if featured
               const uploadEl = this.uploadElements.get(uploadId);
               const radioInput = uploadEl?.element?.querySelector('[name="featured"]');
               if (radioInput?.checked) {
                  post.fields.featured = uploadId;
               }
               post.images.push(imageData);
               uploadMap.push(uploadId);
            }
         }
         posts.push(post);
      }
      // Handle remaining uploads (without groupId) - each becomes its own post
      const remainingUploadIds = uploadIds.filter(uploadId => {
         const upload = this.uploadStore.get(uploadId);
         return !upload?.groupId;
      });
      for (const uploadId of remainingUploadIds) {
         const post = {
            images: [],
            fields: {}
         };
         const file = await this.getBlobData(uploadId);
         if (file) {
            formData.append('files[]', file);
            const imageData = {
               upload_id: uploadId,
               index: uploadMap.length
            };
            post.images.push(imageData);
            uploadMap.push(uploadId);
         }
         posts.push(post);
      }
      // Add metadata to FormData
      formData.append('content', fieldData.config.content);
      formData.append('user', fieldData.config.itemID);
      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} ${fieldData.config.content}${posts.length > 1 ? 's' : ''} from uploads...`,
         popup: `Creating ${posts.length} post${posts.length > 1 ? 's' : ''}...`,
         canMerge: false,
         headers: {
            'action_nonce': window.auth.getNonce('dash')
         },
         append: '_upload',
      };
      try {
         const operationId = await this.queue.addToQueue(operation);
         // Update upload statuses
         uploadIds.forEach(uploadId => {
            const upload = this.uploadStore.get(uploadId);
            if (upload) {
               upload.operationId = operationId;
               upload.status = 'queued';
               this.uploadStore.save(upload);
               this.updateUploadStatus(uploadId, 'queued');
            }
         });
         fieldData.operationId = operationId;
         await this.saveFieldData(fieldData);
         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;
      }
   }
   async queueUpload(fieldId) {
      const fieldData = this.getFieldData(fieldId);
      if (!fieldData?.uploads || fieldData.uploads.size === 0) return;
      const uploads = Array.from(fieldData.uploads);
      const data = this.prepareUploadData(fieldData, uploads);
      this.a11y.announce('Queuing for upload');
      const operation = {
         endpoint: 'uploads',
         method: 'POST',
         data: data,
         title: `Uploading ${uploads.length} file${uploads.length > 1 ? 's' : ''} to server...`,
         popup: `Uploading ${uploads.length} file${uploads.length > 1 ? 's' : ''}...`,
         canMerge: false,
         headers: { 'action_nonce': window.auth.getNonce('dash') },
         append: '_upload'
      };
      try {
         const operationId = await this.queue.addToQueue(operation);
         // Update upload statuses
         uploads.forEach(uploadId => {
            const upload = this.uploadStore.get(uploadId);
            if (upload) {
               upload.operationId = operationId;
               upload.status = 'queued';
               this.uploadStore.save(upload);
               this.updateUploadStatus(uploadId, 'queued');
            }
         });
         fieldData.operationId = operationId;
         await this.saveFieldData(fieldData);
         return operationId;
      } catch (error) {
         throw error;
      }
   }
   async prepareUploadData(fieldData, uploads) {
      const formData = new FormData();
      formData.append('content', fieldData.config.content);
      formData.append('mode', fieldData.config.mode);
      formData.append('field_name', fieldData.config.name);
      formData.append('fieldId', fieldData.id);
      formData.append('field_type', fieldData.config.type);
      formData.append('subtype', fieldData.config.subtype);
      formData.append('item_id', fieldData.config.itemID);
      formData.append('destination', fieldData.config.destination || 'meta');
      let uploadMap = [];
      const blobPromises = uploads.map(async (uploadId) => {
         const upload = this.uploadStore.get(uploadId);
         if (!upload) return;
         const file = await this.getBlobData(uploadId);
         if (file) {
            formData.append('files[]', file);
            uploadMap.push(upload.id);
         }
      });
      await Promise.all(blobPromises);
      formData.append('upload_ids', JSON.stringify(uploadMap));
      return formData;
   }
   async queueUploadMeta(e) {
      const uploadId = this.getUploadIdFromElement(e.target);
      const upload = this.uploadStore.get(uploadId);
      if (!upload) return;
      const fieldData = this.getFieldData(upload.fieldId);
      if (!fieldData) return;
      let data = {};
      data[e.target.name] = e.target.value;
      upload.meta = { ...upload.meta, ...data };
      await this.uploadStore.save(upload);
      let queueData = {};
      queueData[upload.attachmentId ?? upload.id] = upload.meta;
      const operation = {
         endpoint: 'uploads/meta',
         method: 'POST',
         data: queueData,
         title: 'Updating meta',
         canMerge: true,
         headers: { 'action_nonce': window.auth.getNonce('dash') }
      };
      try {
         await this.queue.addToQueue(operation);
      } catch (error) {
         this.error.log(error, {
            component: 'UploadManager',
            action: 'sendMetaUpdate',
            uploadId: upload.id
         });
      }
   }
   /*******************************************************************************
    * QUEUE EVENT HANDLERS - CLEANUP AFTER SUCCESS
    *******************************************************************************/
   /**
    * Handle successful operation completion - CLEAR STORES
    */
   async handleOperationComplete(operation, fieldId) {
      const results = operation.result?.data || operation.serverData?.data || [];
      // Update upload statuses with attachment IDs
      results.forEach(result => {
         const upload = this.uploadStore.get(result.upload_id);
         if (upload) {
            upload.attachmentId = result.attachment_id;
            upload.status = 'completed';
            this.uploadStore.save(upload);
            this.updateUploadStatus(result.upload_id, 'completed');
         }
      });
      if (!fieldId) return;
      const fieldData = this.getFieldData(fieldId);
      if (!fieldData) return;
      // Clean up completed uploads from stores
      const completedUploads = Array.from(fieldData.uploads).filter(uploadId => {
         const upload = this.uploadStore.get(uploadId);
         return upload?.status === 'completed';
      });
      for (const uploadId of completedUploads) {
         await this.clearUpload(uploadId, false);
         fieldData.uploads.delete(uploadId);
      }
      // If all uploads complete, clear entire field from stores
      if (fieldData.uploads.size === 0) {
         await this.clearFieldFromStores(fieldId);
         this.a11y.announce('All uploads completed successfully');
      } else {
         // Otherwise just update field state
         await this.saveFieldData(fieldData);
      }
      this.updateFieldState(fieldId);
   }
   /**
    * Handle operation failure
    */
   handleOperationFailed(operation, fieldId) {
      const uploadIds = operation.data instanceof FormData
         ? JSON.parse(operation.data.get('upload_ids') || '[]')
         : operation.data.upload_ids || [];
      uploadIds.forEach(uploadId => {
         const upload = this.uploadStore.get(uploadId);
         if (upload) {
            upload.status = operation.status === 'operation-failed-permanent'
               ? 'failed_permanent'
               : 'failed';
            this.uploadStore.save(upload);
            this.updateUploadStatus(uploadId, upload.status);
         }
      });
      if (fieldId) {
         this.updateFieldState(fieldId);
      }
   }
   /**
    * Handle operation cancellation
    */
   async handleOperationCancelled(fieldId) {
      const fieldData = this.getFieldData(fieldId);
      if (!fieldData) return;
      const uploadsArray = fieldData.uploads instanceof Set
         ? Array.from(fieldData.uploads)
         : fieldData.uploads;
      for (const uploadId of uploadsArray) {
         await this.clearUpload(uploadId, false);
      }
      await this.clearFieldFromStores(fieldId);
      this.updateFieldState(fieldId);
      this.a11y.announce('Upload cancelled');
   }
   getFieldGroups(fieldId) {
      const fieldData = this.getFieldData(fieldId);
      if (!fieldData?.groups) return [];
      return fieldData.groups.map(group => ({
         id: group.id,
         uploads: group.uploads || [],
         changes: group.changes || {}
      }));
   }
   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;
   }
   async restoreSelectedUploads(selectedUploads) {
      const byField = new Map();
      selectedUploads.forEach(item => {
         if (!byField.has(item.fieldId)) {
            byField.set(item.fieldId, []);
         }
         byField.get(item.fieldId).push(item.uploadId);
      });
      for (const [fieldId, uploadIds] of byField.entries()) {
         const fieldState = this.fieldStore.get(fieldId);
         if (fieldState) {
            fieldState.uploads = uploadIds;
            await this.restoreField(fieldState);
         }
      }
   }
   async restoreField(fieldState) {
      const { config, context, uploads, groups, id } = fieldState;
      // If in a modal, open it first
      if (context?.modalType) {
         await this.openModalForRestore(context);
      }
      // Find field element
      let fieldElement = document.querySelector(`.field.upload[data-field="${config.name}"]`);
      if (!fieldElement) {
         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 if not already registered
      let fieldKey = fieldElement.dataset.uploader;
      if (!fieldKey || !this.fieldElements.has(fieldKey)) {
         fieldKey = this.registerUploader(fieldElement);
      }
      const fieldEl = this.fieldElements.get(fieldKey);
      const fieldData = this.getFieldData(fieldKey);
      if (!fieldEl || !fieldData) {
         console.error('Failed to register field for restoration');
         return;
      }
      // Merge saved state back into field
      fieldData.state = fieldState.state || 'ready';
      // Rebuild UI references if needed
      if (!fieldEl.ui) {
         fieldEl.ui = this.buildFieldUI(fieldElement);
      }
      if (fieldEl.ui.groups?.display) {
         fieldEl.ui.groups.display.hidden = false;
      }
      if (fieldEl.ui.dropZone) {
         fieldEl.ui.dropZone.hidden = true;
      }
      // Restore groups first
      if (groups && groups.length > 0) {
         await this.restoreGroups(fieldKey, groups);
      }
      // Handle both Array and Set for uploads
      const uploadsArray = uploads instanceof Set
         ? Array.from(uploads)
         : Array.isArray(uploads)
            ? uploads
            : [];
      // Restore uploads
      for (const uploadId of uploadsArray) {
         // Get upload data from store
         const uploadData = this.uploadStore.get(uploadId);
         if (uploadData) {
            await this.restoreUpload(fieldKey, uploadData);
         }
      }
      // Update field state
      await this.saveFieldData(fieldData);
      this.updateFieldState(fieldKey);
      this.maybeLockUploads(fieldKey);
      this.refreshSortable(fieldKey);
      // Queue for upload if needed
      if (config.mode === 'direct' && config.destination !== 'post_group') {
         await this.queueUpload(fieldKey);
      }
   }
   async restoreUpload(fieldId, uploadData) {
      const fieldEl = this.fieldElements.get(fieldId);
      const fieldData = this.getFieldData(fieldId);
      if (!fieldEl || !fieldData) {
         console.error('Field not found for upload restoration:', fieldId);
         return;
      }
      // Get reconstructed File from blob data
      const file = await this.getBlobData(uploadData.id);
      if (!file) {
         console.warn('Blob data not found for upload:', uploadData.id);
         return;
      }
      // Create preview URL
      const previewUrl = this.createPreviewUrl(file);
      // Recreate DOM element
      const subtype = this.getSubtypeFromMime(file.type);
      const element = this.createUploadElement({
         id: uploadData.id,
         preview: previewUrl,
         meta: uploadData.meta || {
            originalName: file.name,
            size: file.size,
            type: file.type
         },
         subtype: subtype
      }, fieldData.config.destination === 'post_group');
      // Determine correct location
      let location;
      if (uploadData.groupId) {
         // Check if group exists
         const groupEl = this.groupElements.get(uploadData.groupId);
         if (groupEl?.grid) {
            location = groupEl.grid;
            // Add to group's upload list
            const group = fieldData.groups?.find(g => g.id === uploadData.groupId);
            if (group) {
               if (!group.uploads) group.uploads = [];
               if (!group.uploads.includes(uploadData.id)) {
                  group.uploads.push(uploadData.id);
               }
            }
         } else {
            // Group doesn't exist, add to preview
            location = fieldEl.ui.preview;
            uploadData.groupId = null;
         }
      } else {
         // No group, add to preview
         location = fieldEl.ui.preview;
      }
      // Add element to DOM
      if (location) {
         location.appendChild(element);
      } else if (fieldEl.ui.preview) {
         fieldEl.ui.preview.appendChild(element);
         location = fieldEl.ui.preview;
      }
      // Store runtime element data
      this.uploadElements.set(uploadData.id, {
         element: element,
         preview: previewUrl,
         location: location
      });
      // Add to field uploads
      if (!fieldData.uploads) fieldData.uploads = new Set();
      fieldData.uploads.add(uploadData.id);
      // Update upload data in store
      uploadData.status = 'processed';
      await this.uploadStore.save(uploadData);
      // Update sortable state for the grid
      if (location) {
         this.updateSortableState(location);
      }
   }
   async restoreGroups(fieldId, groups) {
      const fieldEl = this.fieldElements.get(fieldId);
      const fieldData = this.getFieldData(fieldId);
      if (!fieldEl || !fieldData) {
         console.error('Field not found for group restoration:', fieldId);
         return;
      }
      for (const groupData of groups) {
         const group = this.createGroup(fieldId, groupData.id);
         if (!group) {
            console.warn('Failed to create group:', groupData.id);
            continue;
         }
         const storedGroup = fieldData.groups?.find(g => g.id === groupData.id);
         if (storedGroup) {
            // Restore metadata
            if (groupData.changes) {
               storedGroup.changes = { ...groupData.changes };
            }
            // Preserve upload order
            if (groupData.uploads) {
               storedGroup.uploads = [...groupData.uploads];
            }
            // Restore form field values
            if (groupData.changes) {
               const titleInput = group.element.querySelector('[name*="post_title"]');
               const excerptInput = group.element.querySelector('[name*="post_excerpt"]');
               if (titleInput && groupData.changes.post_title) {
                  titleInput.value = groupData.changes.post_title;
               }
               if (excerptInput && groupData.changes.post_excerpt) {
                  excerptInput.value = groupData.changes.post_excerpt;
               }
            }
         }
      }
      await this.saveFieldData(fieldData);
   }
   async openModalForRestore(context) {
      if (!context) return;
      const { modalType, itemId } = context;
      // Find and click the appropriate button to open the modal
      let trigger = null;
      switch(modalType) {
         case 'create':
            trigger = document.querySelector('[data-action="create"]');
            break;
         case 'edit':
            // Need to find the specific edit button
            if (itemId) {
               trigger = document.querySelector(`[data-action="edit"][data-id="${itemId}"]`);
            }
            break;
         case 'bulkEdit':
            trigger = document.querySelector('[data-action="bulk-edit"]');
            break;
      }
      if (trigger) {
         trigger.click();
         // Wait for modal to open and render
         await new Promise(resolve => setTimeout(resolve, 300));
      } else {
         console.warn('Modal trigger not found for restoration:', context);
      }
   }
   formatBytes(bytes, decimals = 2) {
      if (bytes === 0) return '0 Bytes';
      const k = 1024;
      const dm = decimals < 0 ? 0 : decimals;
      const sizes = ['Bytes', 'KB', 'MB', 'GB'];
      const i = Math.floor(Math.log(bytes) / Math.log(k));
      return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
   }
   /*******************************************************************************
    * CLEANUP METHODS - AGGRESSIVE CLEANUP AFTER SUCCESS
    *******************************************************************************/
   /**
    * Clear individual upload from stores (called after successful upload)
    */
   async clearUpload(uploadId, persist = true) {
      const uploadEl = this.uploadElements.get(uploadId);
      if (uploadEl) {
         this.revokePreviewUrl(uploadEl.preview);
         if (uploadEl.element) {
            const previewUrl = uploadEl.element.dataset.previewUrl;
            this.revokePreviewUrl(previewUrl);
            delete uploadEl.element.dataset.previewUrl;
         }
      }
      // Remove from runtime memory
      this.uploadElements.delete(uploadId);
      // Remove from store (no separate blob store - it's part of the upload object)
      await this.uploadStore.delete(uploadId);
      // Update field if needed
      if (persist) {
         const upload = this.uploadStore.get(uploadId);
         if (upload?.fieldId) {
            await this.schedulePersistance(upload.fieldId);
         }
      }
   }
   /**
    * Clear entire field from stores (called when all uploads complete)
    */
   async clearFieldFromStores(fieldId) {
      const fieldData = this.getFieldData(fieldId);
      // Clear all related uploads
      if (fieldData?.uploads) {
         const uploadsArray = fieldData.uploads instanceof Set
            ? Array.from(fieldData.uploads)
            : fieldData.uploads;
         for (const uploadId of uploadsArray) {
            await this.uploadStore.delete(uploadId);
         }
      }
      // Clear field from store
      await this.fieldStore.delete(fieldId);
      // Keep runtime references (fieldElements, etc) intact for reuse
   }
   cleanupAllPreviewUrls() {
      if (this.previewUrls) {
         this.previewUrls.forEach(url => {
            try {
               URL.revokeObjectURL(url);
            } catch (e) {
               // Ignore errors during cleanup
            }
         });
         this.previewUrls.clear();
      }
   }
   /*******************************************************************************
    * UI UPDATE METHODS
    *******************************************************************************/
   updateFieldState(fieldId) {
      const fieldEl = this.fieldElements.get(fieldId);
      const fieldData = this.getFieldData(fieldId);
      if (!fieldEl || !fieldData) return;
      const container = fieldEl.element;
      const uploadCount = fieldData.uploads?.size || 0;
      const hasGroups = fieldEl.ui.groups?.container?.querySelectorAll('.upload-group').length > 0;
      container.dataset.hasUploads = uploadCount > 0 ? 'true' : 'false';
      container.dataset.uploadCount = uploadCount.toString();
      container.dataset.hasGroups = hasGroups ? 'true' : 'false';
      if (fieldEl.ui.preview) {
         fieldEl.ui.preview.setAttribute('aria-label',
            `Upload preview area with ${uploadCount} item${uploadCount !== 1 ? 's' : ''}`
         );
      }
   }
   updateUploadProgress(fieldId, current, total, message) {
      const fieldEl = this.fieldElements.get(fieldId);
      if (!fieldEl?.ui?.progress?.progress) return;
      const progress = fieldEl.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 fieldData = this.getFieldData(fieldId);
      if (!fieldData) return;
      fieldData.state = status;
      this.saveFieldData(fieldData);
   }
   updateUploadStatus(uploadId, status) {
      const upload = this.uploadStore.get(uploadId);
      if (!upload) return;
      upload.status = status;
      this.uploadStore.save(upload);
      this.updateUploadUI(uploadId);
   }
   updateUploadUI(uploadId) {
      const uploadEl = this.uploadElements.get(uploadId);
      const upload = this.uploadStore.get(uploadId);
      if (!upload || !uploadEl?.element) return;
      uploadEl.element.className = uploadEl.element.className.replace(/status-[\w-]+/g, '');
      uploadEl.element.classList.add(`status-${upload.status}`);
      const progress = uploadEl.element.querySelector('.progress');
      if (progress) {
         this.updateUploadItemProgress(uploadId,
            this.getStatusProgress(upload.status),
            upload.status
         );
      }
   }
   showUploadProgress(uploadId, show = true) {
      const uploadEl = this.uploadElements.get(uploadId);
      if (!uploadEl?.element) return;
      const progressEl = uploadEl.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);
         }
      }
   }
   updateUploadItemProgress(uploadId, percent, status = null) {
      const uploadEl = this.uploadElements.get(uploadId);
      if (!uploadEl?.element) return;
      const progressEl = uploadEl.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;
   }
   maybeLockUploads(fieldId) {
      const fieldEl = this.fieldElements.get(fieldId);
      const fieldData = this.getFieldData(fieldId);
      if (!fieldEl?.ui?.dropZone || !fieldData) return;
      const uploadCount = fieldData.uploads?.size || 0;
      // For groupable uploads, set max to 20
      const maxFiles = fieldData.config.destination === 'post_group'
         ? 20
         : (fieldData.config?.maxFiles || 999);
      fieldEl.ui.dropZone.hidden = uploadCount >= maxFiles;
      fieldEl.element.classList.toggle('at-max-uploads', uploadCount >= maxFiles);
      // Show helpful message for groupable uploads
      if (fieldData.config.destination === 'post_group' && uploadCount >= maxFiles) {
         this.a11y.announce('Maximum of 20 uploads reached. Please submit current uploads before adding more.');
      }
   }
   /*******************************************************************************
    * GROUP MANAGEMENT
    *******************************************************************************/
   /**
    * Create sortable instance for a grid
    */
   createSortableForGrid(grid, fieldId, groupId = null) {
      if (!grid || grid.sortableInstance) return;
      const sortableInstance = new Sortable(grid, {
         animation: 150,
         draggable: '.item',
         multiDrag: true,
         selectedClass: 'selected-for-drag',
         avoidImplicitDeselect: true,
         group: { name: fieldId, pull: true, put: true },
         ghostClass: 'sortable-ghost',
         chosenClass: 'sortable-chosen',
         dragClass: 'sortable-drag',
         // Centralized drop handler
         onEnd: (evt) => this.handleDrop(evt, fieldId),
         // Selection sync
         onSelect: (evt) => {
            const checkbox = evt.item.querySelector('[name*="select-item"]');
            if (checkbox && !checkbox.checked) {
               checkbox.checked = true;
               checkbox.dispatchEvent(new Event('change', { bubbles: true }));
            }
         },
         onDeselect: (evt) => {
            const checkbox = evt.item.querySelector('[name*="select-item"]');
            if (checkbox && checkbox.checked) {
               checkbox.checked = false;
               checkbox.dispatchEvent(new Event('change', { bubbles: true }));
            }
         },
         onAdd: (evt) => this.updateSortableState(evt.to),
         onRemove: (evt) => this.updateSortableState(evt.from)
      });
      grid.sortableInstance = sortableInstance;
      const gridId = groupId
         ? `${fieldId}-group-${groupId}`
         : `${fieldId}-preview`;
      this.sortableInstances.set(gridId, sortableInstance);
      return sortableInstance;
   }
   createGroup(fieldId, groupId = null) {
      const fieldData = this.getFieldData(fieldId);
      const fieldEl = this.fieldElements.get(fieldId);
      if (!fieldData || !fieldEl) return null;
      if (!groupId) {
         groupId = `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
      }
      const groupElement = this.createGroupElement(groupId, fieldId);
      if (!groupElement) return null;
      // Store in field UI Map
      if (!fieldEl.ui.groups) {
         fieldEl.ui.groups = {
            groups: new Map(),
            container: null,
            empty: null,
            display: null
         };
      }
      fieldEl.ui.groups.groups.set(groupId, groupElement);
      // Insert into DOM
      if (fieldEl.ui.groups.container && fieldEl.ui.groups.empty) {
         fieldEl.ui.groups.container.insertBefore(groupElement, fieldEl.ui.groups.empty);
      } else if (fieldEl.ui.groups.container) {
         fieldEl.ui.groups.container.appendChild(groupElement);
      }
      // Store group element reference
      const grid = groupElement.querySelector('.item-grid.group');
      this.groupElements.set(groupId, {
         element: groupElement,
         grid: grid,
         fieldId: fieldId
      });
      // Add to field groups
      if (!fieldData.groups) fieldData.groups = [];
      const existingGroup = fieldData.groups.find(g => g.id === groupId);
      if (!existingGroup) {
         fieldData.groups.push({
            id: groupId,
            uploads: [],
            changes: {}
         });
         this.saveFieldData(fieldData);
      }
      // Initialize selection handler
      this.addGroupSelectionHandler(fieldId, groupId);
      if (grid) {
         this.createSortableForGrid(grid, fieldId, groupId);
      }
      return { id: groupId, element: groupElement, grid: grid };
   }
   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);
         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]`;
         }
         const fieldData = this.getFieldData(fieldId);
         if (fieldData && fieldData.config.content !== '') {
            let summary = groupElement.querySelector('summary');
            if (summary) summary.textContent = fieldData.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) {
      const groupEl = this.groupElements.get(groupId);
      if (!groupEl) return;
      const fieldData = this.getFieldData(groupEl.fieldId);
      if (!fieldData) return;
      const group = fieldData.groups?.find(g => g.id === groupId);
      let keepUploads = true;
      if (confirm && group?.uploads?.length > 0) {
         keepUploads = !window.confirm('Delete uploads in group?');
      }
      if (confirm && keepUploads && group?.uploads) {
         // Move uploads back to preview
         group.uploads.forEach(uploadId => {
            this.removeFromGroup(uploadId);
         });
      }
      // Remove from field groups
      if (fieldData.groups) {
         fieldData.groups = fieldData.groups.filter(g => g.id !== groupId);
         this.saveFieldData(fieldData);
      }
      // Remove DOM element
      if (groupEl.element) {
         groupEl.element.remove();
         this.a11y.announce('Group removed');
      }
      // Remove from maps
      this.groupElements.delete(groupId);
      // Clean up sortable
      const sortableKey = `${groupEl.fieldId}-group-${groupId}`;
      const sortable = this.sortableInstances.get(sortableKey);
      if (sortable?.destroy) {
         sortable.destroy();
      }
      this.sortableInstances.delete(sortableKey);
      this.schedulePersistance(groupEl.fieldId);
   }
   addToGroup(uploadId, target = null, persist = true) {
      const upload = this.uploadStore.get(uploadId);
      const uploadEl = this.uploadElements.get(uploadId);
      if (!upload || !uploadEl) return;
      const fieldData = this.getFieldData(upload.fieldId);
      const fieldEl = this.fieldElements.get(upload.fieldId);
      if (!fieldData || !fieldEl) return;
      // Already in correct location
      if ((!target && uploadEl.location === fieldEl.ui.preview) || target === uploadEl.location) {
         return;
      }
      // Remove from previous group
      if (upload.groupId) {
         const group = fieldData.groups?.find(g => g.id === upload.groupId);
         if (group) {
            group.uploads = group.uploads.filter(id => id !== uploadId);
            if (group.uploads.length === 0) {
               this.deleteGroup(upload.groupId);
            }
         }
      }
      // Clear selection checkbox
      const checkbox = uploadEl.element.querySelector('[name*="select-item"]');
      if (checkbox) checkbox.checked = false;
      let featured = uploadEl.element.querySelector('[name="featured"]');
      if (featured) featured.hidden = !target;
      // Moving to preview or to group
      if (!target || target.classList.contains('preview')) {
         target = fieldEl.ui.preview;
         upload.groupId = null;
      } else {
         // Moving to group
         const groupId = target.dataset.groupId;
         if (featured) featured.name = groupId + '_' + featured.name;
         const group = fieldData.groups?.find(g => g.id === groupId);
         if (group) {
            if (!group.uploads) group.uploads = [];
            group.uploads.push(uploadId);
            upload.groupId = groupId;
         }
      }
      // Update location
      uploadEl.location = target;
      target.append(uploadEl.element);
      // Update stores
      this.uploadStore.save(upload);
      if (persist) {
         this.saveFieldData(fieldData);
      }
      // Update sortable state
      this.updateSortableState(target);
      if (uploadEl.location && uploadEl.location !== target) {
         this.updateSortableState(uploadEl.location);
      }
   }
   removeFromGroup(uploadId) {
      const upload = this.uploadStore.get(uploadId);
      const uploadEl = this.uploadElements.get(uploadId);
      if (!upload || !uploadEl) return;
      const fieldData = this.getFieldData(upload.fieldId);
      const fieldEl = this.fieldElements.get(upload.fieldId);
      if (!fieldData || !fieldEl) return;
      // Remove from current group
      if (upload.groupId) {
         const group = fieldData.groups?.find(g => g.id === upload.groupId);
         if (group) {
            group.uploads = group.uploads.filter(id => id !== uploadId);
            if (group.uploads.length === 0) {
               this.deleteGroup(upload.groupId, false);
            }
         }
         upload.groupId = null;
      }
      // Move back to preview
      if (fieldEl.ui?.preview) {
         fieldEl.ui.preview.appendChild(uploadEl.element);
         uploadEl.location = fieldEl.ui.preview;
      }
      // Hide featured radio
      const featured = uploadEl.element.querySelector('[name="featured"]');
      if (featured) {
         featured.hidden = true;
         featured.checked = false;
      }
      this.uploadStore.save(upload);
      this.updateSortableState(fieldEl.ui.preview);
   }
   removeUpload(fieldId, uploadId) {
      const fieldData = this.getFieldData(fieldId);
      const upload = this.uploadStore.get(uploadId);
      const uploadEl = this.uploadElements.get(uploadId);
      if (!fieldData || !upload) return;
      // Remove from field
      fieldData.uploads?.delete(uploadId);
      // Remove from group if grouped
      if (upload.groupId) {
         const group = fieldData.groups?.find(g => g.id === upload.groupId);
         if (group) {
            group.uploads = group.uploads.filter(id => id !== uploadId);
            if (group.uploads.length === 0) {
               this.deleteGroup(upload.groupId);
            }
         }
      }
      // Clean up element
      uploadEl?.element?.remove();
      // Clean up memory
      this.clearUpload(uploadId);
      // Update field state
      this.saveFieldData(fieldData);
      this.updateFieldState(fieldId);
      this.maybeLockUploads(fieldId);
      const handler = this.selectionHandlers.get(fieldId);
      if (handler) {
         handler.deselect(uploadId);
      }
      this.a11y.announce('Upload removed');
   }
   handleGroupMetaChange(input) {
      const groupEl = this.getGroupFromElement(input);
      if (!groupEl) return;
      const fieldData = this.getFieldData(groupEl.fieldId);
      const group = fieldData?.groups?.find(g => g.id === groupEl.element.dataset.groupId);
      if (!group) return;
      if (!group.changes) group.changes = {};
      let name = input.name;
      if (name.includes('group')) {
         name = name.replace(`${group.id}_`, '').replace(`${group.id}[`, '').replace(']', '');
      }
      group.changes[name] = input.value;
      this.saveFieldData(fieldData);
      this.schedulePersistance(groupEl.fieldId);
   }
   /*******************************************************************************
    * ACTION HANDLERS
    *******************************************************************************/
   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':
            const fieldEl = this.fieldElements.get(fieldId);
            if (fieldEl) {
               fieldEl.element.closest('details').open = false;
               document.body.classList.add('uploading');
               this.submitUploads(fieldId);
            }
            break;
         case 'restore':
            this.handleRestoreUploads().then(() => {});
            break;
         case 'restore-all':
            this.handleRestoreAll().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) {
         this.createGroup(fieldId);
      } else {
         const group = this.createGroup(fieldId);
         if (!group) return;
         selected.forEach(uploadId => {
            this.addToGroup(uploadId, group.grid);
         });
         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;
      }
      const items = group.querySelectorAll(this.selectors.items.item);
      items.forEach(item => {
         const uploadId = item.dataset.uploadId;
         this.removeFromGroup(uploadId);
      });
      this.deleteGroup(groupId);
      this.a11y.announce('Group deleted, items returned to upload area');
      this.schedulePersistance(fieldId);
   }
   handleRemoveItem(button) {
      const item = button.closest(this.selectors.items.item);
      if (!item) return;
      const uploadId = item.dataset.uploadId;
      const fieldId = this.getFieldIdFromElement(item);
      if (!confirm('Remove this item?')) return;
      this.removeUpload(fieldId, uploadId);
      this.a11y.announce('Item removed');
      this.schedulePersistance(fieldId);
   }
   /*******************************************************************************
    * SELECTION MANAGEMENT
    *******************************************************************************/
   addFieldSelectionHandler(fieldId) {
      if (this.selectionHandlers.has(fieldId)) {
         return this.selectionHandlers.get(fieldId);
      }
      const fieldEl = this.fieldElements.get(fieldId);
      if (!fieldEl?.element) return;
      const handler = new window.jvbHandleSelection({
         container: fieldEl.element,
         ui: {
            selectAll: fieldEl.element.querySelector('[name="select-all-uploads"]'),
            bulkControls: fieldEl.element.querySelector('.selection-actions'),
            count: fieldEl.element.querySelector('.selection-count')
         },
         itemSelector: '[data-upload-id]',
         checkboxSelector: '[name*="select-item"]'
      });
      handler.subscribe((event, data) => {
         switch(event) {
            case 'item-selected':
               // Sync with Sortable
               this.syncSortableSelection(fieldId, data.selectedItems);
               this.selected.set(fieldId, data.selectedItems);
               break;
            case 'item-deselected':
               this.syncSortableSelection(fieldId, data.selectedItems);
               this.selected.set(fieldId, data.selectedItems);
               break;
            case 'range-selected':
               this.syncSortableSelection(fieldId, data.selectedItems);
               this.selected.set(fieldId, data.selectedItems);
               break;
            case 'select-all':
               this.handleSelectAll(data.container, data.selected);
               break;
         }
      });
      this.selectionHandlers.set(fieldId, handler);
      return handler;
   }
   addGroupSelectionHandler(fieldId, groupId) {
      const handlerKey = `${fieldId}_${groupId}`;
      if (this.selectionHandlers.has(handlerKey)) {
         return this.selectionHandlers.get(handlerKey);
      }
      const groupEl = this.groupElements.get(groupId);
      if (!groupEl?.element) return;
      const handler = new window.jvbHandleSelection({
         container: groupEl.element,
         ui: {
            selectAll: groupEl.element.querySelector(this.selectors.groups.selectAll),
            bulkControls: groupEl.element.querySelector(this.selectors.groups.actions),
            count: groupEl.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) {
      // Can add custom logic here if needed
   }
   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;
   }
   /*******************************************************************************
    * HELPER METHODS
    *******************************************************************************/
   /**
    * Get field data from store and normalize it
    * Always use this instead of directly accessing fieldStore.get()
    */
   getFieldData(fieldId) {
      const fieldData = this.fieldStore.get(fieldId);
      if (!fieldData) return null;
      // Only convert uploads back to Set (DataStore returns Arrays)
      if (Array.isArray(fieldData.uploads)) {
         fieldData.uploads = new Set(fieldData.uploads);
      } else if (!fieldData.uploads) {
         fieldData.uploads = new Set();
      }
      // Ensure groups is an array
      if (!Array.isArray(fieldData.groups)) {
         fieldData.groups = [];
      }
      return fieldData;
   }
   /**
    * Save field data to store, converting Sets to Arrays
    */
   async saveFieldData(fieldData) {
      await this.fieldStore.save({
         ...fieldData,
         timestamp: Date.now()
      });
   }
   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',
            getRuntimeData: (id) => this.fieldElements.get(id),
            getStoreData: (id) => this.getFieldData(id)
         },
         'upload': {
            selector: this.selectors.items.item,
            key: 'uploadId',
            getRuntimeData: (id) => this.uploadElements.get(id),
            getStoreData: (id) => this.uploadStore.get(id)
         },
         'group': {
            selector: this.selectors.groups.container,
            key: 'groupId',
            getRuntimeData: (id) => this.groupElements.get(id),
            getStoreData: (id) => {
               // Groups are stored in field.groups array
               const groupEl = this.groupElements.get(id);
               if (!groupEl) return null;
               const fieldData = this.getFieldData(groupEl.fieldId);
               return fieldData?.groups?.find(g => g.id === id);
            }
         }
      };
      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 combined runtime + store data for convenience
      const runtime = config.getRuntimeData(id);
      const store = config.getStoreData(id);
      return { ...runtime, ...store };
   }
   getFieldFromElement(el) { return this.getFromElement(el, 'field'); }
   getUploadFromElement(el) { return this.getFromElement(el, 'upload'); }
   getGroupFromElement(el) { return this.getFromElement(el, 'group'); }
   getFieldIdFromElement(el) {
      const field = this.getFromElement(el, 'field');
      return field?.id ?? null;
   }
   getUploadIdFromElement(el) {
      const upload = this.getFromElement(el, 'upload');
      return upload?.id ?? null;
   }
   getGroupIdFromElement(el) {
      const group = this.getFromElement(el, 'group');
      return group?.id ?? null;
   }
   getSubtypeFromMime(mimeType) {
      if (mimeType.startsWith('image/')) return 'image';
      if (mimeType.startsWith('video/')) return 'video';
      return 'document';
   }
   getStatusText(status) {
      return this.statusMapping[status] || status;
   }
   getStatusIcon(status) {
      return window.getIcon(this.queue.icons[status]);
      return map[status]||status;
   }
   getStatusProgress(status) {
      const progress = {
      let progress = {
         'local_processing': 28,
         'queued': 50,
         'uploading': 66,
@@ -2629,464 +1487,593 @@
         'processing': 89,
         'completed': 100
      };
      return progress[status] || 0;
      return progress[status]??0;
   }
   getModalType(fieldEl) {
      if (!fieldEl?.element) return null;
      if (fieldEl._cachedModalType !== undefined) {
         return fieldEl._cachedModalType;
      }
      const dialog = fieldEl.element.closest('dialog');
      if (!dialog) {
         fieldEl._cachedModalType = null;
         return null;
      }
      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;
      fieldEl._cachedModalType = modalType;
      return modalType;
   }
   createUploadElement(upload, draggable = false) {
      let image = window.getTemplate('uploadItem');
      if (!image) return;
      image.dataset.uploadId = upload.id;
      image.dataset.subtype = upload.subtype || 'image';
      let [featured, img, video, preview, details] = [
         image.querySelector('[name="featured"]'),
         image.querySelector('img'),
         image.querySelector('video'),
         image.querySelector('label > span'),
         image.querySelector('details')
      ];
      if (featured) featured.value = upload.id;
      switch (upload.subtype) {
         case 'image':
            if (img) {
               img.src = upload.preview;
               img.alt = upload.meta?.originalName || '';
            }
            video?.remove();
            preview?.remove();
            break;
         case 'video':
            if (video) video.src = upload.preview;
            img?.remove();
            preview?.remove();
            break;
         case 'document':
            const fileName = 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');
            if (preview) {
               preview.innerText = fileName;
               preview.prepend(icon);
            }
            img?.remove();
            video?.remove();
            break;
      }
      if (details) {
         let template = window.getTemplate('uploadMeta');
         if (template) details.append(template);
      }
      image.draggable = draggable;
      // Update input IDs
      image.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;
         }
      });
      return image;
   }
   /*******************************************************************************
    * PERSISTENCE
    *******************************************************************************/
    UPLOAD METHODS
   *******************************************************************************/
   async createUpload(uploadId, file, fieldId) {
      let field = this.fields.get(fieldId);
      if (!field) return null;
   /**
    * Normalize field data loaded from IndexedDB
    * Converts Arrays back to Sets, handles missing properties
    */
   normalizeFieldData(fieldData) {
      if (!fieldData) return null;
      // Convert uploads array back to Set
      if (Array.isArray(fieldData.uploads)) {
         fieldData.uploads = new Set(fieldData.uploads);
      } else if (!fieldData.uploads) {
         fieldData.uploads = new Set();
      }
      // Convert groups array, ensure proper structure
      if (!Array.isArray(fieldData.groups)) {
         fieldData.groups = [];
      }
      // Ensure each group has uploads array
      fieldData.groups = fieldData.groups.map(group => ({
         ...group,
         uploads: Array.isArray(group.uploads) ? group.uploads : []
      }));
      return fieldData;
   }
   schedulePersistance(fieldId) {
      const key = `persist_${fieldId}`;
      window.debouncer.schedule(
         key,
         () => this.persistFieldState(fieldId),
         250
      );
   }
   async persistFieldState(fieldId) {
      const fieldData = this.getFieldData(fieldId);
      if (!fieldData) return;
      // Save with updated timestamp
      await this.saveFieldData(fieldData);
   }
   // In UploadManager, add blob conversion helpers
   async saveBlobData(uploadId, file) {
      const arrayBuffer = await file.arrayBuffer();
      const uploadData = this.uploadStore.get(uploadId) || { id: uploadId };
      // Store blob data as ArrayBuffer with metadata
      uploadData.blobData = {
         buffer: arrayBuffer,
         name: file.name,
         type: file.type,
         size: file.size,
         lastModified: file.lastModified || Date.now()
      let data = {
         uploadId: uploadId,
         file: file,
         field: field,
      };
      await this.uploadStore.save(uploadData);
      return this.templates.create('uploadItem', data);
   }
   async getBlobData(uploadId) {
      const upload = this.uploadStore.get(uploadId);
      if (!upload?.blobData) return null;
   getSubtypeFromURL(url) {
      if (!url || url === '') {
         return '';
      }
      const imgs = ['.webp', '.jpg', '.jpeg', '.png', '.gif', '.svg'];
      const videos = ['.mp4', '.ogg', '.mov', '.webm', '.avi'];
      // Reconstruct File from ArrayBuffer
      const blob = new Blob([upload.blobData.buffer], { type: upload.blobData.type });
      return new File([blob], upload.blobData.name, {
         type: upload.blobData.type,
         lastModified: upload.blobData.lastModified
      const path = url.split('?')[0].toLowerCase();
      if (imgs.some(ext => path.endsWith(ext))) return 'image';
      if (videos.some(ext => path.endsWith(ext))) return 'video';
      return 'document';
   }
   getSubtypeFromMime(mimeType) {
      if (mimeType.startsWith('image/')) return 'image';
      if (mimeType.startsWith('video/')) return 'video';
      return 'document';
   }
   /**
    * Called by handleAction
    * @param button
    */
   async handleRemoveItem(button) {
      console.log('Handling remove upload');
      const item = button.closest(this.selectors.items.item);
      if (!item) return;
      const uploadId = item.dataset.uploadId;
      const attachmentId = item.dataset.id;
      if (!uploadId && !attachmentId) return;
      if (!confirm('Remove this item?')) return;
      if (uploadId) {
         await this.removeUpload(uploadId);
      } else {
         const fieldId = this.getFieldIdFromElement(button);
         item.remove();
         if (fieldId) {
            this.updateHiddenInput(fieldId);
            this.maybeLockUploads(fieldId);
         }
      }
      this.a11y.announce('Item removed');
   }
   updateHiddenInput(fieldId) {
      const field = this.fields.get(fieldId);
      if (!field?.ui.hidden) return;
      const remaining = Array.from(field.ui.grid?.querySelectorAll(this.selectors.items.item) || [])
         .map(el => {
            if (Object.hasOwn(el.dataset, 'id') && el.dataset.id > 0) {
               return el.dataset.id;
            }
            if (Object.hasOwn(el.dataset, 'upload-id') && el.dataset.uploadId > 0) {
               return el.dataset.uploadId;
            }
            //For timeline
            return el.dataset.itemId;
         })
         .filter(Boolean);
      const newValue = remaining.join(',');
      if (field.ui.hidden.value === newValue) return;
      field.ui.hidden.value = newValue;
      field.ui.hidden.dispatchEvent(new Event('change', { bubbles: true }));
   }
   async setBulkUpload(uploads, key, value) {
      const promises = Array.from(uploads).map(async (upload) => {
         if (typeof upload === 'string') upload = await this.stores.uploads.get(upload);
         if (!upload) return;
         if (key === 'status') {
            await this.setUploadStatus(upload, value);
         }
         upload[key] = value;
         return this.stores.uploads.save(upload);
      });
      await Promise.all(promises);
   }
   async setUploadStatus(upload, status) {
      if (typeof upload === 'string') upload = await this.stores.uploads.get(upload);
      if (!upload) return;
      if (upload.progress) {
         window.showProgress(upload.progress, this.getStatusProgress(status), 100, this.getStatusText(status), this.queue.icons[status]??'');
      }
   }
   async removeUpload(uploadId) {
      let upload = this.stores.uploads.get(uploadId);
      if (!upload) return;
      const fieldId = upload.field; // grab before clearing
      if (upload.group) {
         let group = this.stores.groups.get(upload.group);
         group.uploads = group.uploads.filter(id => id !== uploadId);
         if (group.uploads.length === 0) {
            await this.removeGroup(group.id, false);
         } else {
            await this.stores.groups.save(group);
         }
      }
      await this.clearUpload(uploadId);
      this.updateHiddenInput(fieldId);
      this.maybeLockUploads(fieldId);
      let handler = this.selectionHandlers.get(fieldId);
      if (handler) {
         handler.deselect(uploadId);
      }
      this.a11y.announce('Upload removed');
   }
   async clearUpload(uploadId) {
      const element = this.uploads.get(uploadId);
      if (element) {
         this.revokePreviewUrl(element.preview);
         if (element.element) {
            const previewUrl = element.element.dataset.previewUrl;
            this.revokePreviewUrl(previewUrl);
            element.element.remove();
         }
      }
      this.uploads.delete(uploadId);
      await this.stores.uploads.delete(uploadId);
   }
   /*******************************************************************************
    * RECOVERY & RESTORATION
    *******************************************************************************/
    GROUP METHODS
   *******************************************************************************/
   async handleAddToGroup(fieldId) {
      const selected = this.selected.get(fieldId);
      if (!selected || selected.size === 0) return;
   handleFieldStoreEvent(event, data) {
      switch(event) {
         case 'data-loaded':
            this.fieldStoreReady = true;
            this.checkIfBothStoresReady();
            break;
      }
      let groupId = await this.createGroup(fieldId);
      if (!groupId) return;
      await Promise.all(
         Array.from(selected).map(uploadId => this.addToGroup(uploadId, groupId))
      );
      this.selectionHandlers.get(fieldId)?.clearSelection();
      this.a11y.announce(`Created group with ${selected.size} items`);
   }
   async createGroup(fieldId, groupId = null) {
      let field = this.fields.get(fieldId);
      if (!field) return;
   handleUploadStoreEvent(event, data) {
      switch(event) {
         case 'data-loaded':
            this.uploadStoreReady = true;
            this.checkIfBothStoresReady();
            break;
         case 'item-saved':
            this.showSaveIndicator(data.key);
            break;
      }
   }
   checkIfBothStoresReady() {
      if (this.fieldStoreReady && this.uploadStoreReady && !this.hasCheckedForUploads) {
         this.hasCheckedForUploads = true;
         this.checkForStoredUploads();
      }
   }
   async checkForStoredUploads() {
      const allFieldStates = this.fieldStore.getAll();
      const pendingFields = allFieldStates.filter(field => {
         if (!field.uploads) return false;
         // Handle both Set and Array (from IndexedDB)
         const uploadsArray = field.uploads instanceof Set
            ? Array.from(field.uploads)
            : Array.isArray(field.uploads)
               ? field.uploads
               : [];
         return uploadsArray.some(uploadId => {
            const upload = this.uploadStore.get(uploadId);
            return upload && !upload.operationId &&
               ['completed', 'processed', 'local_processing', 'processed-original'].includes(upload.status);
         });
      });
      if (pendingFields.length === 0) return;
      this.showRecoveryNotification(pendingFields);
   }
   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');
      if (!notification) {
         console.error('Restore notification template not found');
         return;
      if (!groupId) {
         groupId = window.generateID('group');
      }
      // 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.`;
      const element = this.createGroupElement(groupId, fieldId);
      if (!element) return null;
      const emptyGroup = field.groupUI.empty;
      if (emptyGroup?.nextSibling) {
         field.groupUI.grid.insertBefore(element, emptyGroup.nextSibling);
      } else {
         message = `${totalUploads} upload(s) from ${pendingFields.length} field(s) can be recovered.`;
         field.groupUI.grid.append(element);
      }
      const detailsEl = notification.querySelector('.restore-details');
      if (detailsEl) {
         detailsEl.textContent = message;
      // Create Sortable for this group's grid
      const grid = element.querySelector('.item-grid');
      if (grid) {
         grid.dataset.groupId = groupId;
         this.createSortable(fieldId, grid, groupId);
      }
      // Build the restoration preview
      for (const field of pendingFields) {
         let fieldTemplate = window.getTemplate('restoreField');
         if (!fieldTemplate) continue;
      let storedData = this.stores.groups.data.has(groupId)
         ? this.stores.groups.data.get(groupId)
         : {};
         // Set field name/title
         const titleEl = fieldTemplate.querySelector('h3');
         if (titleEl) {
            titleEl.textContent = field.config.name || 'Unnamed Field';
         }
      await this.setGroup(groupId, { ...storedData, id: groupId, field: fieldId });
         const itemGrid = fieldTemplate.querySelector('.item-grid.restore');
      return groupId;
   }
         // Process each upload
         for (let uploadId of field.uploads) {
            const upload = this.uploadStore.get(uploadId);
            let uploadItem = window.getTemplate('uploadItem');
            if (!uploadItem) continue;
            //
            //    const imgEl = uploadItem.querySelector('img');
            //    const placeholderEl = uploadItem.querySelector('.image-placeholder');
            //
            const file = await this.getBlobData(upload.id);
            if (file) {
   createGroupElement(groupId, fieldId = null) {
               try {
                  // Create new blob URL from stored data
                  const previewUrl = this.createPreviewUrl(file);
      let data = {
         groupId: groupId,
         fieldId: fieldId,
      }
      let element = this.templates.create('imageGroup', data);
                  let [
                     featured,
                     img,
                     video,
                     preview,
                     details
                  ] = [
                     uploadItem.querySelector('[name="featured"]'),
                     uploadItem.querySelector('img'),
                     uploadItem.querySelector('video'),
                     uploadItem.querySelector('label > span'),
                     uploadItem.querySelector('details')
                  ];
      this.groups.set(groupId, {
         element: element,
         ui: window.uiFromSelectors(this.selectors.group, element)
      });
                  uploadItem.dataset.uploadId = upload.id;
      this.getSelectionHandler(fieldId)?.addWrapper(element);
      return element;
   }
                  uploadItem.dataset.fieldId = field.id;
   async setGroup(groupId, data) {
      const defaults = {
         id: groupId,
         src: window.location.href,
         uploads: [],
         operationId: null,
         field: null,
         fields: {}
      };
      const group = {...defaults, ...data};
      Object.preventExtensions(group);
                  let subtype = this.getSubtypeFromMime(file.type);
                  uploadItem.dataset.subtype = subtype;
                  switch (subtype) {
                     case 'image':
                        [
                           img.src,
                           img.alt
                        ] = [
                           previewUrl,
                           file.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;
                        }
      await this.stores.groups.save(group);
   }
                        preview.innerText = upload.originalFile.name;
                        preview.prepend(icon);
                        img.remove();
                        video.remove();
                        break;
                  }
   async setBulkGroup(fieldId, key, value) {
      let groups = this.stores.groups.filterByIndex({field:fieldId});
      if (groups.length === 0) {
         return;
      }
      let Promises = groups.map(group => {
         group[key] = value;
         this.stores.groups.save(group);
      });
      await Promise.all(Promises);
   }
                  // Store URL for cleanup later
                  uploadItem.dataset.previewUrl = previewUrl;
               } catch (error) {
                  console.warn('Failed to create preview for upload:', upload.id, error);
               }
            }
   async addToGroup(uploadId, groupId = null){
      const upload = this.stores.uploads.get(uploadId);
      const element = this.uploads.get(uploadId);
      if (!upload || !element) return;
      const field = this.fields.get(upload.field);
      if (!field) return;
            // Set upload metadata
            const nameEl = uploadItem.querySelector('summary span');
            if (nameEl) {
               nameEl.textContent = upload.meta?.originalName || 'Unknown file';
            }
      //Check if it's already in this destination, it's probably a reorder
      const isInDOM = element.element?.parentElement !== null;
      if (isInDOM && ((!groupId && upload.group === null) || groupId === upload.group)) {
         this.handleReorder(upload.field, groupId);
         return;
      }
            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 (itemGrid) {
               itemGrid.appendChild(uploadItem);
      if (upload.group) {
         const group = this.stores.groups.get(upload.group);
         if (group) {
            group.uploads = group.uploads.filter(id => id !== uploadId);
            if (group.uploads.length === 0) {
               await this.removeGroup(group.id, false);
            } else {
               await this.stores.groups.save(group);
            }
         }
         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'),
      //clear any selection
      if (element.ui.checkbox) element.ui.checkbox.checked = false;
      // Remove from field-level selection
      const fieldHandler = this.selectionHandlers.get(upload.field);
      if (fieldHandler && fieldHandler.isSelected(uploadId)) {
         fieldHandler.deselect(uploadId);
      }
      if (this.selected.get(upload.field)?.has(uploadId)) {
         this.selected.get(upload.field).delete(uploadId);
      }
      if (element.ui.featured) element.ui.featured.hidden = !groupId;
      if (!groupId) {
         upload.group = null;
      } else {
         if (element.ui.featured) element.ui.featured.name = `${groupId}_featured`;
         let group = this.stores.groups.get(groupId);
         if (group) {
            group.uploads.push(uploadId);
            upload.group = groupId;
            await this.stores.groups.save(group);
         }
      }
      let target = (groupId) ? this.groups.get(groupId)?.ui.grid : field.ui.grid;
      if (target) {
         target.append(element.element);
         if (groupId) {
            await this.handleReorder(upload.field, groupId);
         }
      }
      await this.stores.uploads.save(upload);
   }
   handleDeleteGroup(button) {
      const group = button.closest(this.selectors.group.item);
      if (!group) return;
      let groupId = group.dataset.groupId;
      if (!confirm('Delete this group? Items will be moved back to the upload area.')) {
         return;
      }
      let uploads = this.stores.uploads.filterByIndex({group: groupId});
      Promise.all(
         uploads.map(upload => this.addToGroup(upload.id, null))
      ).then(() => {
         this.removeGroup(groupId, false).then(()=>{});
         this.a11y.announce('Group deleted. Items returned to upload area');
      });
   }
   async removeGroup(groupId, confirm = true) {
      let element = this.groups.get(groupId);
      let group = this.stores.groups.get(groupId);
      if (!group) return;
      let keepUploads = true;
      if (confirm && group.uploads.length > 0) {
         keepUploads = window.confirm('Keep uploads in this group?');
      }
      await Promise.all(
         group.uploads.map(uploadId =>
            keepUploads ? this.addToGroup(uploadId, null) : this.removeUpload(uploadId)
         )
      );
      const field = this.fields.get(group.field);
      if (field) {
         const sortableKey = this.getGroupKey(group.field, groupId);
         const selectionHandler = this.selectionHandlers.get(sortableKey);
         if (selectionHandler?.destroy) {
            selectionHandler.destroy();
         }
         if (this.selectionHandlers.get(group.field) && element && element.element) {
            this.selectionHandlers.get(group.field).removeWrapper(element.element)
         }
         // Existing sortable cleanup
         if (this.sortables.has(sortableKey)) {
            const sortable = this.sortables.get(sortableKey);
            if (sortable?.destroy) {
               sortable.destroy();
            }
            this.sortables.delete(sortableKey);
         }
      }
      if (element?.element) {
         element.element.remove();
      }
      this.groups.delete(groupId);
      await this.stores.groups.delete(groupId);
      this.a11y.announce('Group removed');
   }
   maybeLockUploads(fieldId) {
      let field = this.fields.get(fieldId);
      if (!field || !field.ui.dropZone) return;
      let uploads = this.stores.uploads.filterByIndex({field: fieldId});
      let count = uploads.length;
      let max = field.config.maxFiles??0;
      field.ui.dropZone.hidden = max > 0 && count >= max;
   }
   /*******************************************************************************
    OPERATION METHODS
   *******************************************************************************/
   async handleOperationCancelled(uploads) {
      if (uploads.length === 0) return;
      uploads.forEach(upload => {
         this.removeUpload(upload);
      });
   }
   /*******************************************************************************
    SELECTION HANDLERS
   *******************************************************************************/
   getGroupKey(fieldId, groupId = null) {
      return (groupId) ? `${fieldId}_${groupId}` : `${fieldId}`;
   }
   getSelectionHandler(fieldId) {
      let key = this.getGroupKey(fieldId);
      if (!this.selectionHandlers.has(key)) {
         let field = this.fields.get(fieldId);
         if (!field) return;
         if (field.config.destination !== 'post_group') return;
         let handler = new window.jvbHandleSelection(field.element, {
            selectAll: {
               checkbox: this.selectors.fields.selectAll,
               count: this.selectors.fields.count,
               bulkControls: this.selectors.fields.actions
            },
            item: {
               item: this.selectors.items.item,
               checkbox: this.selectors.items.checkbox,
               idAttribute: 'uploadId',
            },
            wrapper: {
               wrapper: '.preview-wrap, .upload-group',
               id: 'groupId'
            },
         });
         handler.subscribe((event, data) => {
            this.selected.set(fieldId, data.selectedItems);
         });
         this.selectionHandlers.set(key, handler);
      }
      return this.selectionHandlers.get(key);
   }
   updateHandlerItems(fieldId) {
      let handler = this.getSelectionHandler(fieldId);
      if (!handler) return;
      handler.collectItems();
   }
   /*******************************************************************************
    SORTABLE
   *******************************************************************************/
   initSortable(fieldId) {
      if (!window.Sortable) return;
      const field = this.fields.get(fieldId);
      if (!field) return;
      if (!Sortable._multiDragMounted && Sortable.MultiDrag) {
         Sortable.mount(new Sortable.MultiDrag());
         Sortable._multiDragMounted = true;
      }
      // Create sortable for the main preview grid
      this.createSortable(fieldId, field.ui.grid, null);
      // Set up empty-group as native drop zone
      this.initEmptyGroupDropZone(fieldId);
   }
   createSortable(fieldId, gridElement, groupId) {
      if (!gridElement) return null;
      const key = this.getGroupKey(fieldId, groupId);
      // Already exists
      if (this.sortables.has(key)) {
         return this.sortables.get(key);
      }
      const sortable = new Sortable(gridElement, {
         animation: 150,
         draggable: '.item',
         multiDrag: true,
         selectedClass: 'selected',
         avoidImplicitDeselect: true,
         group: { name: fieldId, pull: true, put: true },
         dragClass: 'dragging',
         ignore: '.empty-group',
         onStart: (evt) => {
            // Get the dragged item's ID
            const draggedItem = evt.item;
            const uploadId = draggedItem?.dataset.uploadId;
            // Get the selected items Set for this field
            const selectedItems = this.selected.get(fieldId);
            // If the dragged item isn't selected, select it
            if (uploadId && (!selectedItems || !selectedItems.has(uploadId))) {
               const handler = this.selectionHandlers.get(fieldId);
               if (handler) {
                  handler.select(uploadId);
               }
            }
         },
         onEnd: (evt) => this.sortableDrop(evt, fieldId),
      });
      this.restoreModal.handleOpen();
      this.sortables.set(key, sortable);
      return sortable;
   }
   async handleRestoreUploads() {
      let notification = document.querySelector('dialog.restore-uploads');
      if (!notification) {
         return;
      }
   initEmptyGroupDropZone(fieldId) {
      const field = this.fields.get(fieldId);
      const emptyZone = field?.groupUI?.empty;
      if (!emptyZone) return;
      const selectedUploads = this.getSelectedRestorationUploads(notification);
      if (selectedUploads.length === 0) {
         return;
      }
      await this.restoreSelectedUploads(selectedUploads);
      this.cleanupRestore();
   }
   async handleRestoreAll() {
      let notification = document.querySelector('dialog.restore-uploads');
      if (!notification) {
         return;
      }
      // Gets ALL uploads from notification without checking selection
      const allUploads = [];
      notification.querySelectorAll('.item.upload').forEach(item => {
         let uploadId = item.dataset.uploadId;
         let fieldId = item.dataset.fieldId;
         allUploads.push({ uploadId, fieldId });
      emptyZone.addEventListener('dragover', (e) => {
         e.preventDefault();
         e.stopPropagation();
         e.dataTransfer.dropEffect = 'move';
         emptyZone.classList.add('drag-over');
      });
      await this.restoreSelectedUploads(allUploads);
      this.cleanupRestore();
      emptyZone.addEventListener('dragleave', (e) => {
         if (!emptyZone.contains(e.relatedTarget)) {
            emptyZone.classList.remove('drag-over');
         }
      });
      emptyZone.addEventListener('drop', async (e) => {
         e.preventDefault();
         e.stopPropagation();
         emptyZone.classList.remove('drag-over');
         // Get selected items from our tracking
         const selectedItems = this.selected.get(fieldId);
         if (!selectedItems || selectedItems.size === 0) return;
         const groupId = await this.createGroup(fieldId);
         if (!groupId) return;
         await Promise.all(
            Array.from(selectedItems).map(uploadId => this.addToGroup(uploadId, groupId))
         );
         this.selectionHandlers.get(fieldId)?.clearSelection();
      });
   }
   showSaveIndicator(key) {
      // Optional: show user that state is being saved
   async sortableDrop(evt, fieldId) {
      const dropTarget = evt.to;
      const items = evt.items?.length > 0 ? Array.from(evt.items) : [evt.item];
      const uploadIds = items.map(item => item.dataset.uploadId).filter(Boolean);
      if (uploadIds.length === 0) return;
      const targetGroupId = dropTarget.dataset.groupId || null;
      // Process sequentially to avoid race conditions
      for (const uploadId of uploadIds) {
         await this.addToGroup(uploadId, targetGroupId);
      }
      await this.handleReorder(fieldId, targetGroupId);
      this.selectionHandlers.get(fieldId)?.clearSelection();
   }
   cleanupRestore() {
      this.restoreModal.handleClose();
      this.restoreSelection.destroy();
      this.restoreSelection = null;
      this.restoreModal.destroy();
      this.restoreModal.modal.remove();
      this.restoreModal = null;
   }
   handleReorder(fieldId, groupId = null) {
      let target = (groupId)
         ? this.groups.get(groupId)?.ui.grid
         : this.fields.get(fieldId)?.ui.grid;
   async cleanupStoredUploads() {
      await this.fieldStore.clear();
      await this.uploadStore.clear();
   }
      if (!target) {
         console.log('Couldn\'t Reorder items...');
         return;
      }
      if (!groupId) {
         this.updateHiddenInput(fieldId);
      } else {
         let items = Array.from(target.children)
            .filter(el => el.matches(this.selectors.items.item) && !el.classList.contains('ghost'))
            .map(upload => upload.dataset.uploadId)
            .filter(id => id);
         let group = this.stores.groups.get(groupId);
         if (group) {
            group.uploads = items;
            this.stores.groups.save(group).then(()=>{});
         }
      }
      this.a11y.announce('Items reordered');
   }
   /*******************************************************************************
    * EVENT SYSTEM
    *******************************************************************************/
@@ -3098,49 +2085,99 @@
   notify(event, data = {}) {
      this.subscribers.forEach(cb => {
         try {
            cb(event, data);
         } catch (error) {
            console.error('Subscriber error:', error);
         }
         try { cb(event, data); } catch (e) { console.error('Subscriber error:', e); }
      });
   }
   /*******************************************************************************
    * DESTROY & CLEANUP
    *******************************************************************************/
   /********************************************************************
    CLEANUP
   ********************************************************************/
   destroy() {
      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);
      this.subscribers.clear();
      this.previewUrls.forEach(url => {
         this.revokePreviewUrl(url);
      });
      this.previewUrls.clear();
   }
      if (this.dragController) {
         this.dragController.destroy();
   cleanupAllPreviewUrls() {
      this.previewUrls.forEach(url => this.revokePreviewUrl(url));
      this.previewUrls.clear();
   }
   async handleClearCache() {
      const currentSrc = window.location.href;
      const uploads = this.stores.uploads.filterByIndex({src: currentSrc});
      const groups = this.stores.groups.filterByIndex({src:currentSrc});
      await Promise.all([
         ...uploads.map(upload => this.clearUpload(upload.id)),
         ...groups.map(group => {
            this.groups.get(group.id)?.element?.remove();
            this.groups.delete(group.id);
            return this.stores.groups.delete(group.id);
         })
      ]);
      if (this.restoreModal) {
         this.cleanupRestore();
      }
      this.selectionHandlers.forEach(handler => handler.destroy());
      this.selectionHandlers.clear();
      this.a11y.announce('Cache cleared for this page');
   }
      this.cleanupAllPreviewUrls();
   /**
    * Get files from all upload fields in a form
    * Returns array of {file, fieldName, uploadId, meta}
    */
   async getFilesForForm(formElement) {
      const uploadFields = formElement.querySelectorAll(this.selectors.fields.field);
      const allFiles = [];
      this.sortableInstances.forEach(instance => {
         if (instance?.destroy) instance.destroy();
      });
      this.sortableInstances.clear();
      for (const fieldElement of uploadFields) {
         const fieldId = this.determineFieldId(fieldElement);
         const fieldName = fieldElement.dataset.field;
         const uploads = this.stores.uploads.filterByIndex({ field: fieldId });
      this.uploadElements.clear();
      this.fieldElements.clear();
      this.groupElements.clear();
      this.selected.clear();
      this.subscribers.clear();
         for (const upload of uploads) {
            const file = this.formatFile(upload);
            if (file) {
               allFiles.push({
                  file: file,
                  fieldName: fieldName,
                  uploadId: upload.id,
                  meta: upload.fields || {}
               });
            }
         }
      }
      return allFiles;
   }
   /**
    * Clear all uploads and groups for a specific field from stores
    */
   async clearFieldFromStores(fieldId) {
      const uploads = this.stores.uploads.filterByIndex({ field: fieldId });
      const groups = this.stores.groups.filterByIndex({ field: fieldId });
      // Clear all uploads
      await Promise.all(
         uploads.map(upload => this.clearUpload(upload.id))
      );
      // Clear all groups
      await Promise.all(
         groups.map(group => {
            this.groups.get(group.id)?.element?.remove();
            this.groups.delete(group.id);
            return this.stores.groups.delete(group.id);
         })
      );
   }
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', async function () {
   window.auth.subscribe((event) => {
      if (event === 'auth-loaded') {