| | |
| | | /** |
| | | * 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 |
| | | // Load dependencies |
| | | this.queue = window.jvbQueue; |
| | | this.a11y = window.jvbA11y; |
| | | this.error = window.jvbError; |
| | | this.notifications = window.jvbNotifications; |
| | | 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; |
| | | |
| | | //Load Datastore |
| | | this.initDB(); |
| | | window.jvbUploadBlobs = this.uploadStore; |
| | | |
| | | //State management |
| | | this.fields = new Map(); |
| | | this.uploads = new Map(); |
| | | this.uploadBlobs = new Map(); |
| | | this.timeouts = new Map(); |
| | | // 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.dragState = { |
| | | isDragging: false, |
| | | primaryItem: null, |
| | | draggedItems: [], |
| | | isMultiDrag: false, |
| | | fieldId: null, |
| | | sourceType: null, |
| | | startTime: null, |
| | | startPosition: { x: 0, y: 0 }, |
| | | currentPosition: { x: 0, y: 0 }, |
| | | currentTarget: null, |
| | | validTarget: null, |
| | | dragPreview: null, |
| | | touchId: null, |
| | | touchMoved: false |
| | | }; |
| | | this.hasGroups = false; |
| | | |
| | | this.selectionHandlers = new Map(); |
| | | this.previewUrls = new Set(); |
| | | this.sortableInstances = new Map(); |
| | | |
| | | //Worker |
| | | this.worker = { |
| | | worker: null, |
| | | timeout: null, |
| | | tasks: new Map(), |
| | | restart: { |
| | | count: 0, |
| | | max: 3, |
| | | }, |
| | | settings: { |
| | | timeout: 10000, //10 seconds per image |
| | | batchSize: 1, |
| | | maxConcurrent: 3, |
| | | restartAfterTimeout: true |
| | | } |
| | | }; |
| | | // Worker for image processing |
| | | this.initWorker(); |
| | | |
| | | //Groups! |
| | | this.touch = { |
| | | x: null, |
| | | y: null |
| | | } |
| | | this.hasBulkContext = document.querySelector('details.uploader')!==null; |
| | | this.isTouching = false; |
| | | this.groups = new Map(); |
| | | this.groupsMeta = new Map(); |
| | | |
| | | //Notification and Subscribers |
| | | // Notification subscribers |
| | | this.subscribers = new Set(); |
| | | |
| | | this.settings = { |
| | | allowedTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'], |
| | | maxFileSize: 5242880, |
| | | maxProcessingTime: 120000, // 2 minutes max for processing |
| | | processingCheckInterval: 5000, // Check every 5 seconds |
| | | smartCompression: true, |
| | | fieldTypes: { |
| | | 'single': { maxFiles: 1, allowMultiple: false }, |
| | | 'gallery': { maxFiles: 20, allowMultiple: true }, |
| | | 'groupable': { maxFiles: 20, allowMultiple: true } |
| | | // 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.acceptedTypes = { |
| | | image: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], |
| | | video: ['video/mp4', 'video/webm', 'video/ogg', 'video/ogv'], |
| | | document: [ |
| | | 'application/pdf', |
| | | 'application/msword', |
| | | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', |
| | | 'text/plain', |
| | | 'text/csv' |
| | | ] |
| | | }; |
| | | |
| | | this.maxSizes = { |
| | | image: 5 * 1024 * 1024, // 5MB |
| | | video: 100 * 1024 * 1024, // 100MB |
| | | document: 10 * 1024 * 1024 // 10MB |
| | | }; |
| | | |
| | | this.statusMapping = { |
| | | 'received': 'Image Received', |
| | | 'local_processing': 'Processing Image...', |
| | |
| | | } |
| | | |
| | | async init() { |
| | | this.initElements(); |
| | | // this.initializeFields(); |
| | | this.initListeners(); |
| | | this.initCompressionWorker(); |
| | | |
| | | // Queue integration - handle completion/failure |
| | | this.queue.subscribe((event, operation) => { |
| | | if (operation.endpoint !== 'uploads') { |
| | | 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': |
| | | this.clearField(operation.data.get('field_key')); |
| | | if (fieldId) this.handleOperationCancelled(fieldId); |
| | | break; |
| | | case 'operation-status': |
| | | const fieldId = operation.data?.field_key || |
| | | (operation.data instanceof FormData ? |
| | | operation.data.get('field_key') : null); |
| | | |
| | | if (fieldId) { |
| | | this.updateFieldStatus(fieldId, 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; |
| | | } |
| | | }); |
| | | this.scanFields(); |
| | | |
| | | window.addEventListener('beforeunload', () => { |
| | | this.cleanupAllPreviewUrls(); |
| | | }); |
| | | } |
| | | |
| | | initElements() { |
| | | this.selectors = { |
| | | field: { |
| | | field: '.field.upload', |
| | | dropZone: '.file-upload-container', |
| | | preview: '.item-grid.preview', |
| | | previewWrap: '.preview-wrap', |
| | | selectAll: '[type=checkbox]#select-all-uploads', |
| | | selectActions: '.selection-actions', |
| | | selectCount: '.selected .info', |
| | | hiddenValue: 'input[type="hidden"]', |
| | | progress: { |
| | | progress: '.progress', |
| | | details: '.progress .details', |
| | | fill: '.progress .fill', |
| | | count: '.progress .count' |
| | | }, |
| | | }, |
| | | item: { |
| | | img: 'img', |
| | | progress: { |
| | | progress: '.progress', |
| | | details: '.progress .details', |
| | | fill: '.progress .fill', |
| | | count: '.progress .count' |
| | | }, |
| | | status: '.status', |
| | | select: '[name*="select-item"]', |
| | | actions: '.item-actions', |
| | | featured: '[name="featured"]', |
| | | meta: '.upload-meta' |
| | | }, |
| | | groups: { |
| | | container: '.item-grid.groups', |
| | | display: '.group-display', |
| | | selectAll: '#select-all-group', |
| | | actions: '.selection-actions', |
| | | info: '.selection-controls .info', |
| | | count: '.selection-count', |
| | | group: '.upload-group', |
| | | empty: '.empty-group' |
| | | initWorker() { |
| | | this.worker = { |
| | | worker: null, |
| | | timeout: null, |
| | | tasks: new Map(), |
| | | restart: { count: 0, max: 3 }, |
| | | settings: { |
| | | timeout: 10000, |
| | | batchSize: 1, |
| | | maxConcurrent: 3, |
| | | restartAfterTimeout: true |
| | | } |
| | | }; |
| | | this.ui = {}; |
| | | } |
| | | |
| | | scanFields() { |
| | | document.querySelectorAll(this.selectors.field.field).forEach(uploader => { |
| | | this.registerUploader(uploader); |
| | | }); |
| | | /******************************************************************************* |
| | | * FIELD MANAGEMENT |
| | | *******************************************************************************/ |
| | | scanFields(container, autoUpload) { |
| | | console.log(autoUpload, 'autoUpload'); |
| | | const fields = container.querySelectorAll(this.selectors.field.field); |
| | | fields.forEach(uploader => this.registerUploader(uploader, autoUpload)); |
| | | } |
| | | |
| | | /** |
| | | * |
| | | * @param {HTMLElement} uploader |
| | | * @param {object} options |
| | | * @param {string} options.id Uploader field ID: defaults to uploader.dataset.fieldId |
| | | * @param {string} options.type Uploader type: defaults to uploader.dataset.type |
| | | * @param {number} options.maxFiles Maximum files to allow: defaults to type defaults |
| | | * @param {boolean} options.multiple Whether to allow multiple uploads |
| | | * @param {number} options.itemID The post or term ID this is for. |
| | | * @param {string} options.mode |
| | | * @returns {string} |
| | | */ |
| | | registerUploader(uploader, options = {}) { |
| | | //Determine if this is for a post, term, content uploader, or option |
| | | let key = uploader.dataset['uploader']??this.determineKey(uploader); |
| | | registerUploader(uploader, autoUpload) { |
| | | const fieldId = this.determineFieldId(uploader); |
| | | const config = this.extractFieldConfig(uploader, autoUpload); |
| | | const ui = this.buildFieldUI(uploader); |
| | | |
| | | uploader.dataset['uploader'] = key; |
| | | 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() |
| | | }; |
| | | |
| | | if (!this.fields.has(key)) { |
| | | let type = uploader.dataset.type??'single'; |
| | | // Save to store (will convert Sets to Arrays automatically) |
| | | this.fieldStore.save(fieldData); |
| | | |
| | | let typeConfig = this.settings.fieldTypes[type]??this.settings.fieldTypes['single']; |
| | | let config = { |
| | | key: key, |
| | | name: uploader.dataset.field, |
| | | ui: {}, |
| | | type: type, |
| | | subtype: uploader.dataset.subtype??'image', |
| | | maxFiles: typeConfig.maxFiles, |
| | | multiple: typeConfig.allowMultiple, |
| | | content: uploader.dataset.content??uploader.closest('dialog')?.dataset.content??uploader.closest('form').dataset.save??false, |
| | | itemID: uploader.dataset.itemID??uploader.closest('dialog')?.dataset.itemID??false, |
| | | context: uploader.dataset.context??uploader.closest('dialog')?.dataset.context??false, |
| | | mode: uploader.dataset.mode??'direct', |
| | | destination: uploader.dataset.destination ?? 'meta', |
| | | ... options |
| | | // 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() |
| | | }; |
| | | |
| | | config.ui = window.uiFromSelectors(this.selectors, uploader); |
| | | config.ui.groups.groups = new Map(); |
| | | |
| | | this.selected.set(key, new Set()); |
| | | this.fields.set(key, config); |
| | | if(config.destination === 'post_group' && !this.hasGroups) { |
| | | this.initGroupListeners(); |
| | | } |
| | | // Initialize selection handler for this field |
| | | this.initSelectionHandler(key, config); |
| | | } |
| | | return key; |
| | | |
| | | return UI; |
| | | } |
| | | |
| | | initSelectionHandler(fieldKey) { |
| | | const field = this.fields.get(fieldKey); |
| | | if (!field) return; |
| | | /******************************************************************************* |
| | | * SORTABLE INITIALIZATION |
| | | *******************************************************************************/ |
| | | initSortable(fieldId) { |
| | | if (!window.Sortable) return; |
| | | |
| | | // Don't reinitialize if already exists |
| | | if (this.selectionHandlers.has(fieldKey)) { |
| | | return this.selectionHandlers.get(fieldKey); |
| | | // Mount MultiDrag plugin once |
| | | if (!Sortable._multiDragMounted && Sortable.MultiDrag) { |
| | | Sortable.mount(new Sortable.MultiDrag()); |
| | | Sortable._multiDragMounted = true; |
| | | } |
| | | |
| | | // Get the container - use preview for uploads in preview, or field for all uploads |
| | | const container = field.ui.field.previewWrap; |
| | | if (!container) { |
| | | console.warn('No container found for selection handler:', fieldKey); |
| | | return; |
| | | } |
| | | const fieldEl = this.fieldElements.get(fieldId); |
| | | if (!fieldEl) return; |
| | | |
| | | const handler = new window.jvbHandleSelection({ |
| | | container: container, |
| | | ui: { |
| | | selectAll: field.ui.field.selectAll, |
| | | bulkControls: field.ui.field.selectActions, |
| | | count: field.ui.field.selectCount |
| | | }, |
| | | itemSelector: '[data-upload-id]', |
| | | checkboxSelector: '[name*="select-item"]', |
| | | // 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); |
| | | }); |
| | | |
| | | handler.subscribe((event, data) => { |
| | | switch(event) { |
| | | case 'item-selected': |
| | | case 'item-deselected': |
| | | case 'range-selected': |
| | | this.selected.set(fieldKey, data.selectedItems); |
| | | break; |
| | | case 'select-all': |
| | | this.handleSelectAll(data.container, data.selected); |
| | | break; |
| | | // 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); |
| | | } |
| | | }); |
| | | } |
| | | }); |
| | | |
| | | this.selectionHandlers.set(fieldKey, handler); |
| | | |
| | | return handler; |
| | | } |
| | | |
| | | addGroupSelectionHandler(fieldId, groupId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | 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); |
| | | |
| | | const group = this.groups.get(groupId); |
| | | if (!group) return; |
| | | // Determine drop target type |
| | | const targetType = this.getDropTargetType(dropTarget); |
| | | |
| | | let handlerKey = fieldId+'_'+groupId; |
| | | // Don't reinitialize if already exists |
| | | if (this.selectionHandlers.has(handlerKey)) { |
| | | return this.selectionHandlers.get(handlerKey); |
| | | } |
| | | switch (targetType) { |
| | | case 'empty-group': |
| | | this.handleDropToEmptyGroup(items, uploadIds, fieldId); |
| | | break; |
| | | |
| | | // Get the container - use preview for uploads in preview, or field for all uploads |
| | | const container = group.element; |
| | | if (!container) { |
| | | console.warn('No container found for selection handler:', fieldKey); |
| | | return; |
| | | } |
| | | case 'preview': |
| | | this.handleDropToPreview(items, uploadIds, fieldId); |
| | | break; |
| | | |
| | | const handler = new window.jvbHandleSelection({ |
| | | container: container, |
| | | ui: { |
| | | selectAll: container.querySelector(this.selectors.groups.selectAll), |
| | | bulkControls: container.querySelector(this.selectors.groups.actions), |
| | | count: container.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; |
| | | } |
| | | |
| | | removeSelectionHandler(fieldId, groupId = null) { |
| | | let key = fieldId; |
| | | if (groupId) { |
| | | key = key+'_'+groupId; |
| | | } |
| | | if (this.selectionHandlers.has(key)) { |
| | | let handler = this.selectionHandlers.get(key); |
| | | handler.destroy(); |
| | | this.selectionHandlers.delete(key); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Builds a key from the uploader, built from the Content Type, ItemID, and FieldName |
| | | * @param uploader |
| | | * @returns {string} |
| | | */ |
| | | determineKey(uploader) { |
| | | let content = uploader.dataset.content??uploader.closest('dialog')?.dataset.content??uploader.closest('form').dataset.save??''; |
| | | let itemID = uploader.dataset.itemID??uploader.closest('dialog')?.dataset.itemID??''; |
| | | let field = uploader.dataset.field; |
| | | return `${content}_${itemID}_${field}`; |
| | | } |
| | | |
| | | /** |
| | | * |
| | | * @param {HTMLElement} element |
| | | */ |
| | | getFieldIdFromElement(element) { |
| | | let field = element.closest(this.selectors.field.field); |
| | | if (!field) { |
| | | return; |
| | | } |
| | | return field.dataset.uploader??this.determineKey(field); |
| | | } |
| | | |
| | | getFieldFromElement(element) { |
| | | let id = this.getFieldIdFromElement(element); |
| | | return (this.fields.has(id)) ? this.fields.get(id) : false; |
| | | } |
| | | |
| | | getUploadFromElement(element) { |
| | | let id = this.getUploadIdFromElement(element); |
| | | return (this.uploads.has(id)) ? this.uploads.get(id) : false; |
| | | } |
| | | |
| | | getUploadIdFromElement(element) { |
| | | let upload = element.closest('[data-upload-id]'); |
| | | return upload?.dataset.uploadId || null; |
| | | } |
| | | |
| | | getGroupFromElement(element) { |
| | | let groupId = this.getGroupIdFromElement(element); |
| | | return (this.groups.has(groupId)) ? this.groups.get(groupId) : false; |
| | | } |
| | | getGroupIdFromElement(element) { |
| | | return element.dataset.groupId??element.closest('[data-group-id]')?.dataset.groupId??element.closest(':has([data-group-id])')?.querySelector('[data-group-id]')?.dataset.groupId??null; |
| | | } |
| | | |
| | | getModalType(field) { |
| | | // Safety check for field.ui |
| | | if (!field || !field.ui || !field.ui.field || !field.ui.field.field) { |
| | | return null; |
| | | } |
| | | |
| | | const dialog = field.ui.field.field.closest('dialog'); |
| | | if (!dialog) return null; |
| | | |
| | | if (dialog.classList.contains('edit')) return 'edit'; |
| | | if (dialog.classList.contains('create')) return 'create'; |
| | | if (dialog.classList.contains('bulkEdit')) return 'bulkEdit'; |
| | | |
| | | return dialog.className; |
| | | } |
| | | |
| | | getStatusText(status) { |
| | | return this.statusMapping[status] || status; |
| | | } |
| | | |
| | | getStatusIcon(status) { |
| | | return window.getIcon(this.queue.icons[status]); |
| | | } |
| | | getStatusProgress(status) { |
| | | switch (status) { |
| | | case 'local_processing': |
| | | return 28; |
| | | case 'queued': |
| | | return 50; |
| | | case 'uploading': |
| | | return 66; |
| | | case 'pending': |
| | | return 75; |
| | | case 'processing': |
| | | return 89; |
| | | case 'completed': |
| | | return 100; |
| | | case 'group': |
| | | this.handleDropToGroup(items, uploadIds, dropTarget, sourceTarget, fieldId); |
| | | break; |
| | | default: |
| | | return 0; |
| | | // Fallback: return to preview |
| | | this.handleDropToPreview(items, uploadIds, fieldId); |
| | | break; |
| | | } |
| | | |
| | | // Update UI |
| | | this.updateSortableState(dropTarget); |
| | | if (sourceTarget !== dropTarget) { |
| | | this.updateSortableState(sourceTarget); |
| | | } |
| | | } |
| | | |
| | | /****************************************************************************** |
| | | LISTENERS |
| | | ******************************************************************************/ |
| | | initListeners() { |
| | | this.clickHandler = this.handleClick.bind(this); |
| | | this.changeHandler = this.handleChange.bind(this); |
| | | |
| | | if (this.hasBulkContext) { |
| | | this.pasteHandler = this.handlePaste.bind(this); |
| | | document.addEventListener('paste', this.pasteHandler); |
| | | /** |
| | | * 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); |
| | | window.addEventListener('beforeunload', this.handleBeforeUnload.bind(this)); |
| | | |
| | | 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); |
| | | } |
| | | clearListeners() { |
| | | document.removeEventListener('click', this.clickHandler); |
| | | document.removeEventListener('change', this.changeHandler); |
| | | if (this.hasBulkContext) { |
| | | document.removeEventListener('paste', this.pasteHandler); |
| | | |
| | | handleExternalDragLeave(e) { |
| | | const dropZone = e.target.closest(this.selectors.field.dropZone); |
| | | if (dropZone && !dropZone.contains(e.relatedTarget)) { |
| | | dropZone.classList.remove('dragover'); |
| | | } |
| | | } |
| | | |
| | | initGroupListeners() { |
| | | this.hasGroups = true; |
| | | |
| | | this.dragStartHandler = this.handleDragStart.bind(this); |
| | | this.dragEndHandler = this.handleDragEnd.bind(this); |
| | | this.dragEnterHandler = this.handleDragEnter.bind(this); |
| | | this.dragOverHandler = this.handleDragOver.bind(this); |
| | | this.dragLeaveHandler = this.handleDragLeave.bind(this); |
| | | this.dropHandler = this.handleDrop.bind(this); |
| | | |
| | | this.touchStartHandler = this.handleTouchStart.bind(this); |
| | | this.touchMoveHandler = this.handleTouchMove.bind(this); |
| | | this.touchEndHandler = this.handleTouchEnd.bind(this); |
| | | this.touchCancelHandler = this.handleTouchCancel.bind(this); |
| | | |
| | | document.addEventListener('dragstart', this.dragStartHandler); |
| | | document.addEventListener('dragend', this.dragEndHandler); |
| | | document.addEventListener('dragenter', this.dragEnterHandler); |
| | | document.addEventListener('dragover', this.dragOverHandler); |
| | | document.addEventListener('dragleave', this.dragLeaveHandler); |
| | | document.addEventListener('drop', this.dropHandler); |
| | | |
| | | document.addEventListener('touchstart', this.touchStartHandler, { passive: false }); |
| | | document.addEventListener('touchmove', this.touchMoveHandler, { passive: false }); |
| | | document.addEventListener('touchend', this.touchEndHandler, { passive: false }); |
| | | document.addEventListener('touchcancel', this.touchCancelHandler, { passive: false }); |
| | | |
| | | document.addEventListener('input', (e) => { |
| | | if (e.target.matches('.fields.group input, .fields.group textarea')) { |
| | | this.handleGroupMetadataChange(e); |
| | | } |
| | | }); |
| | | 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'); |
| | | } |
| | | } |
| | | handleGroupMetadataChange(e) { |
| | | if (!e.target.closest('.fields.group')) return; |
| | | |
| | | const groupElement = e.target.closest('[data-group-id]'); |
| | | if (!groupElement) return; |
| | | |
| | | const fieldId = groupElement.dataset.fieldId; |
| | | this.persistFieldState(fieldId); |
| | | 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'; |
| | | } |
| | | } |
| | | clearGroupListeners() { |
| | | document.removeEventListener('dragstart', this.dragStartHandler); |
| | | document.removeEventListener('dragend', this.dragEndHandler); |
| | | document.removeEventListener('dragenter', this.dragEnterHandler); |
| | | document.removeEventListener('dragover', this.dragOverHandler); |
| | | document.removeEventListener('dragleave', this.dragLeaveHandler); |
| | | document.removeEventListener('drop', this.dropHandler); |
| | | |
| | | document.removeEventListener('touchstart', this.touchStartHandler, { passive: false }); |
| | | document.removeEventListener('touchmove', this.touchMoveHandler, { passive: false }); |
| | | document.removeEventListener('touchend', this.touchEndHandler, { passive: false }); |
| | | document.removeEventListener('touchcancel', this.touchCancelHandler, { passive: false }); |
| | | handleExternalDrop(e) { |
| | | const dropZone = e.target.closest(this.selectors.field.dropZone); |
| | | if (!dropZone) return; |
| | | |
| | | e.preventDefault(); |
| | | dropZone.classList.remove('dragover'); |
| | | |
| | | const files = Array.from(e.dataTransfer.files); |
| | | if (files.length === 0) return; |
| | | |
| | | const fieldId = this.getFieldIdFromElement(dropZone); |
| | | if (fieldId) { |
| | | this.processFiles(fieldId, files); |
| | | this.a11y.announce(`${files.length} file(s) dropped for upload`); |
| | | } |
| | | } |
| | | |
| | | /******************************************************************************* |
| | | * CLICK & CHANGE HANDLERS |
| | | *******************************************************************************/ |
| | | |
| | | handleClick(e) { |
| | | if (!e.target.closest(this.selectors.field.field)) { |
| | | return; |
| | | } |
| | | let actionButton = window.targetCheck(e, '[data-action]'); |
| | | |
| | | if (!actionButton) { |
| | | return; |
| | | } |
| | | let action = actionButton.dataset.action; |
| | | |
| | | let field = this.getFieldFromElement(actionButton); |
| | | let selected = this.getCurrentSelection(field.key); |
| | | let group = this.getGroupFromElement(actionButton); |
| | | let groupId = (group) ? group.id : false; |
| | | let isItem = actionButton.closest('[data-upload-id]'); |
| | | let items = 'upload'; |
| | | let reference = 'it'; |
| | | if (isItem) { |
| | | selected = [isItem.dataset.uploadId]; |
| | | } else { |
| | | if (selected.length > 1) { |
| | | items = 'uploads'; |
| | | reference = 'them'; |
| | | // 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(); |
| | | } |
| | | } |
| | | |
| | | let deleteUploads; |
| | | |
| | | switch (action) { |
| | | case 'add-to-group': |
| | | //Create from selection |
| | | //Check for groupId, if no group id, create new group with selection |
| | | if (selected.length === 0) { |
| | | //Nothing to move |
| | | return; |
| | | } |
| | | if (!groupId) { |
| | | group = this.createGroup(field.key); |
| | | groupId = group.id; |
| | | } |
| | | this.addSelectionToGroup(group.element); |
| | | |
| | | break; |
| | | case 'remove-from-group': |
| | | if (selected.length === 0) { |
| | | return; |
| | | } |
| | | //confirm if they want to keep uploads |
| | | //remove selection from group |
| | | |
| | | deleteUploads = !confirm(`Would you like to keep the ${items}, just remove ${reference} from this group?`); |
| | | selected.forEach(upload => { |
| | | this.removeFromGroup(field.key, upload, groupId); |
| | | if (deleteUploads) { |
| | | this.removeUpload(field.key, upload); |
| | | } |
| | | }); |
| | | break; |
| | | case 'delete-upload': |
| | | if (selected.length === 0) { |
| | | return; |
| | | } |
| | | //delete selection |
| | | deleteUploads = false; |
| | | reference = (reference === 'them') ? 'these' : 'this'; |
| | | if (confirm(`Are you sure you want to delete ${reference} ${items}?`)) { |
| | | deleteUploads = true; |
| | | } |
| | | selected.forEach(upload => { |
| | | this.removeFromGroup(field.key, upload, groupId); |
| | | if (deleteUploads) { |
| | | this.removeUpload(field.key, upload); |
| | | } |
| | | }); |
| | | break; |
| | | case 'delete-group': |
| | | //delete entire group |
| | | if (group.uploads.length > 0) { |
| | | |
| | | deleteUploads = confirm(`Do you want to remove all uploads in the group, too?`); |
| | | if (deleteUploads) { |
| | | group.uploads.forEach(upload => { |
| | | this.removeUpload(field.key, upload); |
| | | }); |
| | | } else { |
| | | group.uploads.forEach(upload => { |
| | | this.addImageToGroup(upload); |
| | | }) |
| | | } |
| | | } |
| | | this.removeGroup(groupId, false); |
| | | break; |
| | | case 'upload': |
| | | //upload groups |
| | | e.preventDefault(); |
| | | this.submitUploads(field.key); |
| | | break; |
| | | case 'restore': |
| | | let notification = document.querySelector('dialog.restore-uploads'); |
| | | if (!notification) { |
| | | return; |
| | | } |
| | | //restore selected uploads |
| | | const selectedUploads = this.getSelectedRestorationUploads(notification); |
| | | if (selectedUploads.length === 0) { |
| | | // this.notifications.add('No uploads selected for restoration', 'warning'); |
| | | return; |
| | | } |
| | | this.restoreSelectedUploads(selectedUploads); |
| | | |
| | | this.restoreModal.handleClose(); |
| | | this.restoreSelection.destroy(); |
| | | this.restoreSelection = null; |
| | | // Clean up blob URLs before removing notification |
| | | this.cleanupRestoreNotificationUrls(notification); |
| | | notification.remove(); |
| | | break; |
| | | case 'clear-cache': |
| | | if (!confirm(`Save these uploads for later?`)) { |
| | | //clear cached uploads |
| | | this.cleanupStoredRestoration(); |
| | | } |
| | | |
| | | this.restoreModal.handleClose(); |
| | | this.restoreSelection.destroy(); |
| | | this.restoreSelection = null; |
| | | this.restoreModal.destroy(); |
| | | this.restoreModal.modal.remove(); |
| | | |
| | | break; |
| | | // Group actions |
| | | const actionButton = e.target.closest('[data-action]'); |
| | | if (actionButton) { |
| | | this.handleAction(actionButton); |
| | | } |
| | | } |
| | | handleChange(e) { |
| | | if (!e.target.closest(this.selectors.field.field) || e.target.classList.contains(this.selectors.field.hiddenValue)) { |
| | | return; |
| | | } |
| | | e.preventDefault(); |
| | | |
| | | if (window.targetCheck(e, '[type="file"]')) { |
| | | let field = this.getFieldFromElement(e.target); |
| | | if (!field) { |
| | | console.warn('File change on unregistered field: ', field.key) |
| | | 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; |
| | | } |
| | | |
| | | const files = Array.from(e.target.files); |
| | | if (files.length === 0) return; |
| | | |
| | | this.processFiles(field.key, files); |
| | | e.target.value = ''; |
| | | } else if (e.target.closest('.upload-meta')) { |
| | | e.preventDefault(); |
| | | let name = e.target.name; |
| | | let value = e.target.value; |
| | | let upload = this.getUploadFromElement(e.target); |
| | | upload.changes[name] = value; |
| | | this.uploads.set(upload.id, upload); |
| | | this.persistFieldState(upload.fieldId); |
| | | |
| | | //It's meta! |
| | | //TODO: |
| | | //Step 1) determine whether the images have already been sent to the server. If not, we must wait until they have been |
| | | //Step 2) Queue the Meta changes. No need to wait, the Queue.js will handle any debouncing/timeouts |
| | | //Ensure the dependencies have all operations stored to the field that the images were uploaded with (can be multiple) |
| | | //Send to server for processing |
| | | } else if (e.target.closest('.group.fields')) { |
| | | let group = this.getGroupFromElement(e.target); |
| | | let name = e.target.name; |
| | | group.changes[name] = e.target.value; |
| | | |
| | | this.persistFieldState(group.fieldId); |
| | | this.groups.set(group.id, group); |
| | | } |
| | | } |
| | | |
| | | handlePaste(e) { |
| | | window.debouncer.schedule( |
| | | 'imagePaste', |
| | | () => { |
| | | const items = Array.from(e.clipboardData.items); |
| | | const imageItems = items.filter(item => item.type.startsWith('image/')); |
| | | |
| | | if (imageItems.length === 0) return; |
| | | |
| | | e.preventDefault(); |
| | | |
| | | const fieldId = this.getFieldIdFromElement(e.target); |
| | | if (!fieldId) return; |
| | | |
| | | // Convert clipboard items to files |
| | | const files = []; |
| | | imageItems.forEach((item, index) => { |
| | | const file = item.getAsFile(); |
| | | if (file) { |
| | | // Rename for clarity |
| | | const newFile = new File([file], `pasted_image_${index + 1}.png`, { |
| | | type: file.type, |
| | | lastModified: Date.now() |
| | | }); |
| | | files.push(newFile); |
| | | } |
| | | }); |
| | | |
| | | if (files.length > 0) { |
| | | this.processFiles(fieldId, files); |
| | | } |
| | | }, |
| | | 100 |
| | | ); |
| | | } |
| | | |
| | | isTouchOnFormElement(target) { |
| | | // Check if target is a form element or inside one |
| | | const formElements = [ |
| | | 'input', 'button', 'label', 'select', 'textarea', |
| | | ]; |
| | | |
| | | return formElements.some(selector => { |
| | | return target.matches(selector) || target.closest(selector); |
| | | }); |
| | | } |
| | | /**** DRAG AND TOUCH *****/ |
| | | startDragOperation(config) { |
| | | const { |
| | | primaryElement, |
| | | sourceType, |
| | | startPosition, |
| | | event |
| | | } = config; |
| | | |
| | | const uploadId = this.getUploadIdFromElement(primaryElement); |
| | | const fieldId = this.getFieldIdFromElement(primaryElement); |
| | | |
| | | // Determine what items to drag |
| | | const draggedItems = this.getDraggedItems(primaryElement); |
| | | |
| | | // Initialize drag state |
| | | this.dragState = { |
| | | primaryItem: uploadId, |
| | | draggedItems: draggedItems, |
| | | isDragging: true, |
| | | isMultiDrag: draggedItems.length > 1, |
| | | fieldId: fieldId, |
| | | sourceType: sourceType, |
| | | startTime: Date.now(), |
| | | startPosition: startPosition, |
| | | currentPosition: startPosition, |
| | | currentTarget: null, |
| | | validTarget: null, |
| | | dragPreview: null, |
| | | touchId: sourceType === 'touch' ? event.touches[0]?.identifier : null, |
| | | touchMoved: false |
| | | }; |
| | | |
| | | // Create drag preview |
| | | this.createDragPreview(primaryElement); |
| | | |
| | | // Apply dragging state |
| | | this.applyDraggingState(true); |
| | | |
| | | const announceText = this.dragState.isMultiDrag |
| | | ? `Started dragging ${draggedItems.length} items` |
| | | : 'Started dragging item'; |
| | | |
| | | this.a11y.announce(announceText); |
| | | this.provideDragFeedback('start'); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | updateDragOperation(position, elementUnderPointer) { |
| | | if (!this.dragState.isDragging) return; |
| | | |
| | | const { sourceType, startPosition } = this.dragState; |
| | | |
| | | // Update position |
| | | this.dragState.currentPosition = position; |
| | | |
| | | // Check for significant movement (touch) |
| | | if (sourceType === 'touch' && !this.dragState.touchMoved) { |
| | | const deltaX = Math.abs(position.x - startPosition.x); |
| | | const deltaY = Math.abs(position.y - startPosition.y); |
| | | |
| | | if (deltaX > 10 || deltaY > 10) { |
| | | this.dragState.touchMoved = true; |
| | | } |
| | | } |
| | | |
| | | // Update preview and target |
| | | this.updateDragPreview(position); |
| | | this.updateDropTarget(elementUnderPointer); |
| | | } |
| | | |
| | | endDragOperation(elementUnderPointer = null) { |
| | | if (!this.dragState.isDragging) return; |
| | | |
| | | const wasSuccessful = (this.dragState.sourceType === 'drag' || this.dragState.touchMoved) && |
| | | this.dragState.validTarget; |
| | | |
| | | // Process drop if valid - but only here, not in handleDrop |
| | | if (wasSuccessful && this.dragState.validTarget) { |
| | | this.processItemDrop({ |
| | | itemIds: this.dragState.draggedItems, |
| | | targetElement: this.dragState.validTarget, |
| | | fieldId: this.dragState.fieldId, |
| | | dropType: this.dragState.isMultiDrag ? 'multiple' : 'single', |
| | | sourceType: this.dragState.sourceType |
| | | }); |
| | | } |
| | | |
| | | // Cleanup |
| | | this.cleanupDragOperation(); |
| | | |
| | | const announceText = wasSuccessful |
| | | ? (this.dragState.isMultiDrag ? `Moved ${this.dragState.draggedItems.length} items` : 'Item moved') |
| | | : 'Drag cancelled'; |
| | | |
| | | this.a11y.announce(announceText); |
| | | } |
| | | |
| | | /** |
| | | * Shared method to process any drop operation (drag or touch) |
| | | * @param {Object} dropData - Standardized drop data |
| | | * @returns {boolean} Success status |
| | | */ |
| | | processItemDrop(dropData) { |
| | | const { itemIds, targetElement, fieldId, dropType, sourceType } = dropData; |
| | | |
| | | if (!itemIds?.length || !targetElement || !fieldId) { |
| | | return false; |
| | | } |
| | | |
| | | let isPreviewDrop = targetElement.classList.contains('preview') && |
| | | targetElement.classList.contains('item-grid'); |
| | | let actualTarget = targetElement; |
| | | |
| | | // Handle empty group drops |
| | | if (targetElement.classList.contains('empty-group')) { |
| | | let group = this.createGroup(fieldId); |
| | | if (!group) { |
| | | console.error('Failed to create group'); |
| | | return false; |
| | | } |
| | | actualTarget = group.grid; |
| | | isPreviewDrop = false; |
| | | } |
| | | |
| | | itemIds.forEach(uploadId => { |
| | | this.addImageToGroup(uploadId, isPreviewDrop ? null : actualTarget, false); |
| | | }); |
| | | |
| | | const field = this.fields.get(fieldId); |
| | | if (field) { |
| | | this.clearAllSelections(field); |
| | | } |
| | | |
| | | this.persistFieldState(fieldId); |
| | | |
| | | const announceText = dropType === 'multiple' |
| | | ? `Moved ${itemIds.length} images to ${isPreviewDrop ? 'main area' : 'group'}` |
| | | : `Image moved to ${isPreviewDrop ? 'main area' : 'group'}`; |
| | | |
| | | this.a11y.announce(announceText); |
| | | this.provideFeedback(sourceType, 'success', { |
| | | count: itemIds.length, |
| | | isMultiple: dropType === 'multiple' |
| | | }); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | |
| | | |
| | | cleanupDragOperation() { |
| | | if (this.dragState.dragPreview) { |
| | | this.dragState.dragPreview.remove(); |
| | | } |
| | | |
| | | this.applyDraggingState(false); |
| | | this.clearDropTargetStates(); |
| | | |
| | | // Reset state |
| | | this.dragState.isDragging = false; |
| | | this.dragState.dragPreview = null; |
| | | this.dragState.draggedItems = []; |
| | | } |
| | | |
| | | /** |
| | | * Determine what items to drag (single or multiple selection) |
| | | */ |
| | | getDraggedItems(element) { |
| | | const selectedUploads = this.getSelectedUploads(element); |
| | | const primaryUploadId = element.dataset.uploadId; |
| | | |
| | | // If we have multiple selections and primary is selected, drag all |
| | | if (selectedUploads.length > 1 && selectedUploads.includes(primaryUploadId)) { |
| | | return selectedUploads; |
| | | } |
| | | |
| | | // Otherwise, just drag the primary item |
| | | return [primaryUploadId]; |
| | | } |
| | | |
| | | /** |
| | | * Apply/remove dragging visual state to items |
| | | */ |
| | | applyDraggingState(isDragging) { |
| | | this.dragState.draggedItems.forEach(uploadId => { |
| | | const element = document.querySelector(`[data-upload-id="${uploadId}"]`); |
| | | if (element) { |
| | | element.classList.toggle('dragging', isDragging); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Create drag preview element |
| | | */ |
| | | /** |
| | | * Create drag preview element from template |
| | | */ |
| | | createDragPreview() { |
| | | const { draggedItems, sourceType } = this.dragState; |
| | | |
| | | // Get the template |
| | | const template = window.getTemplate('dragPreview'); |
| | | if (!template) { |
| | | console.error('Drag preview template not found'); |
| | | return; |
| | | } |
| | | |
| | | this.dragState.dragPreview = template; |
| | | const itemsContainer = template.querySelector('.drag-items'); |
| | | const countBadge = template.querySelector('.drag-count'); |
| | | |
| | | // Set data attributes for CSS targeting |
| | | template.dataset.source = sourceType; |
| | | |
| | | // Handle single vs multi-item |
| | | const itemCount = draggedItems.length; |
| | | |
| | | if (itemCount > 1) { |
| | | // Multi-item: show count and stack up to 3 items |
| | | template.dataset.count = itemCount; |
| | | countBadge.dataset.count = itemCount; |
| | | countBadge.hidden = false; |
| | | |
| | | const displayCount = Math.min(itemCount, 3); |
| | | for (let i = 0; i < displayCount; i++) { |
| | | const uploadId = draggedItems[i]; |
| | | const uploadElement = document.querySelector(`[data-upload-id="${uploadId}"]`); |
| | | |
| | | if (uploadElement) { |
| | | const clonedItem = uploadElement.cloneNode(true); |
| | | clonedItem.dataset.uploadId = `${uploadId}-preview`; |
| | | // Remove interactive elements from clone |
| | | clonedItem.querySelectorAll('input, button, details').forEach(el => el.remove()); |
| | | itemsContainer.appendChild(clonedItem); |
| | | } |
| | | } |
| | | } else { |
| | | // Single item: just clone it |
| | | const uploadElement = document.querySelector(`[data-upload-id="${draggedItems[0]}"]`); |
| | | if (uploadElement) { |
| | | const clonedItem = uploadElement.cloneNode(true); |
| | | clonedItem.dataset.uploadId = `${draggedItems[0]}-preview`; |
| | | // Remove interactive elements from clone |
| | | clonedItem.querySelectorAll('input, button, details').forEach(el => el.remove()); |
| | | itemsContainer.appendChild(clonedItem); |
| | | } |
| | | } |
| | | |
| | | // Add to DOM |
| | | document.body.appendChild(this.dragState.dragPreview); |
| | | |
| | | // Position immediately at start position |
| | | this.updateDragPreview(this.dragState.startPosition); |
| | | } |
| | | |
| | | /** |
| | | * Update drag preview position |
| | | */ |
| | | updateDragPreview(position) { |
| | | if (!this.dragState.dragPreview) return; |
| | | |
| | | const preview = this.dragState.dragPreview; |
| | | |
| | | // Determine offset based on source type |
| | | let offset; |
| | | if (this.dragState.sourceType === 'touch') { |
| | | // For touch, offset up and to the left so finger doesn't cover preview |
| | | offset = this.dragState.isMultiDrag |
| | | ? { x: -60, y: -80 } |
| | | : { x: -50, y: -60 }; |
| | | } else { |
| | | // For mouse, smaller offset |
| | | offset = this.dragState.isMultiDrag |
| | | ? { x: 15, y: 15 } |
| | | : { x: 10, y: 10 }; |
| | | } |
| | | |
| | | // Position the preview at the current pointer position with offset |
| | | preview.style.left = `${position.x + offset.x}px`; |
| | | preview.style.top = `${position.y + offset.y}px`; |
| | | } |
| | | |
| | | /** |
| | | * Update drop target highlighting |
| | | */ |
| | | updateDropTarget(elementUnderPointer) { |
| | | // Clear previous target |
| | | if (this.dragState.currentTarget) { |
| | | this.clearDropTargetState(this.dragState.currentTarget); |
| | | } |
| | | |
| | | // Find valid drop target |
| | | const validTarget = this.findValidDropTarget(elementUnderPointer); |
| | | |
| | | // Update state |
| | | this.dragState.currentTarget = elementUnderPointer; |
| | | this.dragState.validTarget = validTarget; |
| | | |
| | | // Apply visual feedback |
| | | if (validTarget) { |
| | | this.applyDropTargetState(validTarget); |
| | | |
| | | // Haptic feedback for touch |
| | | if (this.dragState.sourceType === 'touch' && navigator.vibrate) { |
| | | const pattern = this.dragState.isMultiDrag ? [25, 10, 25] : [25]; |
| | | navigator.vibrate(pattern); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Find valid drop target from element |
| | | */ |
| | | findValidDropTarget(element) { |
| | | const target = element?.closest('.item-grid.group, .empty-group, .item-grid.preview'); |
| | | return target && this.getFieldIdFromElement(target) === this.dragState.fieldId ? target : null; |
| | | } |
| | | |
| | | /** |
| | | * Apply drop target visual state |
| | | */ |
| | | applyDropTargetState(target) { |
| | | target.classList.add('dragover'); |
| | | |
| | | if (this.dragState.isMultiDrag) { |
| | | target.classList.add('multi-drop'); |
| | | target.setAttribute('data-item-count', this.dragState.draggedItems.length); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Clear drop target state from element |
| | | */ |
| | | clearDropTargetState(target) { |
| | | target.classList.remove('dragover', 'multi-drop'); |
| | | target.removeAttribute('data-item-count'); |
| | | } |
| | | |
| | | /** |
| | | * Clear all drop target states |
| | | */ |
| | | clearDropTargetStates() { |
| | | document.querySelectorAll('.dragover').forEach(el => { |
| | | el.classList.remove('dragover', 'multi-drop'); |
| | | el.removeAttribute('data-item-count'); |
| | | }); |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Provide feedback for drag operations |
| | | */ |
| | | provideDragFeedback(type) { |
| | | const hapticPatterns = { |
| | | start: [50], |
| | | success: this.dragState.isMultiDrag ? [30, 20, 30] : [50], |
| | | error: [100, 50, 100], |
| | | warning: [50] |
| | | }; |
| | | |
| | | // Haptic feedback (vibration on supported devices) |
| | | if (navigator.vibrate && hapticPatterns[type]) { |
| | | navigator.vibrate(hapticPatterns[type]); |
| | | } |
| | | |
| | | // Visual feedback |
| | | const feedback = document.createElement('div'); |
| | | feedback.className = `drag-feedback ${type}`; |
| | | feedback.style.cssText = ` |
| | | position: fixed; |
| | | top: 50%; |
| | | left: 50%; |
| | | transform: translate(-50%, -50%); |
| | | padding: 1rem 2rem; |
| | | background: var(--${type === 'success' ? 'success' : type === 'error' ? 'danger' : 'warning'}); |
| | | color: white; |
| | | border-radius: var(--radius); |
| | | z-index: 10001; |
| | | animation: feedbackPulse 0.3s ease; |
| | | pointer-events: none; |
| | | `; |
| | | |
| | | const icons = { |
| | | start: '↕️', |
| | | success: '✓', |
| | | error: '✗', |
| | | warning: '⚠' |
| | | }; |
| | | |
| | | feedback.textContent = icons[type] || ''; |
| | | document.body.appendChild(feedback); |
| | | |
| | | setTimeout(() => { |
| | | feedback.style.animation = 'fadeOut 0.3s ease'; |
| | | setTimeout(() => feedback.remove(), 300); |
| | | }, 500); |
| | | } |
| | | |
| | | /** |
| | | * Provide consistent feedback for different input methods |
| | | */ |
| | | provideFeedback(sourceType, feedbackType, data = {}) { |
| | | const hapticPatterns = { |
| | | success: data.isMultiple ? [50, 25, 50, 25, 50] : [50, 25, 50], |
| | | error: [100, 50, 100] |
| | | }; |
| | | |
| | | if (sourceType === 'touch' && navigator.vibrate && hapticPatterns[feedbackType]) { |
| | | navigator.vibrate(hapticPatterns[feedbackType]); |
| | | } |
| | | } |
| | | |
| | | clearDragoverStates() { |
| | | document.querySelectorAll('.dragover').forEach(el => { |
| | | el.classList.remove('dragover', 'multi-drop'); |
| | | el.removeAttribute('data-item-count'); |
| | | }); |
| | | } |
| | | /********* |
| | | * DRAG HANDLERS |
| | | ********/ |
| | | handleDragEnter(e) { |
| | | if (!window.targetCheck(e, '.field.upload')) return; |
| | | |
| | | // Only handle external files |
| | | if (e.dataTransfer.types.includes('Files')) { |
| | | e.preventDefault(); |
| | | const uploadContainer = e.target.closest('.file-upload-container'); |
| | | if (uploadContainer) { |
| | | uploadContainer.classList.add('dragover'); |
| | | } |
| | | } |
| | | } |
| | | handleDragLeave(e) { |
| | | if (!window.targetCheck(e, '.field.upload')) return; |
| | | |
| | | const uploadContainer = e.target.closest('.file-upload-container'); |
| | | if (uploadContainer && !uploadContainer.contains(e.relatedTarget)) { |
| | | uploadContainer.classList.remove('dragover'); |
| | | } |
| | | } |
| | | handleDragStart(e) { |
| | | if (!window.targetCheck(e, '.field.upload')) return; |
| | | |
| | | const uploadItem = e.target.closest('[data-upload-id]'); |
| | | if (!uploadItem) return; |
| | | |
| | | const result = this.startDragOperation({ |
| | | primaryElement: uploadItem, |
| | | sourceType: 'drag', |
| | | startPosition: { x: e.clientX, y: e.clientY }, |
| | | event: e |
| | | }); |
| | | |
| | | if (result) { |
| | | e.dataTransfer.setData('text/plain', this.dragState.primaryItem); |
| | | e.dataTransfer.effectAllowed = 'move'; |
| | | } else { |
| | | e.preventDefault(); |
| | | } |
| | | } |
| | | |
| | | handleDragOver(e) { |
| | | if (!this.dragState.isDragging) return; |
| | | if (!window.targetCheck(e, '.field.upload')) return; |
| | | |
| | | e.preventDefault(); |
| | | e.dataTransfer.dropEffect = 'move'; |
| | | |
| | | const elementUnderPointer = document.elementFromPoint(e.clientX, e.clientY); |
| | | this.updateDragOperation( |
| | | { x: e.clientX, y: e.clientY }, |
| | | elementUnderPointer |
| | | ); |
| | | } |
| | | |
| | | handleDrop(e) { |
| | | if (!window.targetCheck(e, '.field.upload')) return; |
| | | |
| | | e.preventDefault(); |
| | | this.clearDragoverStates(); |
| | | |
| | | // Handle external files (new uploads) |
| | | const uploadContainer = e.target.closest('.file-upload-container'); |
| | | if (uploadContainer) { |
| | | const files = Array.from(e.dataTransfer.files); |
| | | if (files.length > 0) { |
| | | const fieldId = this.getFieldIdFromElement(uploadContainer); |
| | | if (fieldId) { |
| | | this.processFiles(fieldId, files); |
| | | this.a11y.announce(`${files.length} file(s) dropped for upload`); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | handleDragEnd(e) { |
| | | if (!this.dragState.isDragging) return; |
| | | |
| | | // Find the element under the final drop position |
| | | const elementUnderDrop = document.elementFromPoint( |
| | | this.dragState.currentPosition?.x || e.clientX, |
| | | this.dragState.currentPosition?.y || e.clientY |
| | | ); |
| | | |
| | | this.endDragOperation(elementUnderDrop); |
| | | } |
| | | /********* |
| | | * TOUCH HANDLERS |
| | | ********/ |
| | | handleTouchStart(e) { |
| | | if (!window.targetCheck(e, '.field.upload')) return; |
| | | if (this.isTouchOnFormElement(e.target)) { |
| | | return; |
| | | } |
| | | |
| | | const uploadItem = e.target.closest('[data-upload-id]'); |
| | | if (!uploadItem) return; |
| | | |
| | | const touch = e.touches[0]; |
| | | |
| | | const result = this.startDragOperation({ |
| | | primaryElement: uploadItem, |
| | | sourceType: 'touch', |
| | | startPosition: { x: touch.clientX, y: touch.clientY }, |
| | | event: e |
| | | }); |
| | | |
| | | if (result) { |
| | | e.preventDefault(); // Prevent scrolling |
| | | } |
| | | } |
| | | |
| | | handleTouchMove(e) { |
| | | if (!this.dragState.isDragging) return; |
| | | |
| | | e.preventDefault(); |
| | | const touch = e.touches[0]; |
| | | const elementUnderTouch = document.elementFromPoint(touch.clientX, touch.clientY); |
| | | |
| | | this.updateDragOperation( |
| | | { x: touch.clientX, y: touch.clientY }, |
| | | elementUnderTouch |
| | | ); |
| | | } |
| | | |
| | | handleTouchEnd(e) { |
| | | if (!this.dragState.isDragging) return; |
| | | |
| | | e.preventDefault(); |
| | | const touch = e.changedTouches[0]; |
| | | const elementUnderTouch = document.elementFromPoint(touch.clientX, touch.clientY); |
| | | |
| | | this.endDragOperation(elementUnderTouch); |
| | | } |
| | | |
| | | handleTouchCancel(e) { |
| | | if (!this.dragState.isDragging) { |
| | | return; |
| | | } |
| | | if (this.dragState.isDragging) { |
| | | this.cleanupDragOperation(); |
| | | this.a11y.announce('Drag cancelled'); |
| | | } |
| | | } |
| | | /******************************************************************************* |
| | | QUEUE INTEGRATION |
| | | *******************************************************************************/ |
| | | async submitUploads(fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | // Check if there are uploads to submit |
| | | const pendingUploads = Array.from(field.uploads || []) |
| | | .map(id => this.uploads.get(id)) |
| | | .filter(upload => upload && |
| | | (upload.status === 'processed' || |
| | | upload.status === 'processed-original')); |
| | | |
| | | if (pendingUploads.length === 0) { |
| | | // this.notifications.add('No uploads ready to submit', 'warning'); |
| | | return; |
| | | } |
| | | |
| | | // Queue the uploads |
| | | try { |
| | | await this.queueUpload(fieldId); |
| | | // this.notifications.add(`Submitting ${pendingUploads.length} upload(s)`, 'info'); |
| | | } catch (error) { |
| | | this.error.log(error, { |
| | | component: 'UploadManager', |
| | | action: 'submitUploads', |
| | | fieldId |
| | | }); |
| | | // this.notifications.add('Failed to submit uploads', 'error'); |
| | | } |
| | | } |
| | | async retryUpload(uploadId) { |
| | | const upload = this.uploads.get(uploadId); |
| | | if (!upload) return; |
| | | |
| | | const field = this.fields.get(upload.fieldId); |
| | | if (!field) return; |
| | | |
| | | try { |
| | | // Reset status |
| | | this.updateUploadStatus(uploadId, 'received'); |
| | | |
| | | // If we have the processed file, skip to queuing |
| | | if (upload.processedFile) { |
| | | this.updateUploadStatus(uploadId, 'processed'); |
| | | await this.queueUpload(upload.fieldId); |
| | | } else if (upload.originalFile) { |
| | | // Reprocess the file |
| | | const reprocessed = await this.processFile(upload.originalFile, field); |
| | | if (reprocessed) { |
| | | await this.queueUpload(upload.fieldId); |
| | | } |
| | | if (fieldData?.config.destination === 'post_group') { |
| | | this.handleGroupMetaChange(e.target); |
| | | } else { |
| | | throw new Error('No file data available for retry'); |
| | | this.queueUploadMeta(e); |
| | | } |
| | | |
| | | // this.notifications.add('Retrying upload...', 'info'); |
| | | } catch (error) { |
| | | this.error.log(error, { |
| | | component: 'UploadManager', |
| | | action: 'retryUpload', |
| | | uploadId |
| | | }); |
| | | // this.notifications.add('Failed to retry upload', 'error'); |
| | | } |
| | | } |
| | | |
| | | async queueUpload(fieldId) { |
| | | //Further cache it, or is it already cached at this point? |
| | | const field = this.fields.get(fieldId); |
| | | if (!field?.uploads) return; |
| | | |
| | | const uploads = Array.from(field.uploads); |
| | | if (uploads.length === 0) { |
| | | return; |
| | | } |
| | | |
| | | const data = this.prepareUploadData(field, uploads); |
| | | this.a11y.announce('Queuing for upload'); |
| | | let img = (uploads.length === 1) ? 'image' : 'images'; |
| | | const operation = { |
| | | endpoint: 'uploads', |
| | | method: 'POST', |
| | | data: data, |
| | | title: `Uploading ${uploads.length} ${img} to server...`, |
| | | popup: `Uploading ${uploads.length} ${img}...`, |
| | | canMerge: false, |
| | | headers: { |
| | | 'action_nonce': jvbSettings.dash |
| | | }, |
| | | append: '_upload' |
| | | } |
| | | try { |
| | | const operationId = await this.queue.addToQueue(operation); |
| | | |
| | | uploads.forEach(uploadId => { |
| | | let upload = this.uploads.get(uploadId); |
| | | if (!upload) { |
| | | return; |
| | | } |
| | | upload.operationId = operationId; |
| | | this.updateUploadStatus(uploadId, 'queued'); |
| | | }); |
| | | field.operationId = operationId; |
| | | |
| | | return operationId; |
| | | } catch (error) { |
| | | throw error; |
| | | } finally { |
| | | this.persistFieldState(field.key); |
| | | } |
| | | } |
| | | |
| | | prepareUploadData(field, uploads) { |
| | | |
| | | const formData = new FormData(); |
| | | formData.append('content', field.content); |
| | | formData.append('mode', field.mode); |
| | | formData.append('field_name', field.name); |
| | | formData.append('field_key', field.key); |
| | | formData.append('field_type', field.type); |
| | | formData.append('subtype', field.subtype); |
| | | formData.append('item_id', field.itemID); //post, term, or user id |
| | | formData.append('context', field.context); //post, term, or user |
| | | formData.append('destination', field.destination || 'meta'); //meta, post, post_group |
| | | let uploadMap = []; |
| | | |
| | | const fieldGroups = this.getFieldGroups(field.key); |
| | | if (field.destination === 'post_group' && fieldGroups.length > 0) { |
| | | // User has created groups |
| | | let groups = []; |
| | | let titles = []; |
| | | let featuredImages = []; |
| | | |
| | | fieldGroups.forEach(group => { |
| | | let groupUploadIndices = []; |
| | | let featuredIndex = null; |
| | | |
| | | group.uploads.forEach(uploadId => { |
| | | let upload = this.uploads.get(uploadId); |
| | | if (upload) { |
| | | const fileToUpload = upload.processedFile || upload.originalFile; |
| | | if (fileToUpload) { |
| | | formData.append('files[]', fileToUpload); |
| | | const fileIndex = uploadMap.length; |
| | | uploadMap.push(upload.id); |
| | | groupUploadIndices.push(upload.id); |
| | | |
| | | // Check if this is the featured image |
| | | const radioInput = upload.element?.querySelector('[name="featured"]'); |
| | | if (radioInput?.checked) { |
| | | featuredIndex = upload.id; |
| | | } |
| | | } |
| | | } |
| | | }); |
| | | |
| | | groups.push(groupUploadIndices); |
| | | titles.push(group.title || ''); |
| | | featuredImages.push(featuredIndex); |
| | | }); |
| | | |
| | | formData.append('groups', JSON.stringify(groups)); |
| | | formData.append('group_titles', JSON.stringify(titles)); |
| | | formData.append('featured_images', JSON.stringify(featuredImages)); |
| | | } else { |
| | | // No groups - just append all files |
| | | uploads.forEach(uploadId => { |
| | | let upload = this.uploads.get(uploadId); |
| | | if (upload) { |
| | | const fileToUpload = upload.processedFile || upload.originalFile; |
| | | if (fileToUpload) { |
| | | formData.append('files[]', fileToUpload); |
| | | uploadMap.push(upload.id); |
| | | } |
| | | } |
| | | }); |
| | | } |
| | | formData.append('upload_ids', JSON.stringify(uploadMap)); |
| | | |
| | | // console.log('Final FormData:'); |
| | | // for (let pair of formData.entries()) { |
| | | // console.log(pair[0], pair[1]); |
| | | // } |
| | | |
| | | return formData; |
| | | } |
| | | |
| | | getFieldGroups(fieldId) { |
| | | const groups = []; |
| | | |
| | | this.groups.forEach((groupData, groupId) => { |
| | | if (groupData.fieldId === fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | const groupElement = field?.ui?.groups?.groups?.get(groupId); |
| | | |
| | | groups.push({ |
| | | id: groupId, |
| | | uploads: Array.from(groupData.uploads || new Set()), |
| | | meta: this.groupsMeta.get(groupId) || {}, |
| | | element: groupElement || null |
| | | }); |
| | | } |
| | | }); |
| | | |
| | | return groups; |
| | | } |
| | | |
| | | /** |
| | | * Build groups data from field state |
| | | */ |
| | | buildGroupsData(field, uploads) { |
| | | const groups = []; |
| | | const titles = []; |
| | | const uploadMap = []; |
| | | |
| | | if (field.groups && field.groups.length > 0) { |
| | | // User has explicitly created groups |
| | | field.groups.forEach(group => { |
| | | const groupUploads = []; |
| | | group.uploads.forEach(uploadId => { |
| | | groupUploads.push(uploadId); |
| | | uploadMap.push(uploadId); |
| | | }); |
| | | groups.push(groupUploads); |
| | | titles.push(group.title || ''); |
| | | }); |
| | | } else { |
| | | // No explicit groups - treat all as one group |
| | | const allUploads = []; |
| | | uploads.forEach(uploadId => { |
| | | allUploads.push(uploadId); |
| | | uploadMap.push(uploadId); |
| | | }); |
| | | groups.push(allUploads); |
| | | titles.push(''); |
| | | } |
| | | |
| | | return { groups, titles, uploadMap }; |
| | | } |
| | | |
| | | async queueImageMeta(e) { |
| | | const upload = this.getUploadFromElement(element); |
| | | if (!upload) return; |
| | | |
| | | const field = this.fields.get(upload.fieldId); |
| | | if (!field) return; |
| | | |
| | | // Collect meta data from the form |
| | | const metaContainer = element.closest('.upload-meta'); |
| | | if (!metaContainer) return; |
| | | |
| | | const metaData = { |
| | | title: metaContainer.querySelector('[name="title"]')?.value || '', |
| | | alt_text: metaContainer.querySelector('[name="alt_text"]')?.value || '', |
| | | caption: metaContainer.querySelector('[name="caption"]')?.value || '', |
| | | description: metaContainer.querySelector('[name="description"]')?.value || '' |
| | | }; |
| | | |
| | | // Update upload meta |
| | | upload.meta = { ...upload.meta, ...metaData }; |
| | | this.uploads.set(upload.id, upload); |
| | | |
| | | // Mark that we have meta changes |
| | | this.hasMetaChanges = true; |
| | | |
| | | // Determine if upload has been sent to server |
| | | const isOnServer = upload.status === 'completed' && upload.attachmentId; |
| | | |
| | | if (isOnServer) { |
| | | // Queue immediate update |
| | | await this.sendMetaUpdate(upload); |
| | | } else if (upload.operationId) { |
| | | // Wait for upload to complete, then send meta |
| | | this.queueDependentMetaUpdate(upload); |
| | | } else { |
| | | // Upload hasn't been queued yet, meta will be sent with initial upload |
| | | this.persistFieldState(field.key); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Send meta update to server |
| | | */ |
| | | async sendMetaUpdate(upload) { |
| | | const formData = new FormData(); |
| | | formData.append('attachment_id', upload.attachmentId); |
| | | formData.append('title', upload.meta.title); |
| | | formData.append('alt_text', upload.meta.alt_text); |
| | | formData.append('caption', upload.meta.caption); |
| | | formData.append('description', upload.meta.description); |
| | | //TODO: |
| | | // Send an array of attachment IDs with the changes, similar to the post editing logic |
| | | /** |
| | | * let data = { |
| | | * items: { |
| | | * uploadID: { |
| | | * title: '', |
| | | * alt: '', |
| | | * caption: '', |
| | | * depends_on: '' <-- only necessary if uploadID is the generated upload_id |
| | | * } |
| | | * }, |
| | | * user: userID |
| | | * } |
| | | * |
| | | * WHERE uploadID = attachment_id (if already uploaded) or our generated upload_id if the file hasn't been processed yet |
| | | * |
| | | */ |
| | | const operation = { |
| | | endpoint: 'uploads/meta', |
| | | method: 'POST', |
| | | data: formData, |
| | | title: `Updating metadata for ${upload.meta.originalName}`, |
| | | canMerge: true, |
| | | headers: { |
| | | 'action_nonce': jvbSettings.dash |
| | | } |
| | | }; |
| | | |
| | | try { |
| | | await this.queue.addToQueue(operation); |
| | | // this.notifications.add('Metadata updated', 'success'); |
| | | } catch (error) { |
| | | this.error.log(error, { |
| | | component: 'UploadManager', |
| | | action: 'sendMetaUpdate', |
| | | uploadId: upload.id |
| | | }); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Queue meta update that depends on upload completion |
| | | */ |
| | | queueDependentMetaUpdate(upload) { |
| | | const operation = { |
| | | endpoint: 'uploads/meta', |
| | | method: 'POST', |
| | | dependencies: [upload.operationId], |
| | | data: () => { |
| | | // This function will be called when dependencies are resolved |
| | | const formData = new FormData(); |
| | | formData.append('operation_id', upload.operationId); |
| | | formData.append('upload_id', upload.id); |
| | | formData.append('title', upload.meta.title); |
| | | formData.append('alt_text', upload.meta.alt_text); |
| | | formData.append('caption', upload.meta.caption); |
| | | formData.append('description', upload.meta.description); |
| | | return formData; |
| | | }, |
| | | title: `Updating metadata after upload`, |
| | | canMerge: true, |
| | | headers: { |
| | | 'action_nonce': jvbSettings.dash |
| | | } |
| | | }; |
| | | |
| | | this.queue.addToQueue(operation); |
| | | } |
| | | /******************************************************************************* |
| | | IMAGE PROCESSING |
| | | *******************************************************************************/ |
| | | async processFiles(fieldId, files) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | * FILE PROCESSING |
| | | *******************************************************************************/ |
| | | |
| | | // Hide upload container, show group display |
| | | if (field.ui.field.dropZone) { |
| | | field.ui.field.dropZone.hidden = true; |
| | | 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 (field.ui.groups.display) { |
| | | field.ui.groups.display.hidden = false; |
| | | if (fieldEl.ui.groups?.display) { |
| | | fieldEl.ui.groups.display.hidden = false; |
| | | } |
| | | |
| | | const totalFiles = files.length; |
| | | let processedCount = 0; |
| | | |
| | | // Show initial progress |
| | | this.updateUploadProgress(fieldId, 0, totalFiles, 'Processing files...'); |
| | | |
| | | // Initialize field uploads set if needed |
| | | if (!field.uploads) { |
| | | field.uploads = new Set(); |
| | | } |
| | | |
| | | // Process files |
| | | const processPromises = Array.from(files).map(async (file, index) => { |
| | | const processPromises = Array.from(files).map(async (file) => { |
| | | try { |
| | | // Create upload ID |
| | | const uploadId = `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; |
| | | |
| | | // Create upload data |
| | | // Create initial upload data |
| | | const uploadData = { |
| | | id: uploadId, |
| | | attachmentId: null, |
| | | fieldId: fieldId, |
| | | originalFile: file, |
| | | processedFile: null, |
| | | preview: null, |
| | | status: 'local_processing', |
| | | element: null, |
| | | location: null, |
| | | groupId: null, |
| | | meta: { |
| | | originalName: file.name, |
| | | size: file.size, |
| | |
| | | } |
| | | }; |
| | | |
| | | // Create preview URL |
| | | uploadData.preview = URL.createObjectURL(file); |
| | | // Save initial data |
| | | await this.uploadStore.save(uploadData); |
| | | |
| | | // Process the file (resize if image) |
| | | if (file.type.startsWith('image/')) { |
| | | uploadData.processedFile = await this.processImage(file, field.subtype); |
| | | } else { |
| | | uploadData.processedFile = file; |
| | | } |
| | | // Process file |
| | | const preview = this.createPreviewUrl(file); |
| | | const processedFile = file.type.startsWith('image/') |
| | | ? await this.processImage(file, fieldData.config.subtype) |
| | | : file; |
| | | |
| | | // Store blob data separately in IndexedDB |
| | | if (this.db) { |
| | | try { |
| | | await this.storeBlobData(uploadId, uploadData.processedFile || file); |
| | | } catch (error) { |
| | | console.warn('Failed to store blob data:', error); |
| | | } |
| | | } |
| | | |
| | | // Create DOM element |
| | | const subtype = this.getSubtypeFromMime(file.type); |
| | | uploadData.element = this.createImageElement({ |
| | | ...uploadData, |
| | | subtype: subtype |
| | | }, field.destination === 'post_group'); |
| | | |
| | | // Show progress on the item |
| | | // 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 (field.ui.field.preview) { |
| | | field.ui.field.preview.appendChild(uploadData.element); |
| | | uploadData.location = field.ui.field.preview; |
| | | 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 |
| | | }); |
| | | } |
| | | |
| | | // Store upload |
| | | this.uploads.set(uploadId, uploadData); |
| | | field.uploads.add(uploadId); |
| | | // 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'); |
| | | uploadData.status = 'processed'; |
| | | |
| | | // Fade out item progress after a moment |
| | | setTimeout(() => { |
| | | this.showUploadProgress(uploadId, false); |
| | | }, 1000); |
| | | // Fade out progress |
| | | setTimeout(() => this.showUploadProgress(uploadId, false), 1000); |
| | | |
| | | return uploadId; |
| | | |
| | |
| | | } |
| | | }); |
| | | |
| | | // Wait for all files to process |
| | | await Promise.all(processPromises); |
| | | |
| | | this.updateFieldState(fieldId); |
| | | // Cache the state (now without DOM references) |
| | | await this.persistFieldState(fieldId); |
| | | this.refreshSortable(fieldId); |
| | | |
| | | // Queue for upload if in direct mode |
| | | if (field.mode === 'direct' && field.destination !== 'post_group') { |
| | | if (fieldData.config.autoUpload && fieldData.config.destination !== 'post_group') { |
| | | await this.queueUpload(fieldId); |
| | | } |
| | | |
| | | // Lock uploads if max reached |
| | | this.maybeLockUploads(fieldId); |
| | | } |
| | | |
| | | updateFieldState(fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field || !field.ui.field.field) return; |
| | | |
| | | const container = field.ui.field.field; |
| | | const uploadCount = field.uploads?.size || 0; |
| | | const hasGroups = field.ui.groups?.container?.querySelectorAll('.upload-group').length > 0; |
| | | |
| | | // Set data attributes for CSS targeting |
| | | container.dataset.hasUploads = uploadCount > 0 ? 'true' : 'false'; |
| | | container.dataset.uploadCount = uploadCount.toString(); |
| | | container.dataset.hasGroups = hasGroups ? 'true' : 'false'; |
| | | |
| | | // Update ARIA labels for accessibility |
| | | if (field.ui.field.preview) { |
| | | field.ui.field.preview.setAttribute('aria-label', |
| | | `Upload preview area with ${uploadCount} item${uploadCount !== 1 ? 's' : ''}` |
| | | ); |
| | | this.maybeLockUploads(fieldId); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Store file blob data in IndexedDB |
| | | */ |
| | | async storeBlobData(uploadId, file) { |
| | | if (!this.db) return; |
| | | |
| | | const blobData = { |
| | | uploadId: uploadId, |
| | | data: file, |
| | | name: file.name, |
| | | type: file.type, |
| | | lastModified: file.lastModified, |
| | | timestamp: Date.now() |
| | | }; |
| | | |
| | | try { |
| | | const tx = this.db.transaction(['uploadBlobs'], 'readwrite'); |
| | | await tx.objectStore('uploadBlobs').put(blobData); |
| | | } catch (error) { |
| | | console.error('Failed to store blob data:', error); |
| | | throw error; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Show/hide progress indicator on individual upload items |
| | | */ |
| | | showUploadProgress(uploadId, show = true) { |
| | | const upload = this.uploads.get(uploadId); |
| | | if (!upload || !upload.element) return; |
| | | |
| | | const progressEl = upload.element.querySelector('.progress'); |
| | | if (progressEl) { |
| | | if (show) { |
| | | progressEl.style.removeProperty('animation'); |
| | | progressEl.hidden = false; |
| | | } else { |
| | | progressEl.style.animation = 'fadeOut var(--transition-base)'; |
| | | setTimeout(() => { |
| | | progressEl.hidden = true; |
| | | }, 300); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Update individual upload progress bar |
| | | */ |
| | | updateUploadItemProgress(uploadId, percent, status = null) { |
| | | const upload = this.uploads.get(uploadId); |
| | | if (!upload || !upload.element) return; |
| | | |
| | | const progressEl = upload.element.querySelector('.progress'); |
| | | if (!progressEl) return; |
| | | |
| | | const fill = progressEl.querySelector('.fill'); |
| | | const details = progressEl.querySelector('.details'); |
| | | const icon = progressEl.querySelector('.icon'); |
| | | |
| | | if (fill) { |
| | | fill.style.width = `${percent}%`; |
| | | } |
| | | |
| | | if (status && details) { |
| | | details.textContent = this.getStatusText(status); |
| | | } |
| | | |
| | | if (status && icon) { |
| | | icon.innerHTML = this.getStatusIcon(status).outerHTML; |
| | | } |
| | | } |
| | | checkFieldLimits(fieldId, additionalFiles) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return false; |
| | | |
| | | const currentCount = field.uploads?.size || 0; |
| | | const totalCount = currentCount + additionalFiles; |
| | | |
| | | if (totalCount > field.maxFiles) { |
| | | // this.notifications.add( |
| | | // `Cannot add ${additionalFiles} files. Max ${field.maxFiles} allowed, currently have ${currentCount}.`, |
| | | // 'warning' |
| | | // ); |
| | | return false; |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | generateUploadId() { |
| | | return `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; |
| | | } |
| | | validateFile(file, field) { |
| | | // Type validation |
| | | if (!this.settings.allowedTypes.includes(file.type)) { |
| | | this.notify(`Invalid file type: ${file.type}`, 'error'); |
| | | return false; |
| | | } |
| | | |
| | | // Size validation |
| | | if (file.size > this.settings.maxFileSize) { |
| | | this.notify(`File too large: ${this.formatBytes(file.size)}`, 'error'); |
| | | return false; |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | 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]; |
| | | } |
| | | |
| | | shouldProcessClientSide(file, subtype) { |
| | | // Only process images client-side |
| | | if (subtype === 'image' && file.type.startsWith('image/')) { |
| | | return true; |
| | | } |
| | | |
| | | // Videos and documents go straight to server |
| | | return false; |
| | | } |
| | | |
| | | async processBatch(fieldId, files) { |
| | | const results = []; |
| | | const processingQueue = []; |
| | | const maxConcurrent = this.worker.settings.maxConcurrent; |
| | | |
| | | let total = files.length; |
| | | let processedCount = 0; |
| | | |
| | | // Show initial progress |
| | | this.updateUploadProgress(fieldId, 0, totalFiles, 'Processing files...'); |
| | | let field = this.fields.get(fieldId); |
| | | // Initialize field uploads set if needed |
| | | if (!field.uploads) { |
| | | field.uploads = new Set(); |
| | | } |
| | | |
| | | |
| | | for (let i = 0; i < files.length; i++) { |
| | | this.showUploadProgress(uploadId, true); |
| | | this.updateUploadProgress(fieldId, i, total); |
| | | // Wait if we've reached max concurrent processing |
| | | if (processingQueue.length >= maxConcurrent) { |
| | | await Promise.race(processingQueue); |
| | | } |
| | | |
| | | const processPromise = this.processFile(files[i], field) |
| | | .then(upload => { |
| | | // Remove from processing queue |
| | | const index = processingQueue.indexOf(processPromise); |
| | | if (index > -1) processingQueue.splice(index, 1); |
| | | |
| | | if (upload) results.push(upload); |
| | | return upload; |
| | | }) |
| | | .catch(error => { |
| | | console.error(`Failed to process ${files[i].name}:`, error); |
| | | // Remove from processing queue |
| | | const index = processingQueue.indexOf(processPromise); |
| | | if (index > -1) processingQueue.splice(index, 1); |
| | | return null; |
| | | }); |
| | | |
| | | processingQueue.push(processPromise); |
| | | } |
| | | |
| | | // Wait for remaining files |
| | | await Promise.all(processingQueue); |
| | | return results; |
| | | } |
| | | |
| | | async processFile(file, field, uploadId = null) { |
| | | if (!field || !file) { |
| | | console.error('Missing required parameters:', { file, field }); |
| | | return null; |
| | | } |
| | | |
| | | if (!this.shouldProcessClientSide(file, field.subtype)) { |
| | | return upload; |
| | | } |
| | | |
| | | const id = uploadId || `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; |
| | | |
| | | try { |
| | | // Create upload object |
| | | const upload = { |
| | | id, |
| | | fieldId: field.key, |
| | | originalFile: file, |
| | | processedFile: null, |
| | | preview: null, |
| | | status: 'local_processing', |
| | | element: null, |
| | | location: null, |
| | | groupId: null, |
| | | changes: {}, |
| | | meta: { |
| | | originalName: file.name, |
| | | size: file.size, |
| | | type: file.type |
| | | } |
| | | }; |
| | | |
| | | // Create preview URL |
| | | upload.preview = URL.createObjectURL(file); |
| | | |
| | | // Process the file |
| | | let processedFile = null; |
| | | let processingFailed = false; |
| | | |
| | | if (file.type.startsWith('image/')) { |
| | | try { |
| | | processedFile = await this.processImage(file, id); |
| | | } catch (error) { |
| | | console.warn(`Image processing failed for ${file.name}, using original:`, error); |
| | | processingFailed = true; |
| | | processedFile = file; |
| | | } |
| | | } else { |
| | | processedFile = file; // Videos/documents use original |
| | | } |
| | | |
| | | upload.processedFile = processedFile; |
| | | upload.processingFailed = processingFailed; |
| | | |
| | | // Store in uploads map |
| | | this.uploads.set(id, upload); |
| | | |
| | | // Add to field's uploads |
| | | if (!field.uploads) { |
| | | field.uploads = new Set(); |
| | | } |
| | | field.uploads.add(id); |
| | | |
| | | // Update status |
| | | this.updateUploadStatus(id, 'processed'); |
| | | |
| | | // Persist state |
| | | await this.persistFieldState(field.key); |
| | | |
| | | // Announce to screen readers |
| | | const message = processingFailed |
| | | ? `${file.name} added (original format)` |
| | | : `${file.name} processed and ready`; |
| | | this.a11y.announce(message); |
| | | |
| | | return upload; |
| | | |
| | | } catch (error) { |
| | | // Clean up failed upload |
| | | this.cleanupFailedUpload(id, field.key); |
| | | |
| | | this.error.log(error, { |
| | | component: 'UploadManager', |
| | | action: 'processFile', |
| | | uploadId: id, |
| | | fileName: file.name |
| | | }); |
| | | |
| | | return null; |
| | | } |
| | | } |
| | | /******************************************************************************* |
| | | * IMAGE PROCESSING |
| | | *******************************************************************************/ |
| | | |
| | | async processImage(file, uploadId) { |
| | | const timeout = this.worker.settings.timeout; |
| | |
| | | let timeoutId; |
| | | let taskCompleted = false; |
| | | |
| | | // Set timeout |
| | | timeoutId = setTimeout(() => { |
| | | if (!taskCompleted) { |
| | | taskCompleted = true; |
| | | |
| | | // Remove from active tasks |
| | | this.worker.tasks.delete(uploadId); |
| | | |
| | | // Maybe restart worker if configured |
| | | if (this.worker.settings.restartAfterTimeout) { |
| | | this.restartCompressionWorker(); |
| | | } |
| | | |
| | | reject(new Error(`Processing timeout for ${file.name}`)); |
| | | } |
| | | }, timeout); |
| | | |
| | | // Track this task |
| | | this.worker.tasks.set(uploadId, { file, timeoutId }); |
| | | |
| | | // Process image |
| | | this.handleProcess(file, uploadId) |
| | | .then(result => { |
| | | if (!taskCompleted) { |
| | |
| | | } |
| | | |
| | | async handleProcess(file, uploadId) { |
| | | // Skip non-images |
| | | if (!file.type.startsWith('image/')) { |
| | | return file; |
| | | } |
| | |
| | | const maxDimension = this.getMaxDimension(); |
| | | const quality = 0.85; |
| | | |
| | | // Try worker first if available |
| | | if (this.shouldUseWorker(file)) { |
| | | try { |
| | | // Ensure worker is initialized |
| | | if (!this.worker.worker) { |
| | | this.initCompressionWorker(); |
| | | } |
| | | |
| | | if (this.worker.worker) { |
| | | return await this.processWithWorker(file, uploadId, maxDimension, quality); |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | // Fallback to main thread |
| | | return await this.processOnMainThread(file, maxDimension, quality); |
| | | } |
| | | |
| | | /** |
| | | * Process image on main thread with better error handling |
| | | */ |
| | | async processOnMainThread(file, maxDimension, quality) { |
| | | return new Promise((resolve, reject) => { |
| | | const img = new Image(); |
| | |
| | | URL.revokeObjectURL(objectUrl); |
| | | objectUrl = null; |
| | | } |
| | | // Explicitly clean up canvas |
| | | canvas.width = 1; |
| | | canvas.height = 1; |
| | | ctx.clearRect(0, 0, 1, 1); |
| | |
| | | canvas.width = width; |
| | | canvas.height = height; |
| | | |
| | | // Enhanced image smoothing |
| | | ctx.imageSmoothingEnabled = true; |
| | | ctx.imageSmoothingQuality = 'high'; |
| | | ctx.drawImage(img, 0, 0, width, height); |
| | |
| | | }; |
| | | |
| | | try { |
| | | objectUrl = URL.createObjectURL(file); |
| | | objectUrl = this.createPreviewUrl(file); |
| | | img.src = objectUrl; |
| | | } catch (error) { |
| | | cleanup(); |
| | |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Get optimal output format |
| | | */ |
| | | getOptimalFormat(file) { |
| | | // Keep original format for certain types |
| | | if (file.type === 'image/gif' || file.type === 'image/svg+xml') { |
| | | return file.type; |
| | | } |
| | | |
| | | // Use WebP if supported, otherwise JPEG |
| | | return this.supportsWebP() ? 'image/webp' : 'image/jpeg'; |
| | | } |
| | | |
| | | /** |
| | | * Get optimal quality setting |
| | | */ |
| | | getOptimalQuality(file, requestedQuality) { |
| | | // Higher quality for smaller files |
| | | if (file.size < 500 * 1024) return Math.max(requestedQuality, 0.9); |
| | | if (file.size < 2 * 1024 * 1024) return requestedQuality; |
| | | |
| | | // Lower quality for very large files |
| | | return Math.min(requestedQuality, 0.8); |
| | | } |
| | | |
| | | /** |
| | | * Generate processed file name |
| | | */ |
| | | 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'); |
| | | } |
| | | |
| | | /** |
| | | * Get maximum dimension based on device capabilities |
| | | */ |
| | | getMaxDimension() { |
| | | const screenWidth = window.screen.width; |
| | | const devicePixelRatio = window.devicePixelRatio || 1; |
| | | |
| | | // Scale based on device capabilities |
| | | if (screenWidth * devicePixelRatio > 2560) return 2400; |
| | | if (screenWidth * devicePixelRatio > 1920) return 1920; |
| | | return 1200; |
| | | } |
| | | |
| | | /** |
| | | * Determine if we should use Web Worker |
| | | */ |
| | | shouldUseWorker(file) { |
| | | // Use worker for large files or when available |
| | | return this.worker.worker && |
| | | file.size > 1024 * 1024 && // > 1MB |
| | | file.size > 1024 * 1024 && |
| | | typeof OffscreenCanvas !== 'undefined'; |
| | | } |
| | | |
| | |
| | | return; |
| | | } |
| | | |
| | | // Create unique message ID for this task |
| | | const messageId = `${uploadId}_${Date.now()}`; |
| | | |
| | | // Handler for this specific message |
| | | const messageHandler = (e) => { |
| | | if (e.data.messageId !== messageId) return; |
| | | |
| | | // Remove handler |
| | | this.worker.worker.removeEventListener('message', messageHandler); |
| | | this.worker.worker.removeEventListener('error', errorHandler); |
| | | |
| | |
| | | reject(new Error(`Worker error: ${error.message}`)); |
| | | }; |
| | | |
| | | // Add handlers |
| | | this.worker.worker.addEventListener('message', messageHandler); |
| | | this.worker.worker.addEventListener('error', errorHandler); |
| | | |
| | | // Send message to worker |
| | | this.worker.worker.postMessage({ |
| | | messageId, |
| | | file, |
| | |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Restart compression worker |
| | | */ |
| | | restartCompressionWorker() { |
| | | // Terminate existing worker |
| | | if (this.worker.worker) { |
| | | this.worker.worker.terminate(); |
| | | this.worker.worker = null; |
| | | } |
| | | |
| | | // Clear active tasks |
| | | this.worker.tasks.clear(); |
| | | |
| | | // Check restart limit |
| | | if (this.worker.restart.count >= this.worker.restart.max) { |
| | | console.error('Max worker restarts reached, disabling worker'); |
| | | return; |
| | | } |
| | | |
| | | this.worker.restart.count++; |
| | | |
| | | // Reinitialize |
| | | this.initCompressionWorker(); |
| | | } |
| | | |
| | | /** |
| | | * Initialize Web Worker for image compression |
| | | */ |
| | | 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 { |
| | | // Create ImageBitmap from file |
| | | const bitmap = await createImageBitmap(file); |
| | | |
| | | // Calculate dimensions |
| | | 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); |
| | | |
| | | // Create OffscreenCanvas |
| | | const canvas = new OffscreenCanvas(width, height); |
| | | const ctx = canvas.getContext('2d'); |
| | | |
| | | // Draw and resize |
| | | ctx.imageSmoothingEnabled = true; |
| | | ctx.imageSmoothingQuality = 'high'; |
| | | ctx.drawImage(bitmap, 0, 0, width, height); |
| | | |
| | | // Clean up bitmap |
| | | bitmap.close(); |
| | | |
| | | // Convert to blob |
| | | 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 |
| | | }); |
| | | } |
| | | }; |
| | | `; |
| | | 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(URL.createObjectURL(blob)); |
| | | this.worker.worker = new Worker(this.createPreviewUrl(blob)); |
| | | |
| | | } catch (error) { |
| | | console.warn('Failed to initialize compression worker:', error); |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Calculate optimal dimensions with aspect ratio preservation |
| | | */ |
| | | calculateOptimalDimensions(img, maxDimension) { |
| | | let { width, height } = img; |
| | | |
| | | // Don't upscale |
| | | if (width <= maxDimension && height <= maxDimension) { |
| | | return { width, height }; |
| | | } |
| | | |
| | | // Calculate scale factor |
| | | const scale = Math.min(maxDimension / width, maxDimension / height); |
| | | |
| | | return { |
| | | width: Math.round(width * scale), |
| | | height: Math.round(height * scale) |
| | | }; |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Check WebP support |
| | | */ |
| | | supportsWebP() { |
| | | const canvas = document.createElement('canvas'); |
| | | return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0; |
| | | } |
| | | |
| | | /** |
| | | * Clean up failed upload |
| | | */ |
| | | cleanupFailedUpload(uploadId, fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (field?.uploads) { |
| | | field.uploads.delete(uploadId); |
| | | } |
| | | |
| | | const upload = this.uploads.get(uploadId); |
| | | if (upload) { |
| | | // Clean up preview URL |
| | | if (upload.preview?.startsWith('blob:')) { |
| | | URL.revokeObjectURL(upload.preview); |
| | | } |
| | | |
| | | // Remove element |
| | | upload.element?.remove(); |
| | | |
| | | // Remove from uploads |
| | | this.uploads.delete(uploadId); |
| | | } |
| | | |
| | | // Remove from active tasks |
| | | this.worker.tasks.delete(uploadId); |
| | | 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); |
| | | } |
| | | } |
| | | |
| | | /******************************************************************************* |
| | | UI FUNCTIONALITY |
| | | *******************************************************************************/ |
| | | /** |
| | | * Update upload status correctly |
| | | */ |
| | | updateUploadStatus(uploadId, status) { |
| | | let upload = this.uploads.get(uploadId); |
| | | if(!upload) { |
| | | * QUEUE INTEGRATION |
| | | *******************************************************************************/ |
| | | |
| | | async submitUploads(fieldId) { |
| | | const fieldData = this.getFieldData(fieldId); |
| | | const fieldEl = this.fieldElements.get(fieldId); |
| | | if (!fieldData?.uploads || fieldData.uploads.size === 0) { |
| | | return; |
| | | } |
| | | upload.status = status; |
| | | |
| | | this.updateImageUI(upload.id); |
| | | this.persistFieldState(upload.fieldId); |
| | | } |
| | | updateImageUI(uploadId) { |
| | | const upload = this.uploads.get(uploadId); |
| | | if (!upload?.element) return; |
| | | |
| | | |
| | | const progressEl = upload.element.querySelector('.progress'); |
| | | const itemEl = upload.element; |
| | | |
| | | // Update status class on item for CSS styling |
| | | if (itemEl) { |
| | | itemEl.className = itemEl.className.replace(/status-[\w-]+/g, ''); |
| | | itemEl.classList.add(`status-${upload.status}`); |
| | | let uploadIds = Array.from(fieldData.uploads); |
| | | if (uploadIds.length === 0) { |
| | | this.error.log('No uploads to upload', { |
| | | component: 'UploadManager', |
| | | action: 'submitGroupedUploads', |
| | | fieldId: fieldId |
| | | }); |
| | | return; |
| | | } |
| | | |
| | | if (progressEl) { |
| | | let icon = this.getStatusIcon(upload.status); |
| | | let message = this.getStatusText(upload.status); |
| | | let progress = this.getStatusProgress(upload.status); |
| | | const fieldGroups = this.getFieldGroups(fieldId); |
| | | |
| | | const fill = progressEl.querySelector('.fill'); |
| | | const itemIcon = progressEl.querySelector('span.icon'); |
| | | const itemMessage = progressEl.querySelector('span.details'); |
| | | if (fieldGroups.length === 0) { |
| | | this.error.log('No groups created for post_group upload', { |
| | | component: 'UploadManager', |
| | | action: 'submitGroupedUploads', |
| | | fieldId: fieldId |
| | | }); |
| | | return; |
| | | } |
| | | |
| | | if (fill) { |
| | | fill.style.width = `${progress}%`; |
| | | } |
| | | if (itemMessage) itemMessage.textContent = message; |
| | | if (itemIcon) { |
| | | window.removeChildren(itemIcon); |
| | | itemIcon.append(icon); |
| | | // 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; |
| | | } |
| | | |
| | | if (upload.status === 'completed') { |
| | | setTimeout(() => { |
| | | if (progressEl) { |
| | | window.fade(progressEl, false); |
| | | // 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; |
| | | } |
| | | }, 1000); |
| | | |
| | | 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; |
| | | } |
| | | } |
| | | /** |
| | | * Hide the uploader drop zone if we have reached our limit |
| | | */ |
| | | maybeLockUploads(fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | if (field.ui.field.dropZone) { |
| | | const hasUploads = field.uploads && field.uploads.size > 0; |
| | | const atMaxFiles = field.uploads && field.uploads.size >= field.maxFiles; |
| | | async queueUpload(fieldId) { |
| | | const fieldData = this.getFieldData(fieldId); |
| | | if (!fieldData?.uploads || fieldData.uploads.size === 0) return; |
| | | |
| | | // Hide if we have uploads OR if we're at max files |
| | | field.ui.field.dropZone.hidden = hasUploads || atMaxFiles; |
| | | 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; |
| | | } |
| | | } |
| | | createImageElement(upload, draggable = false) { |
| | | let image = window.getTemplate('uploadItem'); |
| | | if (!image) { |
| | | console.error('Image template not found'); |
| | | return; |
| | | } |
| | | image.dataset.uploadId = upload.id; |
| | | if (upload.originalFile) { |
| | | image.dataset.subtype = this.getSubtypeFromMime(upload.originalFile.type); |
| | | } |
| | | |
| | | 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 = []; |
| | | |
| | | |
| | | image.querySelector('[name="featured"]').value = upload.id; |
| | | let [ |
| | | featured, |
| | | img, |
| | | video, |
| | | preview, |
| | | details |
| | | ] = [ |
| | | image.querySelector('[name="featured"]'), |
| | | image.querySelector('img'), |
| | | image.querySelector('video'), |
| | | image.querySelector('label > span'), |
| | | image.querySelector('details') |
| | | ]; |
| | | [ |
| | | featured.value, |
| | | img.src, |
| | | img.alt |
| | | ] = [ |
| | | upload.id, |
| | | upload.preview, |
| | | upload.originalFile?.name ?? upload.meta?.originalName ?? '', |
| | | ]; |
| | | const blobPromises = uploads.map(async (uploadId) => { |
| | | const upload = this.uploadStore.get(uploadId); |
| | | if (!upload) return; |
| | | |
| | | switch (image.dataset.subtype) { |
| | | case 'image': |
| | | [ |
| | | img.src, |
| | | img.alt |
| | | ] = [ |
| | | upload.preview, |
| | | upload.originalFile?.name ?? upload.meta?.originalName?? '' |
| | | ]; |
| | | video.remove(); |
| | | preview.remove(); |
| | | break; |
| | | case 'video': |
| | | video.src = upload.preview; |
| | | img.remove(); |
| | | preview.remove(); |
| | | break; |
| | | case 'document': |
| | | const fileName = upload.originalFile?.name ?? upload.meta?.originalName ?? ''; |
| | | const extension = fileName.split('.').pop()?.toLowerCase() ?? ''; |
| | | 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; |
| | | } |
| | | if (details) { |
| | | let template = window.getTemplate('uploadMeta'); |
| | | if (template){ |
| | | details.append(template); |
| | | } |
| | | } |
| | | image.draggable = draggable; |
| | | |
| | | // Update input IDs safely |
| | | 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; |
| | | } |
| | | const file = await this.getBlobData(uploadId); |
| | | if (file) { |
| | | formData.append('files[]', file); |
| | | uploadMap.push(upload.id); |
| | | } |
| | | }); |
| | | |
| | | return image; |
| | | 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'; |
| | |
| | | return 'document'; |
| | | } |
| | | |
| | | updateUploadProgress(fieldId, current, total, message) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | let progressBar = field.ui.field.progress.progress; |
| | | |
| | | // Create progress bar if it doesn't exist |
| | | if (!progressBar) { |
| | | progressBar = window.getTemplate('imageProgress'); |
| | | |
| | | if (!progressBar) { |
| | | console.warn('Progress bar template not found'); |
| | | return; |
| | | } |
| | | |
| | | // Insert after drop zone or at top of container |
| | | const container = field.ui.field.field; |
| | | const insertAfter = field.ui.field.dropZone; |
| | | |
| | | if (insertAfter) { |
| | | insertAfter.insertAdjacentElement('afterend', progressBar); |
| | | } else if (container) { |
| | | container.prepend(progressBar); |
| | | } |
| | | |
| | | // Update the field UI reference to match actual structure |
| | | if (!field.ui.field.progress) { |
| | | field.ui.field.progress = {}; |
| | | } |
| | | field.ui.field.progress = { |
| | | progress: progressBar, |
| | | bar: progressBar.querySelector('.bar'), |
| | | fill: progressBar.querySelector('.fill'), |
| | | details: progressBar.querySelector('.details'), |
| | | text: progressBar.querySelector('.details .text'), |
| | | count: progressBar.querySelector('.details .count') |
| | | }; |
| | | } |
| | | |
| | | |
| | | progressBar.hidden = false; |
| | | progressBar.style.display = 'flex'; |
| | | progressBar.style.animation = 'none'; |
| | | progressBar.style.opacity = '1'; |
| | | |
| | | // Update progress bar |
| | | const progressPercent = total > 0 ? Math.round((current / total) * 100) : 0; |
| | | const progressFill = field.ui.field.progress.fill; |
| | | const progressText = field.ui.field.progress.text; |
| | | const progressCount = field.ui.field.progress.count; |
| | | |
| | | if (progressFill) { |
| | | progressFill.style.width = `${progressPercent}%`; |
| | | } |
| | | |
| | | if (progressText) { |
| | | progressText.textContent = message; |
| | | } |
| | | |
| | | if (progressCount) { |
| | | progressCount.textContent = `${current}/${total}`; |
| | | } |
| | | |
| | | // Hide when complete |
| | | if (current >= total) { |
| | | setTimeout(() => { |
| | | progressBar.style.animation = 'fadeOut var(--transition-base)'; |
| | | setTimeout(() => { |
| | | progressBar.hidden = true; |
| | | progressBar.style.display = 'none'; |
| | | }, 300); |
| | | }, 1000); |
| | | } |
| | | getStatusText(status) { |
| | | return this.statusMapping[status] || status; |
| | | } |
| | | |
| | | hideUploadProgress(fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | const progressBar = field.ui.field.progress.progress; |
| | | if (progressBar) { |
| | | window.fade(progressBar, false); |
| | | } |
| | | getStatusIcon(status) { |
| | | return window.getIcon(this.queue.icons[status]); |
| | | } |
| | | /******************************************************************************* |
| | | INDEXEDDB CACHE FUNCTIONALITY |
| | | *******************************************************************************/ |
| | | async initDB() { |
| | | if (!('indexedDB' in window)) return; |
| | | |
| | | const request = indexedDB.open(`jvb_uploads_db`, 1); |
| | | |
| | | request.onupgradeneeded = (e) => { |
| | | const db = e.target.result; |
| | | if (!db.objectStoreNames.contains('fieldStates')) { |
| | | const store = db.createObjectStore('fieldStates', { keyPath: 'fieldId' }); |
| | | store.createIndex('timestamp', 'timestamp', { unique: false }); |
| | | store.createIndex('content', 'content', { unique: false }); |
| | | store.createIndex('itemId', 'itemId', { unique: false }); |
| | | } |
| | | |
| | | // Blob storage remains separate for performance |
| | | if (!db.objectStoreNames.contains('uploadBlobs')) { |
| | | db.createObjectStore('uploadBlobs', { keyPath: 'uploadId' }); |
| | | } |
| | | getStatusProgress(status) { |
| | | const progress = { |
| | | 'local_processing': 28, |
| | | 'queued': 50, |
| | | 'uploading': 66, |
| | | 'pending': 75, |
| | | 'processing': 89, |
| | | 'completed': 100 |
| | | }; |
| | | |
| | | request.onsuccess = (e) => { |
| | | this.db = e.target.result; |
| | | this.loadFields(); |
| | | this.checkPendingUploads(); |
| | | }; |
| | | |
| | | request.onerror = (e) => { |
| | | console.error('IndexedDB error:', e); |
| | | }; |
| | | return progress[status] || 0; |
| | | } |
| | | |
| | | async loadFields() { |
| | | if (!this.db) return; |
| | | |
| | | return new Promise((resolve) => { |
| | | const tx = this.db.transaction(['fieldStates', 'uploadBlobs'], 'readonly'); |
| | | const fieldStates = tx.objectStore('fieldStates'); |
| | | const blobStore = tx.objectStore('uploadBlobs'); |
| | | const request = fieldStates.getAll(); |
| | | createUploadElement(upload, draggable = false) { |
| | | let image = window.getTemplate('uploadItem'); |
| | | if (!image) return; |
| | | |
| | | request.onsuccess = (e) => { |
| | | e.target.result.forEach(field => { |
| | | let uploads = field.uploads; |
| | | let uploadIds = uploads.map(upload => upload.id); |
| | | field.uploads = new Set(uploadIds); |
| | | this.fields.set(field.key, field); |
| | | uploads.forEach(upload => { |
| | | this.uploads.set(upload.id, upload); |
| | | }); |
| | | }); |
| | | this.notify('uploads-loaded', { items: Array.from(this.uploads.values()) }); |
| | | resolve(); |
| | | }; |
| | | image.dataset.uploadId = upload.id; |
| | | image.dataset.subtype = upload.subtype || 'image'; |
| | | |
| | | const blobRequest = blobStore.getAll(); |
| | | let [featured, img, video, preview, details] = [ |
| | | image.querySelector('[name="featured"]'), |
| | | image.querySelector('img'), |
| | | image.querySelector('video'), |
| | | image.querySelector('label > span'), |
| | | image.querySelector('details') |
| | | ]; |
| | | |
| | | blobRequest.onsuccess = (e) => { |
| | | e.target.result.forEach(item => { |
| | | this.uploadBlobs.set(item.id, item); |
| | | }); |
| | | this.notify('blobs-loaded', { items: Array.from(this.uploadBlobs.values()) }); |
| | | resolve(); |
| | | }; |
| | | }); |
| | | } |
| | | if (featured) featured.value = upload.id; |
| | | |
| | | getUpload(uploadId) { |
| | | return this.uploads.get(uploadId); |
| | | } |
| | | |
| | | updateFieldStatus(fieldId, status) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | field.uploads.forEach(upload => { |
| | | this.updateUploadStatus(upload, status); |
| | | }); |
| | | |
| | | // Update UI based on status |
| | | const container = field.ui.field.field; |
| | | if (container) { |
| | | container.dataset.uploadStatus = status; |
| | | |
| | | // Show/hide relevant UI elements |
| | | const submitBtn = container.querySelector('.submit-uploads'); |
| | | if (submitBtn) { |
| | | submitBtn.disabled = status === 'uploading' || status === 'processing'; |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle successful upload completion |
| | | */ |
| | | handleUploadComplete(operation) { |
| | | const response = operation.response; |
| | | if (!response?.uploads) return; |
| | | |
| | | response.uploads.forEach(serverUpload => { |
| | | const upload = this.uploads.get(serverUpload.upload_id); |
| | | if (upload) { |
| | | upload.attachmentId = serverUpload.attachment_id; |
| | | this.updateUploadStatus(serverUpload.upload_id, 'completed'); |
| | | this.uploads.set(upload.id, upload); |
| | | |
| | | // **ADD: Cleanup after successful upload** |
| | | this.clearUpload(upload.id); |
| | | } |
| | | }); |
| | | |
| | | const fieldKey = operation.data.get('field_key'); |
| | | if (fieldKey) { |
| | | // **ADD: Clear field cache after all uploads complete** |
| | | const field = this.fields.get(fieldKey); |
| | | const allComplete = Array.from(field.uploads).every(id => { |
| | | const upload = this.uploads.get(id); |
| | | return upload?.status === 'completed'; |
| | | }); |
| | | |
| | | if (allComplete) { |
| | | this.clearField(fieldKey); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Store upload with DataStore integration |
| | | */ |
| | | async setUpload(fieldId, file, uploadId = null) { |
| | | if (!uploadId) { |
| | | uploadId = this.generateUploadId(); |
| | | } |
| | | const upload = { |
| | | id: uploadId, |
| | | fieldId: fieldId, |
| | | groupId: null, |
| | | originalFile: file, |
| | | processedFile: null, |
| | | status: 'received', |
| | | progress: { percent: 0, message: 'Received...' }, |
| | | preview: URL.createObjectURL(file), |
| | | createdAt: Date.now(), |
| | | meta: { |
| | | title: '', |
| | | alt_text: '', |
| | | caption: '', |
| | | originalName: file.name, |
| | | originalType: file.type, |
| | | originalSize: file.size |
| | | }, |
| | | changes: {} |
| | | }; |
| | | |
| | | // Add to field |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) { |
| | | console.error(`Field ${fieldId} not found`); |
| | | return null; |
| | | } |
| | | if (!field.uploads) field.uploads = new Set(); |
| | | field.uploads.add(uploadId); |
| | | |
| | | upload.element = this.createImageElement(upload, field.type==='groupable'); |
| | | upload.ui = window.uiFromSelectors(this.selectors.item, upload.element); |
| | | |
| | | // Store in memory |
| | | this.uploads.set(uploadId, upload); |
| | | this.updateImageUI(uploadId); |
| | | |
| | | // Persist to DataStore |
| | | await this.persistFieldState(fieldId); |
| | | |
| | | return upload; |
| | | } |
| | | |
| | | /** |
| | | * Get uploads for a field, optionally cleaned for storage |
| | | * @param {string} fieldId |
| | | * @param {boolean} clean - Remove DOM references for IndexedDB storage |
| | | * @returns {Array} |
| | | */ |
| | | getFieldUploads(fieldId, clean = false) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field || !field.uploads) return []; |
| | | |
| | | return Array.from(field.uploads) |
| | | .map(uploadId => { |
| | | const upload = this.uploads.get(uploadId); |
| | | if (!upload) return null; |
| | | |
| | | if (clean) { |
| | | // Return cleaned version without DOM references |
| | | return { |
| | | id: upload.id, |
| | | fieldId: upload.fieldId, |
| | | status: upload.status, |
| | | preview: upload.preview, |
| | | attachmentId: upload.attachmentId, |
| | | operationId: upload.operationId, |
| | | groupId: upload.groupId || null, |
| | | meta: { |
| | | originalName: upload.meta?.originalName || upload.originalFile?.name, |
| | | size: upload.meta?.size || upload.originalFile?.size, |
| | | type: upload.meta?.type || upload.originalFile?.type, |
| | | title: upload.meta?.title, |
| | | alt: upload.meta?.alt, |
| | | caption: upload.meta?.caption |
| | | } |
| | | }; |
| | | 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; |
| | | } |
| | | |
| | | // Return full upload object |
| | | return upload; |
| | | }) |
| | | .filter(Boolean); |
| | | 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; |
| | | } |
| | | |
| | | /** |
| | | * Persist upload to DataStore |
| | | * Get all files for a specific field |
| | | */ |
| | | async persistFieldState(fieldId) { |
| | | if (!this.db) return; |
| | | async getFilesForField(fieldId) { |
| | | const fieldData = this.getFieldData(fieldId); |
| | | if (!fieldData?.uploads) return []; |
| | | |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | const files = []; |
| | | const uploadsArray = fieldData.uploads instanceof Set |
| | | ? Array.from(fieldData.uploads) |
| | | : fieldData.uploads; |
| | | |
| | | // Create clean field config |
| | | const { ui, ...cleanConfig } = field; |
| | | for (const uploadId of uploadsArray) { |
| | | const upload = this.uploadStore.get(uploadId); |
| | | if (!upload) continue; |
| | | |
| | | const fieldState = { |
| | | fieldId: fieldId, |
| | | timestamp: Date.now(), |
| | | |
| | | config: { |
| | | ...cleanConfig, |
| | | fieldName: field.name, |
| | | dataField: field.ui?.field?.field?.dataset?.field |
| | | }, |
| | | |
| | | // Recovery context with normalized URL |
| | | context: { |
| | | url: this.normalizeUrl(window.location.href), |
| | | fullUrl: window.location.href, // Keep for reference |
| | | modalType: this.getModalType(field), |
| | | formId: field.formId, |
| | | // **FIX**: Store additional identifiers |
| | | fieldSelector: `.field.upload[data-field="${field.name}"]` |
| | | }, |
| | | |
| | | // Uploads (cleaned of DOM references and blob URLs) |
| | | uploads: this.getFieldUploads(fieldId, true).map(upload => { |
| | | // **FIX**: Don't store blob URLs as they become invalid |
| | | const { preview, element, location, ...cleanUpload } = upload; |
| | | return cleanUpload; |
| | | }), |
| | | |
| | | // Groups structure |
| | | groups: Array.from(this.groups.entries()) |
| | | .filter(([id, data]) => data.fieldId === fieldId && data.uploads && data.uploads.size > 0) |
| | | .map(([id, data]) => ({ |
| | | id: data.id, |
| | | uploads: Array.from(data.uploads), |
| | | meta: data.meta || {}, |
| | | changes: data.changes || {} |
| | | })) |
| | | }; |
| | | |
| | | try { |
| | | const tx = this.db.transaction(['fieldStates'], 'readwrite'); |
| | | await tx.objectStore('fieldStates').put(fieldState); |
| | | } catch (error) { |
| | | console.error('Failed to persist field state:', error); |
| | | } |
| | | } |
| | | |
| | | normalizeUrl(url) { |
| | | try { |
| | | const urlObj = new URL(url); |
| | | // Return just the origin + pathname (no query string or hash) |
| | | return urlObj.origin + urlObj.pathname; |
| | | } catch (e) { |
| | | return url; |
| | | } |
| | | } |
| | | /******************************************************************************* |
| | | RESTORE FUNCTIONALITY |
| | | *******************************************************************************/ |
| | | async checkPendingUploads() { |
| | | if (!this.db) return; |
| | | |
| | | const tx = this.db.transaction(['fieldStates'], 'readonly'); |
| | | const fieldStore = tx.objectStore('fieldStates'); |
| | | |
| | | const allFieldStates = await new Promise(resolve => { |
| | | const request = fieldStore.getAll(); |
| | | request.onsuccess = () => resolve(request.result); |
| | | }); |
| | | |
| | | |
| | | allFieldStates.forEach(field => { |
| | | console.log(`Field ${field.fieldId} has ${field.uploads.length} uploads:`); |
| | | field.uploads.forEach((upload, idx) => { |
| | | console.log(` Upload ${idx}:`, { |
| | | id: upload.id, |
| | | status: upload.status, |
| | | operationId: upload.operationId, |
| | | hasOperationId: !!upload.operationId |
| | | // 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); |
| | | }); |
| | | }); |
| | | |
| | | // Filter for pending uploads (not yet sent to server) |
| | | const pendingFields = allFieldStates.filter(field => |
| | | field.uploads.some(upload => |
| | | // If no operationId, it hasn't been sent to server yet |
| | | !upload.operationId && |
| | | // And it's been processed locally |
| | | (upload.status === 'completed' || |
| | | upload.status === 'processed' || |
| | | upload.status === 'local_processing' || |
| | | upload.status === 'processed-original') |
| | | ) |
| | | ); |
| | | |
| | | console.log('Pending Fields: ', pendingFields); |
| | | |
| | | if (pendingFields.length === 0) return; |
| | | |
| | | // Show recovery notification |
| | | this.showRecoveryNotification(pendingFields); |
| | | await this.showRecoveryNotification(pendingFields); |
| | | } |
| | | |
| | | async showRecoveryNotification(pendingFields) { |
| | |
| | | } |
| | | |
| | | // Build appropriate message |
| | | let message = ''; |
| | | let message; |
| | | if (totalGroups > 0) { |
| | | let group = totalGroups > 1 ? 'groups' : 'group'; |
| | | let upload = totalUploads > 1 ? 'uploads' : 'upload'; |
| | |
| | | const itemGrid = fieldTemplate.querySelector('.item-grid.restore'); |
| | | |
| | | // Process each upload |
| | | for (const upload of field.uploads) { |
| | | |
| | | 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 blobData = await this.getBlobData(upload.id); |
| | | // |
| | | // const imgEl = uploadItem.querySelector('img'); |
| | | // const placeholderEl = uploadItem.querySelector('.image-placeholder'); |
| | | // |
| | | const file = await this.getBlobData(upload.id); |
| | | if (file) { |
| | | |
| | | |
| | | if (blobData) { |
| | | try { |
| | | // Create new blob URL from stored data |
| | | const blob = new Blob([blobData.data], { type: blobData.type }); |
| | | const previewUrl = URL.createObjectURL(blob); |
| | | const previewUrl = this.createPreviewUrl(file); |
| | | |
| | | let [ |
| | | featured, |
| | |
| | | ]; |
| | | |
| | | uploadItem.dataset.uploadId = upload.id; |
| | | uploadItem.dataset.fieldId = field.config.key; |
| | | |
| | | let subtype = this.getSubtypeFromMime(blobData.type); |
| | | |
| | | uploadItem.dataset.fieldId = field.id; |
| | | |
| | | let subtype = this.getSubtypeFromMime(file.type); |
| | | uploadItem.dataset.subtype = subtype; |
| | | switch (subtype) { |
| | | case 'image': |
| | |
| | | img.alt |
| | | ] = [ |
| | | previewUrl, |
| | | upload.originalFile?.name ?? upload.meta?.originalName?? '' |
| | | file.name ?? upload.meta?.originalName ?? '' |
| | | ]; |
| | | video.remove(); |
| | | preview.remove(); |
| | |
| | | |
| | | } |
| | | |
| | | async cleanupStoredRestoration() { |
| | | if (!this.db) return; |
| | | |
| | | const notification = document.querySelector('dialog.restore-uploads'); |
| | | if (!notification) return; |
| | | |
| | | // Get all upload IDs from the notification |
| | | const items = notification.querySelectorAll('[data-upload-id]'); |
| | | const uploadIds = Array.from(items).map(item => item.dataset.uploadId); |
| | | |
| | | // Clean up blob URLs in the notification |
| | | this.cleanupRestoreNotificationUrls(notification); |
| | | |
| | | // **Delete blob data from IndexedDB** |
| | | if (uploadIds.length > 0) { |
| | | const tx = this.db.transaction(['uploadBlobs', 'fieldStates'], 'readwrite'); |
| | | |
| | | // Delete all blob data |
| | | uploadIds.forEach(uploadId => { |
| | | tx.objectStore('uploadBlobs').delete(uploadId); |
| | | }); |
| | | |
| | | // Also delete field states |
| | | const fieldIds = Array.from(items).map(item => item.dataset.fieldId); |
| | | const uniqueFieldIds = [...new Set(fieldIds)]; |
| | | |
| | | uniqueFieldIds.forEach(fieldId => { |
| | | if (fieldId) { |
| | | tx.objectStore('fieldStates').delete(fieldId); |
| | | } |
| | | }); |
| | | |
| | | await tx.complete; |
| | | } |
| | | } |
| | | |
| | | cleanupRestoreNotificationUrls(notification) { |
| | | if (!notification) return; |
| | | |
| | | // Find all elements with preview URLs |
| | | const items = notification.querySelectorAll('[data-preview-url]'); |
| | | items.forEach(item => { |
| | | const url = item.dataset.previewUrl; |
| | | if (url && url.startsWith('blob:')) { |
| | | URL.revokeObjectURL(url); |
| | | delete item.dataset.previewUrl; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | 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) { |
| | | // Group by field |
| | | const byField = new Map(); |
| | | selectedUploads.forEach(item => { |
| | | if (!byField.has(item.fieldId)) { |
| | | byField.set(item.fieldId, []); |
| | | } |
| | | byField.get(item.fieldId).push(item.uploadId); |
| | | }); |
| | | |
| | | // Get full field states from IndexedDB |
| | | if (!this.db) { |
| | | // this.notifications.add('Cannot restore: Database not available', 'error'); |
| | | async handleRestoreUploads() { |
| | | let notification = document.querySelector('dialog.restore-uploads'); |
| | | if (!notification) { |
| | | return; |
| | | } |
| | | |
| | | const tx = this.db.transaction(['fieldStates'], 'readonly'); |
| | | const store = tx.objectStore('fieldStates'); |
| | | |
| | | for (const [fieldId, uploadIds] of byField.entries()) { |
| | | const request = store.get(fieldId); |
| | | const fieldState = await new Promise(resolve => { |
| | | request.onsuccess = () => resolve(request.result); |
| | | request.onerror = () => resolve(null); |
| | | }); |
| | | |
| | | if (fieldState) { |
| | | // Filter to only selected uploads |
| | | fieldState.uploads = fieldState.uploads.filter(u => uploadIds.includes(u.id)); |
| | | await this.restoreField(fieldState); |
| | | } |
| | | } |
| | | |
| | | // this.notifications.add(`Restored ${selectedUploads.length} upload(s)`, 'success'); |
| | | } |
| | | |
| | | async restoreField(fieldState) { |
| | | const { config, context, uploads, groups } = 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.fields.has(fieldKey)) { |
| | | fieldKey = this.registerUploader(fieldElement, config); |
| | | } |
| | | |
| | | const field = this.fields.get(fieldKey); |
| | | if (!field) { |
| | | console.error('Failed to register field for restoration'); |
| | | return; |
| | | } |
| | | |
| | | if (!field.ui.groups) { |
| | | field.ui.groups = {}; |
| | | } |
| | | if (!field.ui.groups.groups) { |
| | | field.ui.groups.groups = new Map(); |
| | | } |
| | | |
| | | // Make sure we have the container and empty group references |
| | | if (!field.ui.groups.container) { |
| | | field.ui.groups.container = fieldElement.querySelector('.item-grid.groups'); |
| | | } |
| | | if (!field.ui.groups.empty) { |
| | | field.ui.groups.empty = fieldElement.querySelector('.empty-group'); |
| | | } |
| | | let display = fieldElement.querySelector('.group-display'); |
| | | if (display) { |
| | | display.hidden = false; |
| | | } |
| | | |
| | | // Restore uploads |
| | | for (const uploadData of uploads) { |
| | | await this.restoreUpload(field, uploadData); |
| | | } |
| | | |
| | | // Restore groups |
| | | if (groups && groups.length > 0) { |
| | | await this.restoreGroups(field, groups, uploads); |
| | | } |
| | | |
| | | // Update UI |
| | | this.updateFieldState(fieldKey); |
| | | this.maybeLockUploads(fieldKey); |
| | | |
| | | await this.persistFieldState(fieldKey); |
| | | |
| | | // Queue for upload if needed (should not happen for post_group) |
| | | if (config.mode === 'direct' && config.destination !== 'post_group') { |
| | | await this.queueUpload(fieldKey); |
| | | } |
| | | } |
| | | |
| | | async restoreUpload(field, uploadData) { |
| | | // Try to get blob data from IndexedDB |
| | | const blobData = await this.getBlobData(uploadData.id); |
| | | |
| | | if (blobData) { |
| | | const file = blobData.data instanceof File |
| | | ? blobData.data |
| | | : new File( |
| | | [blobData.data], |
| | | blobData.name, |
| | | { type: blobData.type, lastModified: blobData.lastModified } |
| | | ); |
| | | |
| | | uploadData.originalFile = file; |
| | | uploadData.processedFile = file; |
| | | uploadData.preview = URL.createObjectURL(file); |
| | | } else { |
| | | console.warn('Blob data not found for upload:', uploadData.id); |
| | | return; // Skip this upload if we can't restore the file |
| | | } |
| | | |
| | | // Add to field |
| | | if (!field.uploads) field.uploads = new Set(); |
| | | field.uploads.add(uploadData.id); |
| | | |
| | | // Recreate DOM element |
| | | const subtype = this.getSubtypeFromMime(uploadData.originalFile.type); |
| | | uploadData.element = this.createImageElement({ |
| | | ...uploadData, |
| | | subtype: subtype |
| | | }, field.destination === 'post_group'); |
| | | |
| | | // Restore to correct location |
| | | let location; |
| | | if (uploadData.groupId && field.ui.groups.groups.has(uploadData.groupId)) { |
| | | location = field.ui.groups.groups.get(uploadData.groupId).querySelector('.item-grid'); |
| | | } else { |
| | | location = field.ui.field.preview; |
| | | } |
| | | |
| | | if (location) { |
| | | location.appendChild(uploadData.element); |
| | | uploadData.location = location; |
| | | } |
| | | |
| | | // Store in memory |
| | | this.uploads.set(uploadData.id, uploadData); |
| | | } |
| | | |
| | | async restoreFieldStates(fieldStates) { |
| | | // Group by URL |
| | | const byUrl = new Map(); |
| | | fieldStates.forEach(field => { |
| | | if (!byUrl.has(field.context.url)) { |
| | | byUrl.set(field.context.url, []); |
| | | } |
| | | byUrl.get(field.context.url).push(field); |
| | | }); |
| | | |
| | | // If all on current page, restore directly |
| | | if (byUrl.size === 1 && byUrl.has(window.location.href)) { |
| | | for (const fieldState of fieldStates) { |
| | | await this.restoreField(fieldState); |
| | | } |
| | | // this.notifications.add(`Restored ${fieldStates.length} field(s)`, 'success'); |
| | | } else { |
| | | // Store intent to restore and navigate |
| | | sessionStorage.setItem('jvb_restore_uploads', JSON.stringify(fieldStates)); |
| | | |
| | | // Navigate to first URL |
| | | const firstUrl = byUrl.keys().next().value; |
| | | if (window.location.href !== firstUrl) { |
| | | window.location.href = firstUrl; |
| | | } |
| | | } |
| | | } |
| | | |
| | | async restoreGroups(field, groups, uploads) { |
| | | // Ensure the groups.groups Map exists |
| | | if (!field.ui.groups.groups) { |
| | | field.ui.groups.groups = new Map(); |
| | | } |
| | | |
| | | for (const groupData of groups) { |
| | | // Create group element |
| | | const groupElement = this.createGroupElement(groupData.id, field.key); |
| | | |
| | | // Store in field UI Map |
| | | field.ui.groups.groups.set(groupData.id, groupElement); |
| | | |
| | | // Insert into DOM |
| | | if (field.ui.groups.container && field.ui.groups.empty) { |
| | | field.ui.groups.container.insertBefore(groupElement, field.ui.groups.empty); |
| | | } else if (field.ui.groups.container) { |
| | | field.ui.groups.container.appendChild(groupElement); |
| | | } |
| | | |
| | | this.groups.set(groupData.id, { |
| | | id: groupData.id, |
| | | fieldId: field.key, |
| | | element: groupElement, |
| | | uploads: new Set(groupData.uploads), // FIXED: was groupData.uploadIds |
| | | meta: groupData.meta || {}, |
| | | changes: groupData.changes || {} |
| | | }); |
| | | |
| | | // Move uploads to group |
| | | groupData.uploads.forEach(uploadId => { |
| | | const upload = uploads.find(u => u.id === uploadId); |
| | | if (upload && upload.element) { |
| | | const groupGrid = groupElement.querySelector('.item-grid'); |
| | | if (groupGrid) { |
| | | groupGrid.appendChild(upload.element); |
| | | upload.location = groupGrid; |
| | | upload.groupId = groupData.id; |
| | | } |
| | | } |
| | | }); |
| | | } |
| | | } |
| | | |
| | | async getBlobData(uploadId) { |
| | | if (!this.db) return null; |
| | | |
| | | const tx = this.db.transaction(['uploadBlobs'], 'readonly'); |
| | | const request = tx.objectStore('uploadBlobs').get(uploadId); |
| | | |
| | | return new Promise(resolve => { |
| | | request.onsuccess = () => resolve(request.result); |
| | | request.onerror = () => resolve(null); |
| | | }); |
| | | } |
| | | |
| | | async openModalForRestore(context) { |
| | | const { modalType, formId } = 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 |
| | | trigger = document.querySelector(`[data-action="edit"][data-id="${context.itemId}"]`); |
| | | break; |
| | | case 'bulkEdit': |
| | | trigger = document.querySelector('[data-action="bulk-edit"]'); |
| | | break; |
| | | } |
| | | |
| | | if (trigger) { |
| | | trigger.click(); |
| | | |
| | | // Wait for modal to open |
| | | await new Promise(resolve => setTimeout(resolve, 300)); |
| | | } |
| | | } |
| | | /******************************************************************************* |
| | | GROUP FUNCTIONALITY |
| | | Includes selection, dragging, and grouping logic |
| | | *******************************************************************************/ |
| | | /** |
| | | * |
| | | * @param {string} uploadId as defined by setUpload |
| | | * @param {HTMLElement|null} target The target location |
| | | * @param {boolean} persist whethet to cache this change |
| | | */ |
| | | addImageToGroup(uploadId, target = null, persist = true) { |
| | | let upload = this.getUpload(uploadId); |
| | | if(!upload) { |
| | | return; |
| | | } |
| | | let field = this.fields.get(upload.fieldId); |
| | | if (!field) { |
| | | return; |
| | | } |
| | | |
| | | //Already in the Preview Grid, or already in the group we're moving to |
| | | if ((!target && upload.location === field.ui.field.preview) || target === upload.location) { |
| | | return; |
| | | } |
| | | |
| | | // Remove from previous location |
| | | if (upload.location) { |
| | | let groupId = upload.location.dataset.groupId; |
| | | if (groupId) { |
| | | let group = this.groups.get(groupId); |
| | | if (group && group.uploads) { |
| | | group.uploads.delete(uploadId); |
| | | |
| | | if (group.uploads.size === 0) { |
| | | this.removeGroup(groupId); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | const checkbox = upload.element.querySelector('[name*="select-item"]'); |
| | | if (checkbox) { |
| | | checkbox.checked = false; |
| | | } |
| | | |
| | | upload.element.querySelector('[name="featured"]').hidden = !target; |
| | | |
| | | //If no target, it's going to the preview grid |
| | | if (!target) { |
| | | target = field.ui.field.preview; |
| | | } else if (!target.classList.contains('item-grid') || !target.classList.contains('preview')) { |
| | | // It's a group target |
| | | let groupId = target.dataset.groupId; |
| | | let group = this.groups.get(groupId); |
| | | if (!group) { |
| | | group = this.createGroup(upload.fieldId); |
| | | target = group.grid; |
| | | } |
| | | if (group) { |
| | | group.uploads.add(uploadId); |
| | | } |
| | | } |
| | | |
| | | upload.location = target; |
| | | target.append(upload.element); |
| | | |
| | | if (persist) { |
| | | this.persistFieldState(field.key); |
| | | } |
| | | } |
| | | |
| | | addSelectionToGroup(target) { |
| | | let field = this.getFieldFromElement(target); |
| | | if (!field) { |
| | | return; |
| | | } |
| | | let currentSelection = this.getCurrentSelection(field.key); |
| | | if (currentSelection.length === 0 ) { |
| | | return; |
| | | } |
| | | |
| | | let group = this.getGroupFromElement(target); |
| | | if (!group && target !== field.ui.field.preview) { |
| | | group = this.createGroup(field.key); |
| | | } |
| | | |
| | | currentSelection.forEach(uploadId => { |
| | | this.addImageToGroup(uploadId, group.grid??null, false); |
| | | }); |
| | | |
| | | this.persistFieldState(group.fieldId); |
| | | } |
| | | |
| | | getCurrentSelection(fieldId) { |
| | | let selected = []; |
| | | for (var [key, handler] of this.selectionHandlers) { |
| | | if ((fieldId === key || key.includes(fieldId)) && handler.selectedItems.size > 0) { |
| | | selected = selected.concat([... handler.selectedItems]); |
| | | } |
| | | } |
| | | return selected; |
| | | } |
| | | |
| | | /** |
| | | * Remove an empty group from the field |
| | | * @param {string} groupId - The group to remove |
| | | * @param {boolean} confirm - ask for confirmation |
| | | */ |
| | | removeGroup(groupId, confirm = false) { |
| | | let group = this.groups.get(groupId); |
| | | if (!group) { |
| | | return; |
| | | } |
| | | |
| | | if (confirm && group.uploads && group.uploads.size > 0) { |
| | | if(!window.confirm('This will delete this group. Any uploads in this group will return to the main grid. Are you sure?')){ |
| | | return; |
| | | } |
| | | } |
| | | |
| | | // Move any remaining uploads back to preview |
| | | if (group.uploads && group.uploads.size > 0) { |
| | | Array.from(group.uploads).forEach(uploadId => { |
| | | this.addImageToGroup(uploadId, null, false); |
| | | }); |
| | | } |
| | | |
| | | // Remove from groups Map |
| | | this.groups.delete(groupId); |
| | | |
| | | // Remove DOM element |
| | | let groupElement = group.element; |
| | | if (groupElement) { |
| | | groupElement.remove(); |
| | | this.a11y.announce('Group removed'); |
| | | } |
| | | |
| | | this.persistFieldState(group.fieldId); |
| | | } |
| | | |
| | | /** |
| | | * Create a new group |
| | | */ |
| | | createGroup(fieldKey) { |
| | | const field = this.fields.get(fieldKey); |
| | | if (!field) { |
| | | console.error('Field not found:', fieldKey); |
| | | return null; |
| | | } |
| | | |
| | | const groupId = `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; |
| | | |
| | | const groupElement = this.createGroupElement(groupId, fieldKey); |
| | | if (!groupElement) { |
| | | console.error('Failed to create group element'); |
| | | return null; |
| | | } |
| | | |
| | | // Store in field UI Map |
| | | if (!field.ui.groups) { |
| | | field.ui.groups = { |
| | | groups: new Map(), |
| | | container: null, |
| | | empty: null, |
| | | display: null |
| | | }; |
| | | } |
| | | |
| | | field.ui.groups.groups.set(groupId, groupElement); |
| | | |
| | | // Insert into DOM |
| | | if (field.ui.groups.container && field.ui.groups.empty) { |
| | | field.ui.groups.container.insertBefore(groupElement, field.ui.groups.empty); |
| | | } else if (field.ui.groups.container) { |
| | | field.ui.groups.container.appendChild(groupElement); |
| | | } |
| | | |
| | | // Create group object |
| | | const group = { |
| | | id: groupId, |
| | | fieldId: fieldKey, |
| | | element: groupElement, |
| | | grid: groupElement.querySelector('.item-grid.group'), |
| | | uploads: new Set(), |
| | | meta: {}, |
| | | changes: {} |
| | | }; |
| | | |
| | | // Store group |
| | | this.groups.set(groupId, group); |
| | | |
| | | // Initialize selection handler for this group |
| | | this.addGroupSelectionHandler(fieldKey, groupId); |
| | | |
| | | // Persist state |
| | | this.persistFieldState(fieldKey); |
| | | |
| | | return group; |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Remove upload from group |
| | | */ |
| | | removeFromGroup(fieldId, uploadId, groupId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field || !field.groups) return; |
| | | |
| | | const group = field.groups.find(g => g.id === groupId); |
| | | if (!group) return; |
| | | |
| | | group.uploads = group.uploads.filter(id => id !== uploadId); |
| | | |
| | | this.renderGroupUI(fieldId); |
| | | this.persistFieldState(field.key); |
| | | } |
| | | |
| | | /** |
| | | * Update group title |
| | | */ |
| | | updateGroupTitle(fieldId, groupId, title) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field || !field.groups) return; |
| | | |
| | | const group = field.groups.find(g => g.id === groupId); |
| | | if (!group) return; |
| | | |
| | | group.title = title; |
| | | this.persistFieldState(field.key); |
| | | } |
| | | |
| | | /** |
| | | * Delete group |
| | | */ |
| | | deleteGroup(fieldId, groupId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field || !field.groups) return; |
| | | |
| | | field.groups = field.groups.filter(g => g.id !== groupId); |
| | | |
| | | this.renderGroupUI(fieldId); |
| | | this.removeSelectionHandler(fieldId, groupId); |
| | | this.persistFieldState(field.key); |
| | | } |
| | | |
| | | /** |
| | | * Render group UI |
| | | */ |
| | | renderGroupUI(fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field || !field.groups) return; |
| | | |
| | | const container = field.ui.group.container; |
| | | if (!container) { |
| | | console.warn('Groups container not found for field:', fieldId); |
| | | return; |
| | | } |
| | | |
| | | // Clear existing |
| | | window.removeChildren(container); |
| | | |
| | | // Render each group |
| | | field.groups.forEach(group => { |
| | | const groupEl = this.createGroupElement(fieldId, group); |
| | | container.appendChild(groupEl); |
| | | }); |
| | | } |
| | | |
| | | createGroupElement(groupId, fieldId) { |
| | | let groupElement = window.getTemplate('imageGroup'); |
| | | if (!groupElement) return; |
| | | |
| | | groupElement.dataset.groupId = groupId; |
| | | groupElement.dataset.fieldId = fieldId; |
| | | |
| | | let fields = window.getTemplate('groupMetadata'); |
| | | const fieldsContainer = groupElement.querySelector('.fields'); |
| | | if (fieldsContainer && fields) { |
| | | fieldsContainer.append(fields); |
| | | |
| | | // Set unique IDs and names for form fields |
| | | const titleInput = fieldsContainer.querySelector('[name="post_title"]'); |
| | | const excerptInput = fieldsContainer.querySelector('[name="post_excerpt"]'); |
| | | |
| | | if (titleInput) { |
| | | titleInput.id = `${groupId}_title`; |
| | | titleInput.name = `${groupId}[post_title]`; |
| | | } |
| | | if (excerptInput) { |
| | | excerptInput.id = `${groupId}_excerpt`; |
| | | excerptInput.name = `${groupId}[post_excerpt]`; |
| | | } |
| | | let field = this.fields.get(fieldId); |
| | | if (field.content !== '') { |
| | | let summary = groupElement.querySelector('summary'); |
| | | summary.textContent = field.content + ' Fields'; |
| | | } |
| | | } else { |
| | | groupElement.querySelector('details').remove(); |
| | | } |
| | | |
| | | const gridContainer = groupElement.querySelector('.item-grid.group'); |
| | | if (gridContainer) { |
| | | gridContainer.dataset.groupId = groupId; |
| | | } |
| | | |
| | | return groupElement; |
| | | } |
| | | |
| | | handleSelectAll(element, checked = null) { |
| | | this.a11y.announce(checked ? 'All uploads selected' : 'All uploads deselected'); |
| | | } |
| | | |
| | | clearAllSelections(field) { |
| | | const handler = this.selectionHandlers.get(field.key); |
| | | if (handler) { |
| | | handler.clearSelection(); |
| | | } |
| | | } |
| | | |
| | | getSelectedUploads(element) { |
| | | const field = this.getFieldFromElement(element); |
| | | if (!field) return []; |
| | | |
| | | const handler = this.selectionHandlers.get(field.key); |
| | | return handler ? handler.getSelected() : []; |
| | | } |
| | | |
| | | removeSelection(button) { |
| | | let fieldId = this.getFieldIdFromElement(button); |
| | | |
| | | const selectedUploads = this.getSelectedUploads(button); |
| | | const selectedUploads = this.getSelectedRestorationUploads(notification); |
| | | if (selectedUploads.length === 0) { |
| | | this.notify('No uploads selected', 'warning'); |
| | | return; |
| | | } |
| | | await this.restoreSelectedUploads(selectedUploads); |
| | | |
| | | selectedUploads.forEach(upload => { |
| | | this.removeUpload(fieldId, upload); |
| | | 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(); |
| | | } |
| | | |
| | | removeUpload(fieldId, uploadId) { |
| | | const field = this.fields.get(fieldId); |
| | | const upload = this.uploads.get(uploadId); |
| | | |
| | | if (!field || !upload) return; |
| | | |
| | | // Remove from field |
| | | field.uploads?.delete(uploadId); |
| | | |
| | | // Remove from group if grouped |
| | | if (upload.groupId) { |
| | | const group = this.groups.get(upload.groupId); |
| | | if (group && group.uploads) { |
| | | group.uploads.delete(uploadId); |
| | | |
| | | if (group.uploads.size === 0) { |
| | | this.removeGroup(upload.groupId); |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Clean up element |
| | | upload.element?.remove(); |
| | | |
| | | // Clean up memory |
| | | this.clearUpload(uploadId); |
| | | |
| | | // Update field state after removal |
| | | this.updateFieldState(fieldId); |
| | | |
| | | // Update UI |
| | | this.maybeLockUploads(fieldId); |
| | | const handler = this.selectionHandlers.get(field.key); |
| | | if (handler) { |
| | | handler.deselect(uploadId); |
| | | } |
| | | |
| | | this.a11y.announce('Upload removed'); |
| | | showSaveIndicator(key) { |
| | | // Optional: show user that state is being saved |
| | | } |
| | | |
| | | /************************************************************************** |
| | | META |
| | | Handled separately, in case it is edited in the middle of processing images |
| | | **************************************************************************/ |
| | | cleanupRestore() { |
| | | this.restoreModal.handleClose(); |
| | | this.restoreSelection.destroy(); |
| | | this.restoreSelection = null; |
| | | this.restoreModal.destroy(); |
| | | this.restoreModal.modal.remove(); |
| | | this.restoreModal = null; |
| | | } |
| | | |
| | | /************************************************************************** |
| | | SUBSCRIBERS |
| | | **************************************************************************/ |
| | | /** |
| | | * Event system |
| | | */ |
| | | 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 => cb(event, data)); |
| | | } |
| | | |
| | | handleBeforeUnload(e) { |
| | | // Check for any uploads in processing or pending state |
| | | const unsavedUploads = Array.from(this.uploads.values()).filter(upload => |
| | | upload.status === 'processing' || |
| | | upload.status === 'pending' || |
| | | upload.status === 'uploading' |
| | | ); |
| | | |
| | | if (unsavedUploads.length > 0) { |
| | | const message = 'You have uploads in progress. Are you sure you want to leave?'; |
| | | e.preventDefault(); |
| | | e.returnValue = message; |
| | | return message; |
| | | } |
| | | } |
| | | /************************************************************************** |
| | | CLEANUP |
| | | **************************************************************************/ |
| | | cleanup() { |
| | | this.clearListeners(); |
| | | if (this.hasGroups) { |
| | | this.clearGroupListeners(); |
| | | } |
| | | this.compressionWorker = null; |
| | | this.subscribers.clear(); |
| | | } |
| | | |
| | | /** |
| | | * Clear individual upload from cache after successful server upload |
| | | */ |
| | | async clearUpload(uploadId) { |
| | | const upload = this.uploads.get(uploadId); |
| | | if (!upload) return; |
| | | |
| | | // Clean up preview URL |
| | | if (upload.preview && upload.preview.startsWith('blob:')) { |
| | | URL.revokeObjectURL(upload.preview); |
| | | upload.preview = null; |
| | | } |
| | | |
| | | // Clean up element preview URL |
| | | if (upload.element) { |
| | | const previewUrl = upload.element.dataset.previewUrl; |
| | | if (previewUrl && previewUrl.startsWith('blob:')) { |
| | | URL.revokeObjectURL(previewUrl); |
| | | delete upload.element.dataset.previewUrl; |
| | | notify(event, data = {}) { |
| | | this.subscribers.forEach(cb => { |
| | | try { |
| | | cb(event, data); |
| | | } catch (error) { |
| | | console.error('Subscriber error:', error); |
| | | } |
| | | } |
| | | |
| | | this.persistFieldState(upload.fieldId); |
| | | // Remove from memory |
| | | this.uploads.delete(uploadId); |
| | | this.uploadBlobs.delete(uploadId); |
| | | |
| | | // Remove from IndexedDB |
| | | if (this.db) { |
| | | const tx = this.db.transaction(['uploadBlobs'], 'readwrite'); |
| | | await tx.objectStore('uploadBlobs').delete(uploadId); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Clear all uploads for a field and cleanup resources |
| | | */ |
| | | clearField(fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | /******************************************************************************* |
| | | * DESTROY & CLEANUP |
| | | *******************************************************************************/ |
| | | |
| | | const uploads = Array.from(field.uploads || []); |
| | | 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); |
| | | |
| | | // Cleanup each upload's resources |
| | | uploads.forEach(uploadId => { |
| | | this.clearUpload(uploadId); |
| | | this.uploads.delete(uploadId); |
| | | }); |
| | | |
| | | // Clear field state |
| | | this.fields.delete(fieldId); |
| | | |
| | | // Cleanup IndexedDB |
| | | if (this.db) { |
| | | const tx = this.db.transaction(['fieldStates', 'uploadBlobs'], 'readwrite'); |
| | | tx.objectStore('fieldStates').delete(fieldId); |
| | | uploads.forEach(uploadId => { |
| | | tx.objectStore('uploadBlobs').delete(uploadId); |
| | | }); |
| | | 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(); |
| | | } |
| | | } |
| | | |
| | | document.addEventListener('DOMContentLoaded', () => { |
| | | window.jvbUploads = new UploadManager(); |
| | | // Initialize when DOM is ready |
| | | document.addEventListener('DOMContentLoaded', async function () { |
| | | window.auth.subscribe((event) => { |
| | | if (event === 'auth-loaded') { |
| | | window.jvbUploads = new UploadManager(); |
| | | } |
| | | }); |
| | | }); |