From 0e4b986e81f8132a44e61fa8df18860301cc3468 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Thu, 01 Jan 2026 20:31:10 +0000
Subject: [PATCH] =JakeVan preliminary additions
---
assets/js/concise/UploadManagerOld.js | 3141 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 3,141 insertions(+), 0 deletions(-)
diff --git a/assets/js/concise/UploadManagerOld.js b/assets/js/concise/UploadManagerOld.js
new file mode 100644
index 0000000..3f1ae58
--- /dev/null
+++ b/assets/js/concise/UploadManagerOld.js
@@ -0,0 +1,3141 @@
+/**
+ * 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.error = window.jvbError;
+ this.fieldStoreReady = false;
+ this.uploadStoreReady = false;
+ this.hasCheckedForUploads = false;
+ const {fields, uploads} = 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: 'status', keyPath: 'status' },
+ { name: 'groupId', keyPath: 'groupId' },
+ { name: 'attachmentId', keyPath: 'attachmentId' }
+ ],
+ delayFetch: true
+ }
+ ]
+ );
+ this.fieldStore = fields;
+ this.uploadStore = uploads;
+
+ window.jvbUploadBlobs = this.uploadStore;
+
+ // Subscribe to store events
+ this.fieldStore.subscribe(this.handleFieldStoreEvent.bind(this));
+ this.uploadStore.subscribe(this.handleUploadStoreEvent.bind(this));
+
+ // 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 }
+
+ // Selection and UI state
+ this.selected = new Map();
+ this.selectionHandlers = new Map();
+ this.previewUrls = new Set();
+ this.sortableInstances = new Map();
+
+ // Worker for image processing
+ this.initWorker();
+
+ // Notification subscribers
+ this.subscribers = new Set();
+
+ // Selectors
+ this.selectors = {
+ field: {
+ field: '[data-upload-field]',
+ input: 'input[type="file"]',
+ dropZone: '.file-upload-container',
+ preview: '.item-grid.preview',
+ progress: '.image-progress'
+ },
+ groups: {
+ container: '.upload-group',
+ grid: '.item-grid.group',
+ header: '.group-header',
+ selectAll: '[name="select-all-group"]',
+ actions: '.group-actions',
+ count: '.selection-controls .info'
+ },
+ items: {
+ item: '[data-upload-id]',
+ checkbox: '[name*="select-item"]',
+ featured: '[name="featured"]',
+ details: 'details'
+ }
+ };
+
+ this.statusMapping = {
+ 'received': 'Image Received',
+ 'local_processing': 'Processing Image...',
+ 'queued': 'Waiting to upload...',
+ 'uploading': 'Uploading to Server',
+ 'pending': 'Successfully sent to server. In line for further processing.',
+ 'processing': 'Processing on server...',
+ 'completed': 'Upload complete!',
+ 'failed': 'Upload failed (will retry)',
+ '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
+ *******************************************************************************/
+ scanFields(container, autoUpload) {
+ console.log(autoUpload, 'autoUpload');
+ const fields = container.querySelectorAll(this.selectors.field.field);
+ fields.forEach(uploader => this.registerUploader(uploader, autoUpload));
+ }
+
+ registerUploader(uploader, autoUpload) {
+ const fieldId = this.determineFieldId(uploader);
+ const config = this.extractFieldConfig(uploader, autoUpload);
+ const ui = this.buildFieldUI(uploader);
+
+ console.log(config, 'registering with config');
+ // 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, autoUpload) {
+ return {
+ autoUpload: autoUpload,
+ 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;
+
+ // 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.autoUpload) {
+ return;
+ }
+ 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.autoUpload && 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
+ console.log(config);
+ if (config.autoUpload && 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).slice(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
+ }
+
+ /*******************************************************************************
+ * 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]);
+ }
+
+ getStatusProgress(status) {
+ const progress = {
+ 'local_processing': 28,
+ 'queued': 50,
+ 'uploading': 66,
+ 'pending': 75,
+ 'processing': 89,
+ 'completed': 100
+ };
+ return progress[status] || 0;
+ }
+
+
+ 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
+ *******************************************************************************/
+
+ 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()
+ };
+
+ await this.uploadStore.save(uploadData);
+ }
+
+ async getBlobData(uploadId) {
+ const upload = this.uploadStore.get(uploadId);
+ if (!upload?.blobData) return null;
+
+ // 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
+ });
+ }
+
+ /*******************************************************************************
+ HELPER to GET UPLOADED FILES
+ *******************************************************************************/
+ /**
+ * Get all files for a form (searches all upload fields within form)
+ */
+ async getFilesForForm(formElement) {
+ const uploadFields = formElement.querySelectorAll('[data-upload-field]');
+ const allFiles = [];
+
+ for (const field of uploadFields) {
+ const fieldId = this.determineFieldId(field);
+ const files = await this.getFilesForField(fieldId);
+ allFiles.push(...files);
+ }
+
+ return allFiles;
+ }
+
+ /**
+ * Get all files for a specific field
+ */
+ async getFilesForField(fieldId) {
+ const fieldData = this.getFieldData(fieldId);
+ if (!fieldData?.uploads) return [];
+
+ const files = [];
+ const uploadsArray = fieldData.uploads instanceof Set
+ ? Array.from(fieldData.uploads)
+ : fieldData.uploads;
+
+ for (const uploadId of uploadsArray) {
+ const upload = this.uploadStore.get(uploadId);
+ if (!upload) continue;
+
+ // Get the actual File object from blob data
+ const file = await this.getBlobData(uploadId);
+ if (file) {
+ files.push({
+ file: file,
+ uploadId: uploadId,
+ fieldName: fieldData.config.name,
+ meta: upload.meta || {}
+ });
+ }
+ }
+
+ return files;
+ }
+
+ /*******************************************************************************
+ * RECOVERY & RESTORATION
+ *******************************************************************************/
+
+ handleFieldStoreEvent(event, data) {
+ switch(event) {
+ case 'data-loaded':
+ this.fieldStoreReady = true;
+ this.checkIfBothStoresReady();
+ break;
+ }
+ }
+
+ 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;
+
+ await 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;
+ }
+
+ // Build appropriate message
+ let message;
+ if (totalGroups > 0) {
+ let group = totalGroups > 1 ? 'groups' : 'group';
+ let upload = totalUploads > 1 ? 'uploads' : 'upload';
+ message = `${totalGroups} ${group} with ${totalUploads} ${upload} can be restored.`;
+ } else {
+ message = `${totalUploads} upload(s) from ${pendingFields.length} field(s) can be recovered.`;
+ }
+
+ const detailsEl = notification.querySelector('.restore-details');
+ if (detailsEl) {
+ detailsEl.textContent = message;
+ }
+
+ // Build the restoration preview
+ for (const field of pendingFields) {
+ let fieldTemplate = window.getTemplate('restoreField');
+ if (!fieldTemplate) continue;
+
+ // Set field name/title
+ const titleEl = fieldTemplate.querySelector('h3');
+ if (titleEl) {
+ titleEl.textContent = field.config.name || 'Unnamed Field';
+ }
+
+ const itemGrid = fieldTemplate.querySelector('.item-grid.restore');
+
+ // Process each upload
+ for (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) {
+
+ try {
+ // Create new blob URL from stored data
+ const previewUrl = this.createPreviewUrl(file);
+
+ let [
+ featured,
+ img,
+ video,
+ preview,
+ details
+ ] = [
+ uploadItem.querySelector('[name="featured"]'),
+ uploadItem.querySelector('img'),
+ uploadItem.querySelector('video'),
+ uploadItem.querySelector('label > span'),
+ uploadItem.querySelector('details')
+ ];
+
+ uploadItem.dataset.uploadId = upload.id;
+
+
+ uploadItem.dataset.fieldId = field.id;
+
+ let subtype = this.getSubtypeFromMime(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;
+ }
+
+ preview.innerText = upload.originalFile.name;
+ preview.prepend(icon);
+ img.remove();
+ video.remove();
+ break;
+ }
+
+ // Store URL for cleanup later
+ uploadItem.dataset.previewUrl = previewUrl;
+ } catch (error) {
+ console.warn('Failed to create preview for upload:', upload.id, error);
+ }
+ }
+
+ // Set upload metadata
+ const nameEl = uploadItem.querySelector('summary span');
+ if (nameEl) {
+ nameEl.textContent = upload.meta?.originalName || 'Unknown file';
+ }
+
+ const metaEl = uploadItem.querySelector('details');
+ if (metaEl && upload.meta) {
+ metaEl.textContent = `${this.formatBytes(upload.meta.size)} • ${upload.meta.type}`;
+ }
+
+ // Update input IDs safely
+ uploadItem.querySelectorAll('input').forEach(input => {
+ let id = input.id;
+ if (id) {
+ let newId = id + upload.id;
+ let label = input.parentNode.querySelector(`label[for="${id}"]`);
+ input.id = newId;
+ if (label) {
+ label.htmlFor = newId;
+ }
+ }
+ });
+
+ if (itemGrid) {
+ itemGrid.appendChild(uploadItem);
+ }
+ }
+
+ notification.querySelector('.wrap').appendChild(itemGrid);
+ }
+
+ document.querySelector('.field.upload').appendChild(notification);
+ notification = document.querySelector('dialog.restore-uploads');
+ this.restoreModal = new window.jvbModal(notification);
+ this.restoreSelection = new window.jvbHandleSelection({
+ container: notification,
+ ui: {
+ selectAll: notification.querySelector('#select-all-restore'),
+ count: notification.querySelector('.selection-count'),
+ },
+ });
+
+ this.restoreModal.handleOpen();
+
+ }
+
+ async handleRestoreUploads() {
+ let notification = document.querySelector('dialog.restore-uploads');
+ if (!notification) {
+ return;
+ }
+
+ const selectedUploads = this.getSelectedRestorationUploads(notification);
+ if (selectedUploads.length === 0) {
+ return;
+ }
+ await this.restoreSelectedUploads(selectedUploads);
+
+ this.cleanupRestore();
+ }
+
+ 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 });
+ });
+
+ await this.restoreSelectedUploads(allUploads);
+ this.cleanupRestore();
+ }
+
+ showSaveIndicator(key) {
+ // Optional: show user that state is being saved
+ }
+
+ cleanupRestore() {
+ this.restoreModal.handleClose();
+ this.restoreSelection.destroy();
+ this.restoreSelection = null;
+ this.restoreModal.destroy();
+ this.restoreModal.modal.remove();
+ this.restoreModal = null;
+ }
+
+ async cleanupStoredUploads() {
+ await this.fieldStore.clear();
+ await this.uploadStore.clear();
+ }
+
+ /*******************************************************************************
+ * EVENT SYSTEM
+ *******************************************************************************/
+
+ subscribe(callback) {
+ this.subscribers.add(callback);
+ return () => this.subscribers.delete(callback);
+ }
+
+ notify(event, data = {}) {
+ this.subscribers.forEach(cb => {
+ try {
+ cb(event, data);
+ } catch (error) {
+ console.error('Subscriber error:', error);
+ }
+ });
+ }
+
+ /*******************************************************************************
+ * DESTROY & 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);
+
+ if (this.dragController) {
+ this.dragController.destroy();
+ }
+
+ this.selectionHandlers.forEach(handler => handler.destroy());
+ this.selectionHandlers.clear();
+
+ this.cleanupAllPreviewUrls();
+
+ this.sortableInstances.forEach(instance => {
+ if (instance?.destroy) instance.destroy();
+ });
+ this.sortableInstances.clear();
+
+ this.uploadElements.clear();
+ this.fieldElements.clear();
+ this.groupElements.clear();
+ this.selected.clear();
+ this.subscribers.clear();
+ }
+}
+
+// Initialize when DOM is ready
+document.addEventListener('DOMContentLoaded', async function () {
+ window.auth.subscribe((event) => {
+ if (event === 'auth-loaded') {
+ window.jvbUploads = new UploadManager();
+ }
+ });
+});
--
Gitblit v1.10.0