class UploadManager { constructor() { //Load dependencies this.queue = window.jvbQueue; this.a11y = window.jvbA11y; this.error = window.jvbError; this.notifications = window.jvbNotifications; //Load Datastore this.initDB(); //State management this.fields = new Map(); this.uploads = new Map(); this.uploadBlobs = new Map(); this.timeouts = new Map(); 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(); //Worker this.worker = { worker: null, timeout: null, tasks: new Map(), restart: { count: 0, max: 3, }, settings: { timeout: 10000, //10 seconds per image batchSize: 1, maxConcurrent: 3, restartAfterTimeout: true } }; //Groups! this.touch = { x: null, y: null } this.hasBulkContext = document.querySelector('details.uploader')!==null; this.isTouching = false; this.groups = new Map(); this.groupsMeta = new Map(); //Notification and Subscribers this.subscribers = new Set(); this.settings = { allowedTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'], maxFileSize: 5242880, maxProcessingTime: 120000, // 2 minutes max for processing processingCheckInterval: 5000, // Check every 5 seconds smartCompression: true, fieldTypes: { 'single': { maxFiles: 1, allowMultiple: false }, 'gallery': { maxFiles: 20, allowMultiple: true }, 'groupable': { maxFiles: 20, allowMultiple: true } } }; 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...', 'queued': 'Waiting to upload...', 'uploading': 'Uploading to Server', 'pending': 'Successfully sent to server. In line for further processing.', 'processing': 'Processing on server...', 'completed': 'Upload complete!', 'failed': 'Upload failed (will retry)', 'failed_permanent': 'Upload failed permanently' }; this.init(); } async init() { this.initElements(); this.initListeners(); this.initCompressionWorker(); this.queue.subscribe((event, operation) => { if (operation.endpoint !== 'uploads') { return; } switch(event) { case 'cancel-operation': this.clearField(operation.data.get('field_key')); 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); } break; } }); this.scanFields(); } 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' } }; this.ui = {}; } scanFields() { document.querySelectorAll(this.selectors.field.field).forEach(uploader => { this.registerUploader(uploader); }); } /** * * @param {HTMLElement} uploader * @param {object} options * @param {string} options.id Uploader field ID: defaults to uploader.dataset.fieldId * @param {string} options.type Uploader type: defaults to uploader.dataset.type * @param {number} options.maxFiles Maximum files to allow: defaults to type defaults * @param {boolean} options.multiple Whether to allow multiple uploads * @param {number} options.itemID The post or term ID this is for. * @param {string} options.mode * @returns {string} */ registerUploader(uploader, options = {}) { //Determine if this is for a post, term, content uploader, or option let key = uploader.dataset['uploader']??this.determineKey(uploader); uploader.dataset['uploader'] = key; if (!this.fields.has(key)) { let type = uploader.dataset.type??'single'; 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 }; 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; } initSelectionHandler(fieldKey) { const field = this.fields.get(fieldKey); if (!field) return; // Don't reinitialize if already exists if (this.selectionHandlers.has(fieldKey)) { return this.selectionHandlers.get(fieldKey); } // 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 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"]', }); 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; } }); this.selectionHandlers.set(fieldKey, handler); return handler; } addGroupSelectionHandler(fieldId, groupId) { const field = this.fields.get(fieldId); if (!field) return; const group = this.groups.get(groupId); if (!group) return; let handlerKey = fieldId+'_'+groupId; // Don't reinitialize if already exists if (this.selectionHandlers.has(handlerKey)) { return this.selectionHandlers.get(handlerKey); } // 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; } 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; default: return 0; } } /****************************************************************************** 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); } document.addEventListener('click', this.clickHandler); document.addEventListener('change', this.changeHandler); window.addEventListener('beforeunload', this.handleBeforeUnload.bind(this)); } clearListeners() { document.removeEventListener('click', this.clickHandler); document.removeEventListener('change', this.changeHandler); if (this.hasBulkContext) { document.removeEventListener('paste', this.pasteHandler); } } initGroupListeners() { this.hasGroups = true; this.dragStartHandler = this.handleDragStart.bind(this); this.dragEndHandler = this.handleDragEnd.bind(this); this.dragEnterHandler = this.handleDragEnter.bind(this); this.dragOverHandler = this.handleDragOver.bind(this); this.dragLeaveHandler = this.handleDragLeave.bind(this); this.dropHandler = this.handleDrop.bind(this); this.touchStartHandler = this.handleTouchStart.bind(this); this.touchMoveHandler = this.handleTouchMove.bind(this); this.touchEndHandler = this.handleTouchEnd.bind(this); this.touchCancelHandler = this.handleTouchCancel.bind(this); document.addEventListener('dragstart', this.dragStartHandler); document.addEventListener('dragend', this.dragEndHandler); document.addEventListener('dragenter', this.dragEnterHandler); document.addEventListener('dragover', this.dragOverHandler); document.addEventListener('dragleave', this.dragLeaveHandler); document.addEventListener('drop', this.dropHandler); document.addEventListener('touchstart', this.touchStartHandler, { 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); } }); } 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); } 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 }); } 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'; } } 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; } } 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) 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); } } else { throw new Error('No file data available for retry'); } // 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; // Hide upload container, show group display if (field.ui.field.dropZone) { field.ui.field.dropZone.hidden = true; } if (field.ui.groups.display) { field.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) => { try { // Create upload ID const uploadId = `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // Create upload data const uploadData = { id: uploadId, fieldId: fieldId, originalFile: file, processedFile: null, preview: null, status: 'local_processing', element: null, location: null, meta: { originalName: file.name, size: file.size, type: file.type } }; // Create preview URL uploadData.preview = URL.createObjectURL(file); // Process the file (resize if image) if (file.type.startsWith('image/')) { uploadData.processedFile = await this.processImage(file, field.subtype); } else { uploadData.processedFile = file; } // Store blob data separately in IndexedDB 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 this.showUploadProgress(uploadId, true); this.updateUploadItemProgress(uploadId, 50, 'local_processing'); // Add to preview grid if (field.ui.field.preview) { field.ui.field.preview.appendChild(uploadData.element); uploadData.location = field.ui.field.preview; } // Store upload this.uploads.set(uploadId, uploadData); field.uploads.add(uploadId); // Update progress processedCount++; this.updateUploadProgress(fieldId, processedCount, totalFiles, 'Processing files...'); this.updateUploadItemProgress(uploadId, 100, 'processed'); uploadData.status = 'processed'; // Fade out item progress after a moment setTimeout(() => { this.showUploadProgress(uploadId, false); }, 1000); return uploadId; } catch (error) { console.error('Error processing file:', file.name, error); processedCount++; this.updateUploadProgress(fieldId, processedCount, totalFiles, 'Processing files...'); return null; } }); // Wait for all files to process await Promise.all(processPromises); this.updateFieldState(fieldId); // Cache the state (now without DOM references) await this.persistFieldState(fieldId); // Queue for upload if in direct mode if (field.mode === 'direct' && field.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' : ''}` ); } } /** * 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; } } async processImage(file, uploadId) { const timeout = this.worker.settings.timeout; return new Promise((resolve, reject) => { 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) { taskCompleted = true; clearTimeout(timeoutId); this.worker.tasks.delete(uploadId); resolve(result); } }) .catch(error => { if (!taskCompleted) { taskCompleted = true; clearTimeout(timeoutId); this.worker.tasks.delete(uploadId); reject(error); } }); }); } async handleProcess(file, uploadId) { // 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); } } catch (error) { console.warn('Worker processing failed, falling back to main thread:', error); } } // 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(); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); let objectUrl = null; const cleanup = () => { img.onload = null; img.onerror = null; if (objectUrl) { URL.revokeObjectURL(objectUrl); objectUrl = null; } // Explicitly clean up canvas canvas.width = 1; canvas.height = 1; ctx.clearRect(0, 0, 1, 1); }; img.onload = () => { try { const { width, height } = this.calculateOptimalDimensions(img, maxDimension); canvas.width = width; canvas.height = height; // Enhanced image smoothing ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(img, 0, 0, width, height); const outputFormat = this.getOptimalFormat(file); const outputQuality = this.getOptimalQuality(file, quality); canvas.toBlob( (blob) => { cleanup(); if (blob) { const processedFile = new File( [blob], this.getProcessedFileName(file, outputFormat), { type: outputFormat, lastModified: Date.now() } ); resolve(processedFile); } else { reject(new Error('Canvas toBlob failed')); } }, outputFormat, outputQuality ); } catch (error) { cleanup(); reject(new Error(`Canvas processing failed: ${error.message}`)); } }; img.onerror = () => { cleanup(); reject(new Error(`Failed to load image: ${file.name}`)); }; try { objectUrl = URL.createObjectURL(file); img.src = objectUrl; } catch (error) { cleanup(); reject(new Error(`Failed to create object URL: ${error.message}`)); } }); } /** * 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 typeof OffscreenCanvas !== 'undefined'; } async processWithWorker(file, uploadId, maxDimension, quality) { return new Promise((resolve, reject) => { if (!this.worker.worker) { reject(new Error('Worker not available')); return; } // 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); if (e.data.success) { const processedFile = new File( [e.data.blob], this.getProcessedFileName(file, e.data.format || 'image/webp'), { type: e.data.format || 'image/webp', lastModified: Date.now() } ); resolve(processedFile); } else { reject(new Error(e.data.error || 'Worker processing failed')); } }; const errorHandler = (error) => { this.worker.worker.removeEventListener('message', messageHandler); this.worker.worker.removeEventListener('error', errorHandler); reject(new Error(`Worker error: ${error.message}`)); }; // Add handlers this.worker.worker.addEventListener('message', messageHandler); this.worker.worker.addEventListener('error', errorHandler); // Send message to worker this.worker.worker.postMessage({ messageId, file, maxDimension, quality, outputFormat: this.getOptimalFormat(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 }); } }; `; const blob = new Blob([workerScript], { type: 'application/javascript' }); this.worker.worker = new Worker(URL.createObjectURL(blob)); } catch (error) { console.warn('Failed to initialize compression worker:', error); this.worker.worker = null; } } /** * 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); } /******************************************************************************* UI FUNCTIONALITY *******************************************************************************/ /** * Update upload status correctly */ updateUploadStatus(uploadId, status) { let upload = this.uploads.get(uploadId); if(!upload) { 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}`); } if (progressEl) { let icon = this.getStatusIcon(upload.status); let message = this.getStatusText(upload.status); let progress = this.getStatusProgress(upload.status); const fill = progressEl.querySelector('.fill'); const itemIcon = progressEl.querySelector('span.icon'); const itemMessage = progressEl.querySelector('span.details'); if (fill) { fill.style.width = `${progress}%`; } if (itemMessage) itemMessage.textContent = message; if (itemIcon) { window.removeChildren(itemIcon); itemIcon.append(icon); } if (upload.status === 'completed') { setTimeout(() => { if (progressEl) { window.fade(progressEl, false); } }, 1000); } } } /** * Hide the uploader drop zone if we have reached our limit */ maybeLockUploads(fieldId) { const field = this.fields.get(fieldId); if (!field) return; if (field.ui.field.dropZone) { const hasUploads = field.uploads && field.uploads.size > 0; const atMaxFiles = field.uploads && field.uploads.size >= field.maxFiles; // Hide if we have uploads OR if we're at max files field.ui.field.dropZone.hidden = hasUploads || atMaxFiles; } } 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); } 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 ?? '', ]; 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; } } }); return image; } getSubtypeFromMime(mimeType) { if (mimeType.startsWith('image/')) return 'image'; if (mimeType.startsWith('video/')) return 'video'; 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); } } hideUploadProgress(fieldId) { const field = this.fields.get(fieldId); if (!field) return; const progressBar = field.ui.field.progress.progress; if (progressBar) { window.fade(progressBar, false); } } /******************************************************************************* 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' }); } }; request.onsuccess = (e) => { this.db = e.target.result; this.loadFields(); this.checkPendingUploads(); }; request.onerror = (e) => { console.error('IndexedDB error:', e); }; } 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(); request.onsuccess = (e) => { e.target.result.forEach(field => { let uploads = field.uploads; let uploadIds = uploads.map(upload => upload.id); field.uploads = new Set(uploadIds); this.fields.set(field.key, field); uploads.forEach(upload => { this.uploads.set(upload.id, upload); }); }); this.notify('uploads-loaded', { items: Array.from(this.uploads.values()) }); resolve(); }; const blobRequest = blobStore.getAll(); blobRequest.onsuccess = (e) => { e.target.result.forEach(item => { this.uploadBlobs.set(item.id, item); }); this.notify('blobs-loaded', { items: Array.from(this.uploadBlobs.values()) }); resolve(); }; }); } 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 } }; } // Return full upload object return upload; }) .filter(Boolean); } /** * Persist upload to DataStore */ async persistFieldState(fieldId) { if (!this.db) return; const field = this.fields.get(fieldId); if (!field) return; // Create clean field config const { ui, ...cleanConfig } = field; 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 }); }); }); // 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); } async showRecoveryNotification(pendingFields) { const totalUploads = pendingFields.reduce((sum, field) => sum + field.uploads.length, 0); const totalGroups = pendingFields.reduce((sum, field) => sum + (field.groups?.length || 0), 0); let notification = window.getTemplate('restoreNotification'); if (!notification) { console.error('Restore notification template not found'); return; } // Build appropriate message let message = ''; if (totalGroups > 0) { let group = totalGroups > 1 ? 'groups' : 'group'; let upload = totalUploads > 1 ? 'uploads' : 'upload'; message = `${totalGroups} ${group} with ${totalUploads} ${upload} can be restored.`; } else { message = `${totalUploads} upload(s) from ${pendingFields.length} field(s) can be recovered.`; } const detailsEl = notification.querySelector('.restore-details'); if (detailsEl) { detailsEl.textContent = message; } // Build the restoration preview for (const field of pendingFields) { let fieldTemplate = window.getTemplate('restoreField'); if (!fieldTemplate) continue; // Set field name/title const titleEl = fieldTemplate.querySelector('h3'); if (titleEl) { titleEl.textContent = field.config.name || 'Unnamed Field'; } const itemGrid = fieldTemplate.querySelector('.item-grid.restore'); // Process each upload for (const upload of field.uploads) { let uploadItem = window.getTemplate('uploadItem'); if (!uploadItem) continue; // // const imgEl = uploadItem.querySelector('img'); // const placeholderEl = uploadItem.querySelector('.image-placeholder'); // const blobData = await this.getBlobData(upload.id); if (blobData) { try { // Create new blob URL from stored data const blob = new Blob([blobData.data], { type: blobData.type }); const previewUrl = URL.createObjectURL(blob); let [ featured, img, video, preview, details ] = [ uploadItem.querySelector('[name="featured"]'), uploadItem.querySelector('img'), uploadItem.querySelector('video'), uploadItem.querySelector('label > span'), uploadItem.querySelector('details') ]; uploadItem.dataset.uploadId = upload.id; uploadItem.dataset.fieldId = field.config.key; let subtype = this.getSubtypeFromMime(blobData.type); uploadItem.dataset.subtype = subtype; switch (subtype) { case 'image': [ img.src, img.alt ] = [ previewUrl, upload.originalFile?.name ?? upload.meta?.originalName?? '' ]; video.remove(); preview.remove(); break; case 'video': video.src = previewUrl; img.remove(); preview.remove(); break; case 'document': let extension = ''; let icon; switch (extension) { case 'pdf': icon = window.getIcon('file-pdf'); break; case 'csv': icon = window.getIcon('file-csv'); break; case 'doc': icon = window.getIcon('file-doc'); break; case 'txt': icon = window.getIcon('file-txt'); break; case 'xls': icon = window.getIcon('file-xls'); break; default: icon = window.getIcon('file'); break; } preview.innerText = upload.originalFile.name; preview.prepend(icon); img.remove(); video.remove(); break; } // Store URL for cleanup later uploadItem.dataset.previewUrl = previewUrl; } catch (error) { console.warn('Failed to create preview for upload:', upload.id, error); } } // Set upload metadata const nameEl = uploadItem.querySelector('summary span'); if (nameEl) { nameEl.textContent = upload.meta?.originalName || 'Unknown file'; } const metaEl = uploadItem.querySelector('details'); if (metaEl && upload.meta) { metaEl.textContent = `${this.formatBytes(upload.meta.size)} • ${upload.meta.type}`; } // Update input IDs safely uploadItem.querySelectorAll('input').forEach(input => { let id = input.id; if (id) { let newId = id + upload.id; let label = input.parentNode.querySelector(`label[for="${id}"]`); input.id = newId; if (label) { label.htmlFor = newId; } } }); if (itemGrid) { itemGrid.appendChild(uploadItem); } } notification.querySelector('.wrap').appendChild(itemGrid); } document.querySelector('.field.upload').appendChild(notification); notification = document.querySelector('dialog.restore-uploads'); this.restoreModal = new window.jvbModal(notification); this.restoreSelection = new window.jvbHandleSelection({ container: notification, ui: { selectAll: notification.querySelector('#select-all-restore'), count: notification.querySelector('.selection-count'), }, }); this.restoreModal.handleOpen(); } async 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'); 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); if (selectedUploads.length === 0) { this.notify('No uploads selected', 'warning'); return; } selectedUploads.forEach(upload => { this.removeUpload(fieldId, upload); }); } removeUpload(fieldId, uploadId) { const field = this.fields.get(fieldId); const upload = this.uploads.get(uploadId); if (!field || !upload) return; // Remove from field field.uploads?.delete(uploadId); // Remove from group if grouped if (upload.groupId) { const group = this.groups.get(upload.groupId); 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'); } /************************************************************************** META Handled separately, in case it is edited in the middle of processing images **************************************************************************/ /************************************************************************** SUBSCRIBERS **************************************************************************/ /** * 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; } } 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; const uploads = Array.from(field.uploads || []); // 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); }); } } } document.addEventListener('DOMContentLoaded', () => { window.jvbUploads = new UploadManager(); });