/** * Centralized Upload Manager * Handles all file inputs on the page from a single location */ class UploadManager { constructor(store = null) { this.store = store || new window.jvbStore({ name: 'uploads', cacheTTL: 604800, // 7 days useIndexedDB: true }); this.queue = window.jvbQueue; this.a11y = window.jvbA11y; this.error = window.jvbError; this.notifications = window.jvbNotifications; // Central state management this.fields = new Map(); // fieldId -> field configuration this.uploads = new Map(); // uploadId -> upload state this.subscribers = new Set(); this.performanceMonitor = new UploadPerformanceMonitor(); this.compressionWorker = null; // Global settings this.settings = { allowedTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'], maxFileSize: 5242880, // 5MB // Field type configurations fieldTypes: { 'single': { maxFiles: 1, allowMultiple: false }, 'gallery': { maxFiles: 20, allowMultiple: true }, 'groupable': {maxFiles: 20, allowMultiple: true } }, smartCompression: true, }; this.statusMapping = { 'queued': { status: 'queued', message: 'Waiting in queue...' }, 'pending': { status: 'pending', message: 'Waiting for server...' }, 'processing': { status: 'processing', message: 'Processing on server...' }, 'uploading': { status: 'uploading', message: 'Uploading files...' }, 'completed': { status: 'completed', message: 'Upload complete!' }, 'failed': { status: 'failed', message: 'Upload failed (will retry)' }, 'failed_permanent': { status: 'failed_permanent', message: 'Upload failed permanently' } }; //Groups! this.groups = new Map(); // groupId -> Set of uploadIds this.groupMetadata = new Map(); // groupId -> group metadata (name, etc.) this.initializeDragState(); this.init(); } /** * Initialize the upload manager */ async init() { // Check for unfinished uploads from previous session await this.checkUnfinishedUploads(); // Set up event listeners this.initListeners(); // Scan for existing fields this.scanFields(); } /** * Check for unfinished uploads using DataStore */ async checkUnfinishedUploads() { try { console.log('Checking for unfinished uploads...'); // Get all forms that might contain upload data const allForms = this.store.getAllForms(); const unfinishedUploads = new Map(); // Look for upload-related form data for (const [formId, formData] of allForms) { if (formData.status === 'pending' && formData.uploadData) { const fieldId = formData.uploadData.fieldId; if (!unfinishedUploads.has(fieldId)) { unfinishedUploads.set(fieldId, []); } unfinishedUploads.get(fieldId).push(formData); } } // Show restore notifications for fields with unfinished uploads for (const [fieldId, uploads] of unfinishedUploads) { await this.showRestoreNotification(fieldId, uploads); } } catch (error) { console.error('Failed to check unfinished uploads:', error); } } /** * Store upload progress and data using DataStore */ cacheUploadProgress(fieldId, uploadId, data) { const cacheKey = `upload_${fieldId}_${uploadId}`; const uploadData = { formId: cacheKey, fieldId, uploadId, uploadData: { ...data, fieldId, timestamp: Date.now() }, status: 'pending', operationId: data.operationId || null }; // Store using DataStore's form methods this.store.storeForm(cacheKey, uploadData); } /** * Get cached upload data */ getCachedUpload(fieldId, uploadId) { const cacheKey = `upload_${fieldId}_${uploadId}`; return this.store.getForm(cacheKey); } /** * Clear upload cache for a specific field */ async clearFieldCache(fieldId) { try { console.log(`Clearing cache for field: ${fieldId}`); // Get all forms and filter for this field's uploads const allForms = this.store.getAllForms(); const keysToDelete = []; for (const [formId, formData] of allForms) { if (formData.uploadData && formData.uploadData.fieldId === fieldId) { keysToDelete.push(formId); } } // Clear all related form data keysToDelete.forEach(key => { this.store.clearForm(key); }); // Clear any cached field data this.clearFieldMemoryCache(fieldId); console.log(`Cleared cache for field ${fieldId}: ${keysToDelete.length} items removed`); } catch (error) { console.error(`Failed to clear field cache for ${fieldId}:`, error); throw error; } } /** * Clear memory cache data related to a field */ clearFieldMemoryCache(fieldId) { // Clear uploads related to this field const uploadsToRemove = []; for (const [uploadId, upload] of this.uploads) { if (upload.fieldId === fieldId) { // Clean up blob URLs if (upload.preview && upload.preview.startsWith('blob:')) { URL.revokeObjectURL(upload.preview); } uploadsToRemove.push(uploadId); } } // Remove uploads from memory uploadsToRemove.forEach(uploadId => { this.uploads.delete(uploadId); }); console.log(`Cleared ${uploadsToRemove.length} uploads from memory for field: ${fieldId}`); } /** * Show restore notification for unfinished uploads */ async showRestoreNotification(fieldId, cachedUploads) { const field = this.fields.get(fieldId); if (!field) { console.warn(`Cannot show restore for unknown field: ${fieldId}`); return; } // Create restore notification const notification = this.createSelectiveRestoreNotification(fieldId, cachedUploads); // Insert into field container field.container.insertBefore(notification, field.container.firstChild); } /** * Create selective restore notification UI */ createSelectiveRestoreNotification(fieldId, cachedUploads) { const template = window.getTemplate('restoreNotification'); if (!template) { console.error('restoreNotification template not found'); return null; } const notification = template.cloneNode(true); const details = notification.querySelector('.restore-details'); const container = notification.querySelector('.item-grid.restore'); // Update message details.textContent = `Found ${cachedUploads.length} unfinished upload(s) for this field.`; // Create restore items cachedUploads.forEach(upload => { const item = this.createRestoreItem(upload); container.append(item); }); // Attach event listeners this.attachRestoreEventListeners(notification, fieldId, cachedUploads); return notification; } /** * Create individual restore item */ createRestoreItem(uploadData) { const template = window.getTemplate('restoreItem'); if (!template) { console.error('restoreItem template not found'); return null; } const item = template.cloneNode(true); const checkbox = item.querySelector('.restore-checkbox'); const img = item.querySelector('img'); const name = item.querySelector('.item-name'); // Set up item data checkbox.id = `restore-${uploadData.uploadId}`; checkbox.value = uploadData.uploadId; // Use cached preview or create placeholder if (uploadData.uploadData.preview) { img.src = uploadData.uploadData.preview; img.alt = uploadData.uploadData.originalName || 'Upload preview'; } else { img.style.display = 'none'; item.querySelector('.image-placeholder').style.display = 'block'; } if (name) { name.textContent = uploadData.uploadData.originalName || 'Untitled upload'; } return item; } /** * Attach event listeners to restore notification */ attachRestoreEventListeners(notification, fieldId, cachedUploads) { const selectAll = notification.querySelector('.select-all-restore'); const selectNone = notification.querySelector('.select-none-restore'); const restoreSelected = notification.querySelector('.restore-selected'); const clearCache = notification.querySelector('.restart-uploads'); const dismiss = notification.querySelector('.dismiss-cache-check'); // Select all/none functionality selectAll?.addEventListener('click', () => { notification.querySelectorAll('.restore-checkbox').forEach(cb => cb.checked = true); }); selectNone?.addEventListener('click', () => { notification.querySelectorAll('.restore-checkbox').forEach(cb => cb.checked = false); }); // Restore selected items restoreSelected?.addEventListener('click', async () => { const selectedCheckboxes = notification.querySelectorAll('.restore-checkbox:checked'); const selectedIds = Array.from(selectedCheckboxes).map(cb => cb.value); if (selectedIds.length === 0) { this.notify('No items selected for restore', 'warning'); return; } // Restore selected uploads const selectedUploads = cachedUploads.filter(upload => selectedIds.includes(upload.uploadId) ); await this.restoreSelectedUploads(fieldId, selectedUploads); notification.remove(); this.notify(`Restored ${selectedIds.length} item(s)`, 'success'); }); // Clear cache clearCache?.addEventListener('click', async () => { if (confirm('This will permanently delete all cached data. Are you sure?')) { await this.clearFieldCache(fieldId); notification.remove(); this.notify('Cache cleared', 'info'); } }); // Dismiss notification dismiss?.addEventListener('click', () => { notification.remove(); }); } /** * Restore selected uploads */ async restoreSelectedUploads(fieldId, selectedUploads) { const field = this.fields.get(fieldId); if (!field) return; let operations = new Set(); for (const cachedUpload of selectedUploads) { try { const upload = await this.restoreUploadFromCache(cachedUpload); if (upload) { operations.add(upload.operationId); // Add to field if (!field.uploads) field.uploads = new Set(); field.uploads.add(upload.id); // Add to main preview this.addImageToPost(upload.id, field.previewGrid, true); } } catch (error) { console.error(`Failed to restore upload ${cachedUpload.uploadId}:`, error); } } field.operationId = operations; this.fields.set(fieldId, field); // Update field UI this.maybeLockUploads(fieldId); if (field.type === 'groupable') { field.container.querySelector('.group-display').hidden = false; } } /** * Restore individual upload from cached data */ async restoreUploadFromCache(cachedUpload) { try { const upload = { id: cachedUpload.uploadId, fieldId: cachedUpload.fieldId, status: 'cached', progress: { percent: 100, message: 'Restored from cache' }, meta: cachedUpload.uploadData.meta || {}, preview: cachedUpload.uploadData.preview || null, createdAt: cachedUpload.uploadData.timestamp || Date.now(), operationId: cachedUpload.operationId }; // If we have processed file data, restore it if (cachedUpload.uploadData.processedFile) { upload.processedFile = cachedUpload.uploadData.processedFile; } if (cachedUpload.uploadData.originalFile) { upload.originalFile = cachedUpload.uploadData.originalFile; } // Store upload in memory this.uploads.set(upload.id, upload); return upload; } catch (error) { console.error('Failed to restore upload from cache:', error); throw error; } } /** * Save upload progress during processing */ saveUploadProgress(fieldId, uploadId, progressData) { const upload = this.uploads.get(uploadId); if (!upload) return; // Update upload progress upload.progress = progressData.progress || upload.progress; upload.status = progressData.status || upload.status; // Cache the updated data this.cacheUploadProgress(fieldId, uploadId, { ...upload, originalFile: upload.originalFile ? { name: upload.originalFile.name, type: upload.originalFile.type, size: upload.originalFile.size, lastModified: upload.originalFile.lastModified } : null, processedFile: null, // Don't store the actual file data in cache operationId: upload.operationId, meta: upload.meta, originalName: upload.originalFile?.name }); } /** * Mark upload as completed and clean up cache */ completeUpload(fieldId, uploadId) { const cacheKey = `upload_${fieldId}_${uploadId}`; // Remove from cache since upload is complete this.store.clearForm(cacheKey); // Update upload status in memory const upload = this.uploads.get(uploadId); if (upload) { upload.status = 'completed'; } } /** * Event system */ subscribe(callback) { this.subscribers.add(callback); return () => this.subscribers.delete(callback); } notify(message, type = 'info') { this.subscribers.forEach(cb => { if (typeof cb === 'function') { cb('notification', { message, type }); } }); // Also log to console console.log(`[BatchFileUploader] ${message}`); } /************************************************************** EVENTS **************************************************************/ initializeDragState() { this.dragState = { // What's being dragged primaryItem: null, draggedItems: [], isDragging: false, isMultiDrag: false, // Drag context fieldId: null, sourceType: null, // 'drag' or 'touch' startTime: null, // Position tracking startPosition: null, // { x, y } currentPosition: null, // { x, y } // Target tracking currentTarget: null, validTarget: null, // Visual elements dragPreview: null, // Touch-specific touchId: null, touchMoved: false }; } /** * Set up global event delegation */ initEventListeners() { this.paste = window.debouncer.schedule( 'image-paste', () => this.handlePaste.bind(this), 300 ); this.clickHandler = this.handleClick.bind(this); this.changeHandler = this.handleChange.bind(this); 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('click', this.clickHandler); document.addEventListener('change', this.changeHandler); document.addEventListener('paste', this.paste); window.addEventListener('beforeunload', this.handleBeforeUnload.bind(this)); //Groups 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 }); } /**************************************************************** * * Scanning, registering, and validating page uploaders * ***************************************************************/ /** * Scan page for existing upload fields and register them */ scanExistingFields() { const uploaders = document.querySelectorAll('.field.image'); uploaders.forEach(uploader => { try { this.registerUploader(uploader); } catch (error) { this.error.log(error, { component: 'UploadManager', action: 'scanExistingFields', container: uploader.dataset.name }); } }); } registerUploader(uploader, options = {}) { let input = uploader.querySelector('input[type=file]'); if (!input) { return; } if (!('fieldId' in uploader.dataset)) { uploader.dataset.fieldId = this.createFieldId(uploader); } let fieldId = uploader.dataset.fieldId; let typeConfig = this.settings.fieldTypes[uploader.dataset.type] || this.settings.fieldTypes['single']; let config = { id: fieldId, input: input, container: uploader, type: uploader.dataset.type, name: uploader.dataset.name, operationId: new Set(), posts: new Map(), maxFiles: typeConfig.maxFiles, allowMultiple: typeConfig.allowMultiple, groupsContainer: uploader.querySelector('.item-grid.groups')??false, groupDisplay: uploader.querySelector('.group-display')??false, selectAll: uploader.querySelector('#select-all-uploads'), selectActions: uploader.querySelector('.selection-actions'), selectInfo: uploader.querySelector('.selection-controls .info'), selectCount: uploader.querySelector('.selection-count'), content: uploader.dataset.content || 'options', contentFields: uploader.dataset.fields ?? {}, //If this is a content creation uploader, we can add its fields here postId: uploader.dataset.postId??false, termId: uploader.dataset.termId??false, mode: uploader.dataset.mode??'direct', hiddenValue: uploader.querySelector('input[type="hidden"]'), fields: { title: { label: 'Image Title', type: 'text', required: false }, alt: { label: 'Alt Text', type: 'text', required: true }, caption: { label: 'Image Caption', type: 'textarea', required: false } }, dropZone: uploader.querySelector('.file-upload-container'), previewGrid: uploader.querySelector('.item-grid.preview'), uploads: new Set(), status: 'ready', ... options }; this.fields.set(fieldId, config); return fieldId; } /******************************************************************* * * Event Listeners * ******************************************************************/ /** * Handle file input changes */ handleChange(e) { //Only run on uploader changes if (!window.targetCheck(e, '.field.image')) { return; } e.preventDefault(); if (window.targetCheck(e, 'input[type="file"]')) { const fieldId = this.getFieldId(e.target); const field = this.fields.get (fieldId); if (!field) { console.warn('File change on unregistered field: ', fieldId); return; } const files = Array.from(e.target.files); if (files.length === 0) return; this.processFiles(fieldId, files); e.target.value = ''; } else if (e.target.closest('.upload-group') || e.target.name === 'featured') { this.addMetaToPost(e.target); } else if (e.target.closest('.upload-meta')) { this.addMetaToImage(e.target); this.maybeUpdateImageMeta(); } } handleClick(e) { if (!window.targetCheck(e, '.image.field')) return; let [ restart, dismissCacheCheck, selectAll, selectOne, createFromSelection, removeSelection, addToPost, removePost, remove, submitUploads, retry, ] = [ window.targetCheck(e, '.restart-uploads'), window.targetCheck(e, '.dismiss-cache-check'), window.targetCheck(e, '#select-all-uploads'), window.targetCheck(e, '.upload-select'), window.targetCheck(e, '.create-from-selection'), window.targetCheck(e, '.remove-selection'), window.targetCheck(e, '.add-to-group')??window.targetCheck(e,'.add-selection-to-group'), window.targetCheck(e, '.remove-group'), window.targetCheck(e, '.remove'), //handle remove from group and removal window.targetCheck(e, '.submit-uploads'), window.targetCheck(e, '.retry-upload'), ]; if (this.isUploadTrigger(e.target)) { this.triggerFileSelection(this.getFieldId(e.target)); } else if (restart) { this.clearCache(this.getFieldId(restart)); } else if (dismissCacheCheck) { this.dismissCacheCheck(this.getFieldId(dismissCacheCheck)); } else if (selectAll) { this.handleSelectAll(selectAll); } else if (selectOne) { if (e.shiftKey && this.lastClickedUpload) { this.handleRangeSelection(selectOne, e); } else { this.handleUploadSelection(selectOne); // Track last clicked upload for range selection this.lastClickedUpload = this.getUploadId(selectOne); } } else if (createFromSelection) { this.createPostFromSelection(createFromSelection); }else if (removeSelection) { this.removeSelection(removeSelection); } else if (addToPost) { let group = addToPost.closest('.upload-group')?.querySelector('.item-grid')??false; if (!group) { group = this.createPostElement(this.getFieldId(addToPost)); } this.getSelectedUploads(addToPost).forEach(upload => { this.addImageToPost(upload, group); }); } else if (removePost) { this.removePost(removePost); } else if (remove) { this.removeImageFromPost(remove, this.getUploadId(remove)); } else if (submitUploads) { this.submitPostData(submitUploads); } else if (retry) { this.retryUpload(this.getFieldId(retry), this.getUploadId(retry)); } } async retryUpload(fieldId, uploadId) { const upload = this.uploads.get(uploadId); if (!upload || upload.status !== 'error') return; this.a11y.announce(`Retrying upload for ${upload.originalFile.name}`); await this.queueUpload(uploadId); } /** * Handle page unload */ handleBeforeUnload(e) { const activeUploads = Array.from(this.uploads.values()) .filter(upload => ['processing', 'uploading'].includes(upload.status)); if (activeUploads.length > 0) { e.preventDefault(); e.returnValue = `You have ${activeUploads.length} upload(s) in progress. Are you sure you want to leave?`; return e.returnValue; } //TODO: Check for unsaved field changes } /** * Handle paste events (for image paste support) */ handlePaste(e) { const activeField = document.activeElement?.closest('[data-upload-field]'); if (!activeField) return; 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.getFieldId(activeField); 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); } } /*************************************************************** * * Information Handling * ***************************************************************/ /** * * @param {string} uploadId the referred uploadId, as set by the this.uploads logic * @param element the target element the image is 'dropping' to * @param {boolean} isPreviewGrid * @returns {string|boolean} */ addImageToPost(uploadId, element, isPreviewGrid = true) { let field = this.checkField(element); if (!field) return false; let upload = this.uploads.get(uploadId); if (!upload) { return false; } const previousLocation = this.removeImageFromCurrentLocation(uploadId, field); if (isPreviewGrid) { const postId = this.generateID(); const post = { id: postId, images: new Set([uploadId]), // This post only contains this one image fields: {} }; this.addImageElementTo(uploadId, element, false, postId); // Store the individual post field.posts.set(postId, post); this.fields.set(field.id, field); // Announce the move if (previousLocation) { this.a11y.announce(`Image moved from ${previousLocation} back to main area`); } // Cache the field data this.cachePostData(field.id); return postId; } else { let post = this.getPostDataFromElement(element); post.images.add(uploadId); // Handle .empty-group drops by creating the group element here if (element.classList.contains('empty-group')) { element = this.createPostElement(field.id, post.id); } this.addImageElementTo(uploadId, element); element.dataset.postId = post.id; // Announce the move const groupNumber = Array.from(field.posts.keys()).indexOf(post.id) + 1; if (previousLocation === 'preview') { this.a11y.announce(`Image moved from main area to group ${groupNumber}`); } else if (previousLocation) { this.a11y.announce(`Image moved from ${previousLocation} to group ${groupNumber}`); } else { this.a11y.announce(`Image added to group ${groupNumber}`); } //update field data field.posts.set(post.id, post); this.fields.set(field.id, field); this.cachePostData(field.id); return post.id; } } /** * Remove an image from its current location (preview grid or another group) * @param {string} uploadId - The upload ID to remove * @param {Object} field - The field object * @returns {string|null} - Description of where image was removed from */ removeImageFromCurrentLocation(uploadId, field) { // Find which post/group currently contains this image let currentPostId = null; let currentPost = null; for (const [postId, post] of field.posts) { if (post.images.has(uploadId)) { currentPostId = postId; currentPost = post; break; } } console.log('current post id: ', currentPostId); console.log('current post: ', currentPost); if (currentPostId && currentPost) { // Remove from the current post currentPost.images.delete(uploadId); // Find the DOM element let item = document.querySelector(`[data-upload-id="${uploadId}"]`); if (!item) { console.warn(`Element not found for upload ${uploadId} - may have been moved already`); // Still clean up the data structure even if DOM element is missing if (currentPost.images.size === 0) { this.removeEmptyGroup(currentPostId, field); } else { field.posts.set(currentPostId, currentPost); } this.cachePostData(field.id); return 'unknown location'; } let parent = item.closest('.upload-group, .item-grid.preview'); let type = (parent && parent.classList.contains('preview')) ? 'preview' : 'group ' + (Array.from(field.posts.keys()).indexOf(currentPostId) + 1); if (currentPost.images.size === 0) { this.removeEmptyGroup(currentPostId, field); } else { field.posts.set(currentPostId, currentPost); } // Remove the DOM element item.remove(); this.cachePostData(field.id); return type; } return null; // Image wasn't found in any location } /** * Remove an empty group from the field * @param {string} postId - The post ID of the group to remove * @param {Object} field - The field object */ removeEmptyGroup(postId, field) { // Remove from data structure field.posts.delete(postId); // Remove DOM element const groupElement = field.container.querySelector(`[data-post-id="${postId}"]`); if (groupElement) { groupElement.remove(); this.a11y.announce('Empty group removed'); } // Update field this.fields.set(field.id, field); this.cachePostData(field.id); } checkField(element) { let fieldId = this.getFieldId(element); return this.fields.get(fieldId); } getPostDataFromElement(element) { let field = this.checkField(element); if (!field) return; let postId = element.dataset.postId??element.closest('.upload-group').dataset.postId??field.postId??field.termId; let post; //If this isn't a groupable post, we can just add the information to the post id if (postId && !field.posts.has(postId)) { post = { id: postId, images: new Set(), fields: {} }; } else if (postId && field.posts.has(postId)) { post = field.posts.get(postId); } else { post = this.createPost(element); } return post; } removeImageFromPost(element, uploadId) { let field = this.checkField(element); if (!field) return; let postId = element.dataset.postId; if (!postId) { postId = this.getPostIdFromUpload(field, uploadId); if (!postId) { console.warn('Could not find post for upload:', uploadId); return; } } const post = field.posts.get(postId); if (!post) return; // Remove from data structure post.images.delete(uploadId); // Remove DOM element const imageElement = element.closest('.upload-group, .item-grid.preview').querySelector(`[data-upload-id="${uploadId}"]`); if (imageElement) { imageElement.remove(); } // If group is empty, remove it; otherwise update it if (post.images.size === 0) { this.removeEmptyGroup(postId, field); } else { // Update the post data and move image back to preview field.posts.set(postId, post); this.addImageToPost(uploadId, field.previewGrid, true); this.fields.set(field.id, field); this.cachePostData(field.id); } return true; } removePost(element, cache = true) { element = element.closest('.upload-group, .upload-item') ?? element; let field = this.getField(element); let postId = element.dataset.postId; if (field.posts.has(postId)) { let post = field.posts.get(postId); post.images.forEach(uploadId => { let upload = this.uploads.get(uploadId); if (upload) { this.addImageToPost(uploadId, field.previewGrid, true); } }); field.posts.delete(postId); this.fields.set(field.id, field); if (cache) { this.cachePostData(field.id); } } element.remove(); this.a11y.announce('Group deleted and images moved back to main area'); } getPostIdFromUpload(field, uploadId) { for (const [id, post] of field.posts) { if (post.images.has(uploadId)) { return id; } } return null; } addMetaToImage(element) { let field = this.checkField(element); if (!field) return; let item = element.closest('.upload-item'); let uploadId = item?.dataset.uploadId; if (!uploadId) return; const upload = this.uploads.get(uploadId); if (!upload) return; if (!upload.meta) { upload.meta = {}; } upload.meta[element.name] = element.value; this.uploads.set(uploadId, upload); this.cacheUpload(upload); } addMetaToPost(element) { console.log('Adding meta to post: '); let field = this.checkField(element); console.log('Field:', field); if (!field || field.type !== 'groupable') return; let post = this.getPostDataFromElement(element); console.log('Post: ',post); console.log('element: ', element); post.fields[element.name] = element.value; field.posts.set(post.id, post); this.fields.set(field.id, field); this.cachePostData(field.id); return post.id; } createPost(element) { let id = this.generateID(); if (!element.classList.contains('empty-group')) { element.dataset.postId = id; } const post = { id: id, images: new Set(), fields: {} }; // Cache the new post immediately const field = this.checkField(element); if (field) { field.posts.set(id, post); this.fields.set(field.id, field); this.cachePostData(field.id); } return post; } createPostFromSelection(element) { const fieldId = this.getFieldId(element); const selected = this.getSelectedUploads(element); if (selected.length === 0) { this.notify('No uploads selected', 'warning'); return; } // Create new post element const postElement = this.createPostElement(fieldId); selected.forEach(uploadId => { this.addImageToPost(uploadId, postElement, false); // Clear selection const uploadItem = document.querySelector(`[data-upload-id="${uploadId}"]`); const checkbox = uploadItem?.querySelector('[name*="select-item"]'); if (checkbox) checkbox.checked = false; }); this.updateSelectAll(element); this.a11y.announce(`Created new group with ${selected.length} images.`); } async submitPostData(element) { let field = this.checkField(element); if (!field) return; let length = field.posts.size; const operation = { endpoint: 'uploads/groups', method: "POST", title: `Uploading ${length} ${field.plural}`, popup: `Sending ${length} ${field.plural} to server...`, canMerge: true, type: 'image_groups', headers: { 'action_nonce': jvbSettings.dash }, user: jvbSettings.currentUser, field: field.id, onComplete: (operation) => this.handleFinalCompletion(operation) }; // Convert Map to plain object with proper structure let data = {}; let dependencies = new Set(); let index = 0; data.posts = {}; for (const [postId, post] of field.posts) { let images = []; for (const uploadId of post.images) { const upload = this.uploads.get(uploadId); if (upload) { images.push({ upload_id: uploadId, meta: upload.meta, operationId: upload.operationId }); dependencies.add(upload.operationId); } } data.posts[index] = { ...post, images: images }; index++; } data.content = field.content; operation.data = data; operation['depends_on'] = [...dependencies]; try { await this.queue.addToQueue(operation); this.notify(`Sent ${field.plural} to server`); } catch (error) { throw error; } } handleFinalCompletion (operation) { if(operation.field) { let field = this.fields.get(operation.field); if (field.onGroupingComplete && typeof field.onGroupingComplete === "function") { field.onGroupingComplete(operation); } field.container.querySelector('.group-display').hidden = true; field.container.closest('details').open = false; this.cleanField(operation.field); } } cleanField(fieldId) { let field = this.fields.get(fieldId); if(!field) return; if (field.previewGrid) { window.removeChildren(field.previewGrid); } if (field.groupsContainer) { window.removeChildren(field.groupsContainer); } this.clearFieldCache(fieldId); } /*************************************************************** * * UI HANDLING * ***************************************************************/ addImageElementTo(upload, element, prepend = true, postId = null) { upload = (typeof upload === 'string') ? this.uploads.get(upload) : upload; if (!upload) { console.warn('Upload not found:', upload); return; } let image = window.getTemplate('uploadItem'); if (!image) { console.error('uploadItem template not found'); return; } image.dataset.uploadId = upload.id; if (postId) { image.dataset['postId'] = postId; } else { image.querySelector('.item-actions').remove(); let groupActions = window.getTemplate('groupActions'); groupActions.querySelector('input[name="featured"]').value = upload.id; image.querySelector('.actions').append(groupActions); } const img = image.querySelector('img'); if (img) { img.src = upload.preview; img.alt = upload.originalFile?.name ?? upload.meta?.originalName ?? 'Unknown File'; } // Safely add metadata template const details = image.querySelector('details'); if (details) { const metaTemplate = window.getTemplate('uploadMeta'); if (metaTemplate) { details.append(metaTemplate); } } let field = this.getField(element); if (field && field.type === 'groupable') { image.draggable = true; } // 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; } } }); if (prepend) { element.prepend(image); } else { element.append(image); } this.updateImageUI(upload.id, element); } removeImageElementFrom(uploadId, element, moveBackToPreview = null) { element = (typeof element === 'string') ? field.container.querySelector(element) : element; element.querySelector(`[data-upload-id=${uploadId}]`).remove(); if (moveBackToPreview) { this.addImageToPost(uploadId, this.getField(element).previewGrid, true); } } updateImageUI(uploadId, element = null) { const upload = this.uploads.get(uploadId); if (!upload) return; element = (element) ? element : this.getField(document.querySelector(`[data-upload-id="${uploadId}"]`))?.container; if (!element) return; const item = element.querySelector(`[data-upload-id="${uploadId}"]`); if (!item) return; item.dataset.status = upload.status; // Safely update progress elements if (upload.progress) { const fillElement = item.querySelector('.fill'); const textElement = item.querySelector('.details'); if (fillElement) { fillElement.style.width = `${upload.progress.percent || 0}%`; } if (textElement) { textElement.textContent = upload.progress.message ?? ''; } } // Safely update status indicator const statusElement = item.querySelector('.status'); if (statusElement && !statusElement.classList.contains(upload.status)) { window.removeChildren(statusElement); statusElement.className = `status ${upload.status}`; statusElement.append(this.getStatusIcon(upload.status)); } // Safely hide/show progress const progressElement = item.querySelector('.progress'); if (progressElement) { progressElement.hidden = ['completed'].includes(upload.status); } } createPostElement(fieldId, id = null) { let field = this.fields.get(fieldId); if (!field) return; let post = window.getTemplate('imageGroup'); const postId = id || this.generateID(); post.dataset.fieldId = fieldId; post.dataset.postId = postId; let meta = post.querySelector('.fields'); let fields = window.getTemplate('groupMetadata'); meta.append(fields); field.groupsContainer.insertBefore(post, field.groupsContainer.querySelector('.empty-group').nextElementSibling); // Return the grid element, not the post container return field.container.querySelector(`[data-post-id="${postId}"] .item-grid`); } /** * Hide the uploader drop zone if we have reached our limit */ maybeLockUploads(fieldId) { const field = this.fields.get(fieldId); if (!field) return; // Hide/show drop zone based on file count if (field.dropZone) { const isAtCapacity = field.uploads && field.uploads.size >= field.maxFiles; field.dropZone.style.hidden = isAtCapacity; } } /*************************************************************** * * Image Processing * **************************************************************/ /** * Process files for a specific field */ async processFiles(fieldId, files) { const field = this.fields.get(fieldId); if (!field) return; // Validate files const validFiles = files.filter(file => this.validateFile(file, field)); if (validFiles.length === 0) return; // Check field limits if (!this.checkFieldLimits(fieldId, validFiles.length)) { this.notify(`Cannot add ${validFiles.length} files. Field limit exceeded.`, 'warning'); return; } const processedUploads = await this.processBatch(fieldId, validFiles, { batchSize: 3, // Process 3 files simultaneously showProgress: true, }); this.maybeLockUploads(fieldId); if (field.groupDisplay) { field.groupDisplay.hidden = false; } await this.queueUpload(fieldId); this.a11y.announce(`Processed ${processedUploads.length} of ${validFiles.length} files`); } /** * Process a single file */ async processFile(fieldId, file) { const field = this.fields.get(fieldId); try { let upload = this.storeUpload(fieldId, file); let uploadId = upload.id; if (!field.uploads) field.uploads = new Set(); field.uploads.add(uploadId); this.addImageToPost(uploadId, field.previewGrid, true); // Process image with better error context upload.processedFile = await this.processImage(file, { uploadId, retries: 2 }); upload.status = 'processed'; if (upload.preview && upload.preview.startsWith('blob:')) { URL.revokeObjectURL(upload.preview); } // Create new preview URL for processed file upload.preview = URL.createObjectURL(upload.processedFile); this.cacheUpload(upload); this.updateImageUI(uploadId); // Announce completion (only for small batches) if (this.uploads.size <= 3) { this.a11y.announce(`${file.name} processed and ready`); } return upload; } catch (error) { this.updateUploadStatus(uploadId, 'error', 'Processing failed'); // Enhanced error context for batch processing this.error.log(error, { component: 'UploadManager', action: 'processFile', uploadId, fileName: file.name, fileSize: file.size, batchProcessing: true }); // Don't spam error announcements for large batches if (this.uploads.size <= 3) { this.a11y.announce(`${file.name} processing failed`); } return null; } } /** * Stores file in our uploads Map * @param fieldId * @param file * @returns {object} */ storeUpload(fieldId, file) { let uploadId = this.generateUploadId(); const upload = { id: uploadId, fieldId: fieldId, originalFile: file, status: 'processing', progress: { percent: 0, message: 'Processing...' }, preview: URL.createObjectURL(file), createdAt: Date.now(), meta: { originalName: file.name, originalType: file.type, originalSize: file.size } }; this.uploads.set(uploadId, upload); return upload; } async processImage(file, options = {}) { if (!file.type.startsWith('image/')) { return file; } const startTime = performance.now(); this.performanceMonitor?.startTiming(options.uploadId, 'processing'); try { const maxDimension = this.getMaxDimension(); const quality = options.quality || 0.85; let processedFile; if (this.shouldUseWorker(file) && this.compressionWorker) { processedFile = await this.processWithWorker(file, maxDimension, quality); } else { processedFile = await this.processOnMainThread(file, maxDimension, quality); } if (!this.isValidProcessedFile(processedFile, file)) { console.warn(`Processing failed for ${file.name}, using original`); return file; } const processingTime = performance.now() - startTime; this.performanceMonitor?.endTiming(options.uploadId, 'processing'); return processedFile; } catch (error) { this.performanceMonitor?.endTiming(options.uploadId, 'processing'); // Let ErrorHandler deal with the error classification and user notification this.error.log(error, { component: 'UploadManager', action: 'processImage', fileName: file.name, fileSize: file.size, fileType: file.type, uploadId: options.uploadId }); return file; // Fallback to original } } /** * Validate processed file */ isValidProcessedFile(processedFile, originalFile) { if (!processedFile || !(processedFile instanceof File)) { return false; } if (processedFile.size === 0) { return false; } // Check if processing actually reduced file size (for large files) if (originalFile.size > 2 * 1024 * 1024 && processedFile.size >= originalFile.size) { console.warn(`Processing didn't reduce file size: ${originalFile.size} → ${processedFile.size}`); // Still valid, just not optimal } return true; } /** * 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.compressionWorker && file.size > 1024 * 1024 && // > 1MB typeof OffscreenCanvas !== 'undefined'; } async processWithWorker(file, maxDimension, quality) { if (!this.compressionWorker) { throw new Error('Worker not available'); } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('Worker processing timeout')); }, 30000); // 30 second timeout this.compressionWorker.onmessage = (e) => { clearTimeout(timeout); 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')); } }; this.compressionWorker.onerror = (error) => { clearTimeout(timeout); reject(new Error(`Worker error: ${error.message}`)); }; // Send file to worker this.compressionWorker.postMessage({ file: file, maxDimension: maxDimension, quality: quality, outputFormat: this.getOptimalFormat(file) }); }); } /** * Initialize Web Worker for image compression */ initCompressionWorker() { if (this.compressionWorker || typeof Worker === 'undefined') return; try { const workerScript = ` self.onmessage = async function(e) { const { 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); // Convert to blob const blob = await canvas.convertToBlob({ type: outputFormat, quality: quality }); self.postMessage({ success: true, blob: blob, format: outputFormat }); } catch (error) { self.postMessage({ success: false, error: error.message }); } }; `; const blob = new Blob([workerScript], { type: 'application/javascript' }); this.compressionWorker = new Worker(URL.createObjectURL(blob)); } catch (error) { console.warn('Failed to initialize compression worker:', error); this.compressionWorker = null; } } async processBatch(fieldId, files, options = {}) { const { batchSize = 3, showProgress = true, onBatchComplete = null, delayBetweenBatches = 100 } = options; const results = []; const totalFiles = files.length; let processedCount = 0; // Show initial progress if (showProgress) { this.updateUploadProgress(fieldId, 0, totalFiles, 'Starting batch processing...'); } // Process files in batches for (let i = 0; i < files.length; i += batchSize) { const batch = files.slice(i, i + batchSize); // Process current batch (parallel processing within batch) const batchPromises = batch.map(async (file, index) => { try { const upload = await this.processFile(fieldId, file); // Update progress for individual file processedCount++; if (showProgress) { this.updateUploadProgress( fieldId, processedCount, totalFiles, `Processed ${file.name}` ); } return upload; } catch (error) { console.error(`Failed to process file ${file.name}:`, error); processedCount++; // Still count as processed (failed) if (showProgress) { this.updateUploadProgress( fieldId, processedCount, totalFiles, `Failed: ${file.name}` ); } return null; // Return null for failed files } }); // Wait for current batch to complete const batchResults = await Promise.all(batchPromises); // Filter out null results (failed files) const successfulUploads = batchResults.filter(upload => upload !== null); results.push(...successfulUploads); // Call batch completion callback if (onBatchComplete) { onBatchComplete(successfulUploads); } // Small delay between batches to prevent browser overload if (i + batchSize < files.length && delayBetweenBatches > 0) { await new Promise(resolve => setTimeout(resolve, delayBetweenBatches)); } } // Final progress update if (showProgress) { this.updateUploadProgress( fieldId, totalFiles, totalFiles, `Completed! ${results.length}/${totalFiles} files processed successfully` ); // Hide progress after a delay setTimeout(() => { this.hideUploadProgress(fieldId); }, 2000); } return results; } updateUploadProgress(fieldId, current, total, message) { const field = this.fields.get(fieldId); if (!field) return; let progressBar = field.container.querySelector('.progress'); // Create progress bar if it doesn't exist if (!progressBar) { progressBar = window.getTemplate('imageProgress'); // Insert after drop zone or at top of container const insertAfter = field.dropZone || field.container.firstElementChild; if (insertAfter) { insertAfter.insertAdjacentElement('afterend', progressBar); } else { field.container.prepend(progressBar); } } // Update progress bar const progressPercent = total > 0 ? Math.round((current / total) * 100) : 0; const progressFill = progressBar.querySelector('.fill'); const progressMessage = progressBar.querySelector('.details'); const progressCount = progressBar.querySelector('.count'); if (progressFill) { progressFill.style.width = `${progressPercent}%`; } if (progressMessage) { progressMessage.textContent = message; } if (progressCount) { progressCount.textContent = `${current}/${total}`; } // Add completion styling if (current === total) { progressBar.classList.add('completed'); } } hideUploadProgress(fieldId) { const field = this.fields.get(fieldId); if (!field) return; const progressBar = field.container.querySelector('.progress'); if (progressBar) { progressBar.style.opacity = '0'; setTimeout(() => { progressBar.remove(); }, 300); } } /** * 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; } /***************************************** * * TOUCH, DRAG, and DROP * * Shared handlers, then individual listener handlers * ****************************************/ startDragOperation(config) { const { primaryElement, sourceType, startPosition, event } = config; const uploadId = this.getUploadId(primaryElement); const fieldId = this.getFieldId(primaryElement); // Determine what items to drag const draggedItems = this.getDraggedItems(primaryElement); // Initialize drag state this.dragState = { primaryItem: uploadId, draggedItems: draggedItems, isDragging: true, isMultiDrag: draggedItems.length > 1, fieldId: fieldId, sourceType: sourceType, startTime: Date.now(), startPosition: startPosition, currentPosition: startPosition, currentTarget: null, validTarget: null, dragPreview: null, touchId: sourceType === 'touch' ? event.touches[0]?.identifier : null, touchMoved: false }; // Create drag preview this.createDragPreview(primaryElement); // Apply dragging state this.applyDraggingState(true); const announceText = this.dragState.isMultiDrag ? `Started dragging ${draggedItems.length} items` : 'Started dragging item'; this.a11y.announce(announceText); this.provideDragFeedback('start'); return true; } updateDragOperation(position, elementUnderPointer) { if (!this.dragState.isDragging) return; const { sourceType, startPosition } = this.dragState; // Update position this.dragState.currentPosition = position; // Check for significant movement (touch) if (sourceType === 'touch' && !this.dragState.touchMoved) { const deltaX = Math.abs(position.x - startPosition.x); const deltaY = Math.abs(position.y - startPosition.y); if (deltaX > 10 || deltaY > 10) { this.dragState.touchMoved = true; } } // Update preview and target this.updateDragPreview(position); this.updateDropTarget(elementUnderPointer); } endDragOperation(elementUnderPointer = null) { if (!this.dragState.isDragging) return; const wasSuccessful = (this.dragState.sourceType === 'drag' || this.dragState.touchMoved) && this.dragState.validTarget; // Process drop if valid - but only here, not in handleDrop if (wasSuccessful && this.dragState.validTarget) { this.processItemDrop({ itemIds: this.dragState.draggedItems, targetElement: this.dragState.validTarget, fieldId: this.dragState.fieldId, dropType: this.dragState.isMultiDrag ? 'multiple' : 'single', sourceType: this.dragState.sourceType }); } // Cleanup this.cleanupDragOperation(); const announceText = wasSuccessful ? (this.dragState.isMultiDrag ? `Moved ${this.dragState.draggedItems.length} items` : 'Item moved') : 'Drag cancelled'; this.a11y.announce(announceText); } /** * Shared method to process any drop operation (drag or touch) * @param {Object} dropData - Standardized drop data * @returns {boolean} Success status */ processItemDrop(dropData) { const { itemIds, targetElement, fieldId, dropType, sourceType } = dropData; if (!itemIds?.length || !targetElement || !fieldId) { return false; } // Determine if it's a preview drop let isPreviewDrop = targetElement.classList.contains('item-grid') && targetElement.classList.contains('preview'); // Handle empty group drops by creating the group element let actualTarget = targetElement; if (targetElement.classList.contains('empty-group')) { actualTarget = this.createPostElement(fieldId); isPreviewDrop = false; } // Use existing addImageToPost method for each item // This method already handles: // - removeImageFromCurrentLocation (cleanup of old location) // - Adding to new location // - Updating field.posts data structure // - Caching the data itemIds.forEach(uploadId => { this.addImageToPost(uploadId, actualTarget, isPreviewDrop); }); // Clear selections for multi-drops if (dropType === 'multiple') { const field = this.fields.get(fieldId); this.clearAllSelections(field); } // Announce completion const announceText = dropType === 'multiple' ? `Moved ${itemIds.length} images to ${isPreviewDrop ? 'main area' : 'group'}` : `Image moved to ${isPreviewDrop ? 'main area' : 'group'}`; this.a11y.announce(announceText); this.provideFeedback(sourceType, 'success', { count: itemIds.length, isMultiple: dropType === 'multiple' }); return true; } clearAllSelections(field) { // Clear all selection checkboxes in the entire field container const allCheckboxes = field.container.querySelectorAll('[name*="select-item"]'); allCheckboxes.forEach(checkbox => { checkbox.checked = false; }); // Update the select all state if (field.selectAll) { field.selectAll.checked = false; const label = field.selectAll.nextElementSibling; if (label) { label.textContent = 'Select All'; } } // Hide selection controls if (field.selectActions) field.selectActions.hidden = true; if (field.selectInfo) field.selectInfo.hidden = true; } cleanupDragOperation() { if (this.dragState.dragPreview) { this.dragState.dragPreview.remove(); } this.applyDraggingState(false); this.clearDropTargetStates(); // Reset state this.dragState.isDragging = false; this.dragState.dragPreview = null; this.dragState.draggedItems = []; } /** * Validate if drag can start */ validateDragStart(element) { //TODO: Likely remove. We are already only listening to [draggable] items return { canDrag: true }; } /** * Determine what items to drag (single or multiple selection) */ getDraggedItems(element) { const selectedUploads = this.getSelectedUploads(element); const primaryUploadId = element.dataset.uploadId; // If we have multiple selections and primary is selected, drag all if (selectedUploads.length > 1 && selectedUploads.includes(primaryUploadId)) { return selectedUploads; } // Otherwise, just drag the primary item return [primaryUploadId]; } /** * Apply/remove dragging visual state to items */ applyDraggingState(isDragging) { this.dragState.draggedItems.forEach(uploadId => { const element = document.querySelector(`[data-upload-id="${uploadId}"]`); if (element) { element.classList.toggle('dragging', isDragging); } }); } /** * Create drag preview element */ createDragPreview(originalElement) { const { isMultiDrag, draggedItems } = this.dragState; if (isMultiDrag) { this.dragState.dragPreview = this.createMultiDragPreview(originalElement, draggedItems); } else { this.dragState.dragPreview = this.createSingleDragPreview(originalElement); } this.updateDragPreview(this.dragState.startPosition); document.body.appendChild(this.dragState.dragPreview); } /** * Create single item drag preview */ createSingleDragPreview(originalElement) { const preview = originalElement.cloneNode(true); preview.dataset.uploadId = preview.dataset.uploadId+'-dragging'; this.styleDragPreview(preview, false); return preview; } /** * Create multiple items drag preview */ createMultiDragPreview(originalElement, draggedItems) { const container = document.createElement('div'); container.className = 'drag-preview multi-item'; // Create stacked effect with up to 3 items const displayCount = Math.min(draggedItems.length, 3); for (let i = 0; i < displayCount; i++) { const uploadId = draggedItems[i]; const uploadElement = document.querySelector(`[data-upload-id="${uploadId}"]`); if (uploadElement) { const stackedItem = uploadElement.cloneNode(true); stackedItem.dataset.uploadId = uploadId + '_dragging'; stackedItem.style.cssText = ` position: absolute; top: ${i * 4}px; left: ${i * 4}px; width: calc(100% - ${i * 4}px); height: calc(100% - ${i * 4}px); opacity: ${1 - (i * 0.15)}; transform: rotate(${(i - 1) * 2}deg); z-index: ${10 - i}; border-radius: 4px; overflow: hidden; `; container.appendChild(stackedItem); } } // Add count badge if (draggedItems.length > 1) { const badge = this.createCountBadge(draggedItems.length); container.appendChild(badge); } this.styleDragPreview(container, true); return container; } isElementInViewport(element) { const rect = element.getBoundingClientRect(); return ( rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth) ); } /** * Style drag preview element */ styleDragPreview(element, isMulti) { const primaryItem = document.querySelector(`[data-upload-id="${this.dragState.primaryItem}"]`); const rect = primaryItem?.getBoundingClientRect(); element.className = `drag-preview${isMulti ? ' multi-item' : ''}`; // For multi-item previews, use a consistent size rather than original element size const previewSize = isMulti ? { width: 120, height: 120 } : { width: rect?.width || 100, height: rect?.height || 100 }; element.style.cssText = ` position: fixed; top: ${rect?.top || 0}px; left: ${rect?.left || 0}px; width: ${previewSize.width}px; height: ${previewSize.height}px; pointer-events: none; z-index: 9999; opacity: 0.8; transform: scale(1.05); transition: none; box-shadow: 0 8px 25px rgba(0,0,0,0.3); border-radius: 8px; `; } /** * Update drag preview position */ updateDragPreview(position) { if (!this.dragState.dragPreview) return; // Calculate offset based on preview type and source let offset; if (this.dragState.sourceType === 'touch') { offset = this.dragState.isMultiDrag ? { x: -60, y: -80 } : { x: -50, y: -60 }; } else { offset = this.dragState.isMultiDrag ? { x: 15, y: 15 } : { x: 10, y: 10 }; } const deltaX = position.x - this.dragState.startPosition.x; const deltaY = position.y - this.dragState.startPosition.y; this.dragState.dragPreview.style.transform = `translate(${deltaX + offset.x}px, ${deltaY + offset.y}px) scale(1.05)`; } /** * Update drop target highlighting */ updateDropTarget(elementUnderPointer) { // Clear previous target if (this.dragState.currentTarget) { this.clearDropTargetState(this.dragState.currentTarget); } // Find valid drop target const validTarget = this.findValidDropTarget(elementUnderPointer); // Update state this.dragState.currentTarget = elementUnderPointer; this.dragState.validTarget = validTarget; // Apply visual feedback if (validTarget) { this.applyDropTargetState(validTarget); // Haptic feedback for touch if (this.dragState.sourceType === 'touch' && navigator.vibrate) { const pattern = this.dragState.isMultiDrag ? [25, 10, 25] : [25]; navigator.vibrate(pattern); } } } /** * Find valid drop target from element */ findValidDropTarget(element) { if (!element) return null; const postContainer = element.closest('.item-grid.group, .empty-group, .item-grid.preview'); if (postContainer) { const fieldId = this.getFieldId(postContainer); if (fieldId === this.dragState.fieldId) { return postContainer; } } return null; } /** * Apply drop target visual state */ applyDropTargetState(target) { target.classList.add('dragover'); if (this.dragState.isMultiDrag) { target.classList.add('multi-drop'); target.setAttribute('data-item-count', this.dragState.draggedItems.length); } } /** * 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'); }); } /** * Create count badge for multi-item preview */ createCountBadge(count) { const badge = document.createElement('div'); badge.className = 'selection-count-badge'; badge.textContent = count.toString(); badge.style.cssText = ` position: absolute; top: -8px; right: -8px; background: var(--accent-primary); color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: bold; box-shadow: 0 2px 8px rgba(0,0,0,0.3); z-index: 20; `; return badge; } /** * Provide feedback for drag operations */ provideDragFeedback(type) { const hapticPatterns = { start: [50], success: this.dragState.isMultiDrag ? [50, 25, 50, 25, 50] : [50, 25, 50], cancel: [100] }; if (this.dragState.sourceType === 'touch' && navigator.vibrate && hapticPatterns[type]) { navigator.vibrate(hapticPatterns[type]); } } /** * Provide consistent feedback for different input methods */ provideFeedback(sourceType, feedbackType, data = {}) { const hapticPatterns = { success: data.isMultiple ? [50, 25, 50, 25, 50] : [50, 25, 50], error: [100, 50, 100] }; if (sourceType === 'touch' && navigator.vibrate && hapticPatterns[feedbackType]) { navigator.vibrate(hapticPatterns[feedbackType]); } } handleExternalFileDrop(e, files) { const uploadContainer = e.target.closest('.file-upload-container'); if (uploadContainer && files.length > 0) { const fieldId = this.getFieldId(uploadContainer); if (fieldId) { this.processFiles(fieldId, files); this.a11y.announce(`${files.length} file(s) dropped for upload`); } } } clearDragoverStates() { document.querySelectorAll('.dragover').forEach(el => { el.classList.remove('dragover', 'multi-drop'); el.removeAttribute('data-item-count'); }); } /********* * DRAG HANDLERS ********/ handleDragEnter(e) { if (!window.targetCheck(e, '.image.field')) return; // Only handle external files if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); const uploadContainer = e.target.closest('.file-upload-container'); if (uploadContainer) { uploadContainer.classList.add('dragover'); } } } handleDragLeave(e) { if (!window.targetCheck(e, '.image.field')) return; const uploadContainer = e.target.closest('.file-upload-container'); if (uploadContainer && !uploadContainer.contains(e.relatedTarget)) { uploadContainer.classList.remove('dragover'); } } handleDragStart(e) { if (!window.targetCheck(e, '.image.field')) return; const uploadItem = e.target.closest('[data-upload-id]'); if (!uploadItem) return; const result = this.startDragOperation({ primaryElement: uploadItem, sourceType: 'drag', startPosition: { x: e.clientX, y: e.clientY }, event: e }); if (result) { e.dataTransfer.setData('text/plain', this.dragState.primaryItem); e.dataTransfer.effectAllowed = 'move'; } else { e.preventDefault(); } } handleDragOver(e) { if (!this.dragState.isDragging) return; if (!window.targetCheck(e, '.image.field')) return; e.preventDefault(); this.updateDragOperation({ x: e.clientX, y: e.clientY }, e.target); } handleDrop(e) { if (!window.targetCheck(e, '.image.field')) return; e.preventDefault(); this.clearDragoverStates(); // Handle external files (new uploads) const uploadContainer = e.target.closest('.file-upload-container'); if (uploadContainer) { const files = Array.from(e.dataTransfer.files); if (files.length > 0) { const fieldId = this.getFieldId(uploadContainer); if (fieldId) { this.processFiles(fieldId, files); this.a11y.announce(`${files.length} file(s) dropped for upload`); } } return; } // DON'T handle internal drops here - let endDragOperation handle them // This prevents double processing } handleDragEnd(e) { if (!this.dragState.isDragging) return; // Find the element under the final drop position const elementUnderDrop = document.elementFromPoint( this.dragState.currentPosition?.x || e.clientX, this.dragState.currentPosition?.y || e.clientY ); this.endDragOperation(elementUnderDrop); } /********* * TOUCH HANDLERS ********/ handleTouchStart(e) { if (!window.targetCheck(e, '.image.field')) return; if (this.isTouchOnFormElement(e.target)) { return; } const uploadItem = e.target.closest('[data-upload-id]'); if (!uploadItem) return; const touch = e.touches[0]; const result = this.startDragOperation({ primaryElement: uploadItem, sourceType: 'touch', startPosition: { x: touch.clientX, y: touch.clientY }, event: e }); if (result) { e.preventDefault(); // Prevent scrolling } } isTouchOnFormElement(target) { // Check if target is a form element or inside one const formElements = [ 'input', 'button', 'label', 'select', 'textarea', '.upload-select', '.item-select', '[type="checkbox"]', '[type="radio"]', '.form-control' ]; return formElements.some(selector => { return target.matches(selector) || target.closest(selector); }); } handleTouchMove(e) { if (!this.dragState.isDragging) return; e.preventDefault(); const touch = e.touches[0]; const elementUnderTouch = document.elementFromPoint(touch.clientX, touch.clientY); this.updateDragOperation({ x: touch.clientX, y: touch.clientY }, elementUnderTouch); } handleTouchEnd(e) { if (!this.dragState.isDragging) return; e.preventDefault(); const touch = e.changedTouches[0]; const elementUnderTouch = document.elementFromPoint(touch.clientX, touch.clientY); this.endDragOperation(elementUnderTouch); } handleTouchCancel(e) { if (this.dragState.isDragging) { this.cleanupDragOperation(); this.a11y.announce('Drag cancelled'); } } /***************************************************** * * Handle upload selection * ****************************************************/ /** * Handle select all functionality */ handleSelectAll(element, checked = null) { const field = this.getField(element); if (!field) return; // Use element's checked state if not provided if (checked === null) { checked = element.checked; } const target = field.previewGrid; const previewItems = target.querySelectorAll('[data-upload-id]') || []; previewItems.forEach(item => { const checkbox = item.querySelector('[name*="select-item"]'); if (checkbox) { checkbox.checked = checked; } }); this.updateSelectAll(element); this.a11y.announce(checked ? 'All uploads selected' : 'All uploads deselected'); // Clear last clicked since we're selecting/deselecting all this.lastClickedUpload = null; } updateSelectAll(element) { const field = this.getField(element); if (!field) return; const selected = this.getSelectedUploads(element); if (selected.length > 0 ) { field.selectActions.hidden = false; field.selectInfo.hidden = false; field.selectCount.textContent = `${selected.length}`; } else { field.selectActions.hidden = true; field.selectInfo.hidden = true; } let selectAll = selected.length === field.container.querySelectorAll('.item-grid.preview .upload-item').length; field.selectAll.checked = selectAll; field.selectAll.nextElementSibling.textContent = (selectAll) ? 'Clear Selection' : 'Select All'; } getSelectedUploads(element) { // Starting from the provided element, we get the closest container of items let grid = element.closest(':has(.item-grid)')?.querySelector('.item-grid') ?? (element.classList.contains('item-grid') ? element : this.getField(element).previewGrid); // We check for any selected checkboxes, and return an array of uploadIds let uploads = []; grid.querySelectorAll('[name*="select-item"]:checked').forEach(checkbox => { let uploadItem = checkbox.closest('[data-upload-id]'); // FIX 6: Get the element, not ID if (uploadItem) { uploads.push(uploadItem.dataset.uploadId); // FIX 7: Get the ID from dataset } }); return uploads; } /** * Handle individual upload selection */ handleUploadSelection(element) { // Update the select all state this.updateSelectAll(element); // Track this as the last clicked upload this.lastClickedUpload = this.getUploadId(element); } handleRangeSelection(currentElement, event) { const field = this.getField(currentElement); if (!field) return; const currentUploadId = this.getUploadId(currentElement); if (!currentUploadId || !this.lastClickedUpload) return; // Get all upload items in the preview grid const previewGrid = field.previewGrid; const allItems = Array.from(previewGrid.querySelectorAll('[data-upload-id]')); // Find indices of first and current items const firstIndex = allItems.findIndex(item => item.dataset.uploadId === this.lastClickedUpload ); const currentIndex = allItems.findIndex(item => item.dataset.uploadId === currentUploadId ); if (firstIndex === -1 || currentIndex === -1) return; // Determine range (handle both directions) const startIndex = Math.min(firstIndex, currentIndex); const endIndex = Math.max(firstIndex, currentIndex); // Select all items in range (including the clicked one!) for (let i = startIndex; i <= endIndex; i++) { const item = allItems[i]; const checkbox = item.querySelector('[name*="select-item"]'); if (checkbox) { checkbox.checked = true; } } currentElement.checked = true; // Update selection UI this.updateSelectAll(currentElement); // Announce the range selection const selectedCount = endIndex - startIndex + 1; this.a11y.announce(`Selected ${selectedCount} items in range`); // Update the last clicked item to the current one this.lastClickedUpload = currentUploadId; } removeSelection(button) { let fieldId = this.getFieldId(button); const selectedUploads = this.getSelectedUploads(button); if (selectedUploads.length === 0) { this.notify('No uploads selected', 'warning'); return; } selectedUploads.forEach(upload => { this.removeUpload(fieldId, upload); }); } /***************************************** * * META * ****************************************/ /** * Schedule debounced metadata update */ /** * Send metadata update to server */ getUploadMeta() { let meta = {}; this.uploads.forEach(upload => { let item = { id: upload.id, //either the generated ID, or the attachment id meta: upload.meta }; if (upload.operationId) { item.operationId = upload.operationId; } meta[upload.id] = item; }); return meta; } async maybeUpdateImageMeta() { let metaData = this.getUploadMeta(); let changes = window.getDifferences.map(this.oldUploads, metaData); if (window.isEmptyObject(changes)) return; try { const operation = { endpoint: 'uploads/meta', method: 'POST', title: 'Saving image meta', popup: `Sending ${changes.length} changes to server...`, canMerge: true, type: 'image_meta', headers: { 'action_nonce': jvbSettings.dash}, user: jvbSettings.currentUser, field: field.id, data: changes, } let dependencies = new Set(); for (const [uploadId, settings] of metaData) { if (settings.operationId) { dependencies.add(settings.operationId); } } if (dependencies.size > 0) { operation['depends_on'] = [ ... dependencies]; } let operationId = this.queue.addToQueue(operation); } catch (error) { throw error; } finally { this.oldUploads = this.uploads; } } /** * Process pending metadata when upload completes */ processPendingMetadata(uploadId) { if (!this.metadataPending.has(uploadId)) return; const pendingMetadata = this.metadataPending.get(uploadId); if (Object.keys(pendingMetadata).length === 0) return; // Send all pending metadata at once this.sendMetadataUpdate(uploadId, pendingMetadata); this.metadataPending.delete(uploadId); } /******************************************************************** * * Queueing and Updates * *******************************************************************/ async queueUpload(fieldId) { // Cache data before queuing this.cachePostData(fieldId); const field = this.fields.get(fieldId); if (!field?.uploads) return; const operationData = this.prepareUploadData(fieldId); if (!operationData || !operationData.data) { console.warn('No operation data prepared for field:', fieldId); return; } // At this point, we're in the initial upload phase - just use field.uploads let uploadIds = []; if (field.uploads && field.uploads.size > 0) { uploadIds = Array.from(field.uploads); } else { console.warn('No uploads found in field.uploads for field:', fieldId); } // Validate we have uploads to process if (uploadIds.length === 0) { console.warn('No uploads found to queue for field:', fieldId); return; } uploadIds.forEach(upload => { this.updateUploadStatus(upload, 'uploading', 'Uploading image'); }); this.a11y.announce(`Queuing for upload`); const operation = { endpoint: "uploads", method: "POST", data: operationData.data, title: this.getOperationTitle(field, uploadIds), popup: `Uploading ${uploadIds.length} file(s)...`, canMerge: false, type: field.groupable ? 'batch_creation' : 'image_upload', headers: { 'action_nonce': jvbSettings.dash }, user: jvbSettings.currentUser, append: '_upload', onUpdate: (operation) => this.handleUpdate(operation), onComplete: (operation) => this.handleCompletion(operation) }; try { const operationId = await this.queue.addToQueue(operation); this.activeOperations.set(operationId, { uploadIds: uploadIds, fieldId: field.id, status: 'queued', type: field.groupable ? 'batch_creation' : 'image_upload', startTime: Date.now(), retryCount: 0, }); // Store operation ID in uploads for (const uploadId of uploadIds) { const upload = this.uploads.get(uploadId); upload.operationId = operationId; upload.attachmentId = null; this.cacheUpload(upload); } if (!field.operationId) { field.operationId = new Set(); } field.operationId.add(operationId); this.fields.set(field.id, field); this.cachePostData(fieldId); this.notify(`Queued ${uploadIds.length} file(s) for upload`, 'info'); return operationId; } catch (error) { throw error; } } prepareUploadData(fieldId) { const field = this.fields.get(fieldId); const formData = new FormData(); // Standard field metadata formData.append('content', field.content); formData.append('mode', field.mode); formData.append('field_name', field.container.dataset.field); formData.append('field_id', field.id); formData.append('type', field.type); if (field.container.dataset.postId) { formData.append('post_id', field.container.dataset.postId); } else if (field.container.dataset.termId) { formData.append('term_id', field.container.dataset.termId); } let index = 0; let uploadMap = []; if (field.uploads && field.uploads.size > 0) { field.uploads.forEach(uploadId => { const upload = this.uploads.get(uploadId); if (upload) { const file = upload.processedFile || upload.originalFile; formData.append(`files[${index}]`, file); uploadMap.push(uploadId); index++; } }); } else { console.warn('No uploads found in field.uploads for field:', fieldId); } formData.append('upload_map', uploadMap); console.log('Gathered data:'); for (var [key, value] of formData.entries()) { console.log(key, value); } return {data: formData}; } /** * Handle queue updates */ handleUpdate(queueItem) { console.log('Updating item in Uploader...', queueItem); const operation = this.activeOperations.get(queueItem.id); if (queueItem.status !== operation.status) { const mapping = this.statusMapping[queueItem.status] || { status: queueItem.status, message: queueItem.status }; // Update progress calculation const progress = this.calculateProgress(queueItem); operation.status = queueItem.status; operation.uploadIds.forEach(id => { const upload = this.uploads.get(id); upload.status = mapping.status; upload.progress = { percent: progress, message: mapping.message, serverStatus: queueItem.status }; this.uploads.set(id, upload); this.cacheUpload(upload); this.updateImageUI(id); }); if (operation.type === 'image_upload') { //TODO: Gather any metadata changes //TODO: Add changes to queue //TODO: Remove preview items //TODO: Send onUpdate to caller } } } /** * Handle operation completion */ handleCompletion(queueItem) { console.log('Completion item: ', queueItem); const operation = this.activeOperations.get(queueItem.id); const mapping = this.statusMapping['completed'] || { status: 'completed', message: 'Everything\'s ready!' }; console.log('Grabbed operation: ', operation); // Update progress calculation const progress = this.calculateProgress(queueItem); operation.status = 'completed'; operation.uploadIds.forEach(id => { const upload = this.uploads.get(id); upload.status = mapping.status; upload.progress = { percent: progress, message: mapping.message, serverStatus: 'completed' }; this.uploads.set(id, upload); this.cacheUpload(upload); this.updateImageUI(id); }); if (operation.type === 'image_upload') { //TODO: Gather any metadata changes //TODO: Add changes to queue //TODO: Remove preview items //TODO: Send onUpdate to caller } // const operation = this.activeOperations.get(item.id); // if (!operation || operation.type !== 'batch') return; // // if (item.status === 'completed' && item.result?.success) { // const responseData = item.result.data || []; // const isArray = Array.isArray(responseData); // // operation.uploadIds.forEach((uploadId, index) => { // const upload = this.uploads.get(uploadId); // if (upload) { // const fileData = isArray ? responseData[index] : responseData; // // if (fileData) { // upload.uploadedData = { // attachment_id: fileData.attachment_id, // url: fileData.url, // file_path: fileData.file_path, // metadata: fileData.metadata || {} // }; // // // Store attachment ID and clear operation ID // upload.attachmentId = fileData.attachment_id; // upload.operationId = null; // Clear since upload is complete // // this.updateUploadStatus(upload.id, 'uploaded', 'Upload complete!'); // this.updateFieldValue(operation.fieldId, upload); // // // Process any pending metadata updates // this.processPendingMetadata(uploadId); // } else { // this.updateUploadStatus(upload.id, 'error', 'No data received from server'); // } // } // }); // // const successCount = operation.uploadIds.filter(id => // this.uploads.get(id)?.status === 'uploaded' // ).length; // // this.a11y.announce(`Successfully uploaded ${successCount} of ${operation.uploadIds.length} files`); // this.notify(`Successfully uploaded ${successCount} file(s)`, 'success'); // // const field = this.fields.get(operation.fieldId); // if (field?.onUploadComplete) { // field.onUploadComplete({ // ...item.result, // uploadedFiles: responseData, // fieldId: operation.fieldId // }); // } // } // this.activeOperations.delete(queueItem.id); // this.updateFieldUI(operation.fieldId); } /** * Calculate progress from queue item */ calculateProgress(queueItem) { const progressMap = { 'queued': 5, 'pending': 15, 'processing': 50, 'uploading': 75, 'completed': 100, 'failed': 0, 'failed_permanent': 0 }; let baseProgress = progressMap[queueItem.status] || 0; // Add incremental progress if available if (queueItem.progress) { const increment = queueItem.progress.percentage || 0; baseProgress = Math.min(100, baseProgress + (increment * 0.8)); } return Math.round(baseProgress); } /** * Generate operation title for UI */ getOperationTitle(field, uploads) { const fileCount = uploads.length; const fieldTypeNames = { 'single': 'Image', 'gallery': 'Gallery', }; const typeName = fieldTypeNames[field.type] || 'File'; if (fileCount === 1) { return `Uploading ${typeName}`; } else { return `Uploading ${fileCount} ${typeName}s`; } } /****************************************************************** * * UI * *****************************************************************/ /** * Update upload status */ updateUploadStatus(uploadId, status, message = '') { console.log('Updating upload status'); const upload = this.uploads.get(uploadId); if (!upload) return; console.log('Updating ', upload); console.log('Status: ', status); console.log('Message: ', message); upload.status = status; upload.progress = upload.progress || {}; upload.progress.message = message; if (status === 'uploaded') { upload.progress.percent = 100; } else if (status === 'error') { upload.error = message; } this.updateImageUI(uploadId); } /** * Get status icon */ getStatusIcon(status) { return window.getIcon(this.queue.icons[status]); } /******************************************************************* * * Cache Handling * ******************************************************************/ /** * Resume pending uploads from previous session */ async resumePendingUploads() { return; //TODO: Old system try { console.log('Checking for pending uploads...'); const pendingUploads = await this.cache.getImagesPendingByOperation(); if (pendingUploads.length > 0) { console.log(`Found ${pendingUploads.length} pending uploads`); // Group by field const fieldGroups = new Map(); pendingUploads.forEach(upload => { const fieldId = upload.metadata?.uploadConfig?.fieldId; if (fieldId) { if (!fieldGroups.has(fieldId)) { fieldGroups.set(fieldId, []); } fieldGroups.get(fieldId).push(upload); } }); // Show selective restore for each field for (const [fieldId, uploads] of fieldGroups) { await this.showRestoreNotification(fieldId, uploads); } } } catch (error) { console.error('Failed to resume pending uploads:', error); } } async showRestoreNotification(fieldId, cachedUploads) { const field = this.fields.get(fieldId); if (!field) { console.warn(`Cannot show restore for unknown field: ${fieldId}`); return; } // Pre-fetch preview images for all cached uploads console.log(`Pre-fetching ${cachedUploads.length} preview images...`); const uploadsWithPreviews = await this.fetchPreviewsForCachedUploads(cachedUploads); // Create restore notification with actual preview images const notification = this.createSelectiveRestoreNotification(fieldId, uploadsWithPreviews); // Insert into field container field.container.insertBefore(notification, field.container.firstChild); // Auto-hide after 60 seconds if no interaction // setTimeout(() => { // if (notification.parentNode) { // this.dismissCacheCheck(fieldId); // } // }, 60000); } async fetchPreviewsForCachedUploads(cachedUploads) { const uploadsWithPreviews = []; for (const upload of cachedUploads) { const uploadWithPreview = { ...upload }; try { // Get cached image data const cachedFile = await this.cache.getImagePending(upload.id); if (cachedFile && cachedFile.imageData) { // Create blob from cached data const blob = new Blob([cachedFile.imageData], { type: cachedFile.metadata?.type || 'image/jpeg' }); // Create preview URL uploadWithPreview.previewUrl = URL.createObjectURL(blob); console.log(`Created preview for ${upload.metadata?.originalName || upload.id}`); } else { console.warn(`No cached image data found for upload ${upload.id}`); uploadWithPreview.previewUrl = null; } } catch (error) { console.error(`Failed to fetch preview for upload ${upload.id}:`, error); uploadWithPreview.previewUrl = null; } uploadsWithPreviews.push(uploadWithPreview); } return uploadsWithPreviews; } createSelectiveRestoreNotification(fieldId, cachedUploads) { let notification = window.getTemplate('restoreNotification'); notification.dataset.fieldId = fieldId; notification.querySelector('.restore-details').textContent = `We found ${cachedUploads.length} image(s) from your previous session. You can restore them here if you'd like, or you can start over.`; let field = this.fields.get(fieldId); let container = notification.querySelector('.item-grid'); // ${cachedUploads.map((upload, index) => this.createRestoreItemHTML(upload, index)).join('')} cachedUploads.forEach((upload, index) => { let item = window.getTemplate('restoreItem'); const fileName = upload.metadata?.originalName || `Upload ${index + 1}`; const fileSize = upload.metadata?.originalSize || 0; const hasPreview = upload.previewUrl; let preview = item.querySelector('.preview'); if (hasPreview) { preview.querySelector('div').remove(); let img = preview.querySelector('img'); [ img.src, img.alt ] = [ upload.previewUrl, fileName ]; } else { preview.querySelector('img').remove(); } [ item.dataset.id, item.querySelector('.name').textContent, item.querySelector('input').id, item.querySelector('input').name, item.htmlFor ] = [ upload.id, fileName, `restore-${upload.id}`, `restore-${upload.id}`, `restore-${upload.id}`, ]; container.append(item); }); // Attach event listeners this.attachRestoreEventListeners(notification, fieldId, cachedUploads); return notification; } attachRestoreEventListeners(notification, fieldId, cachedUploads) { const selectAll = notification.querySelector('.select-all-restore'); const selectNone = notification.querySelector('.select-none-restore'); const restoreSelected = notification.querySelector('.restore-selected'); const deleteCache = notification.querySelector('.delete-cache'); const dismiss = notification.querySelector('.dismiss-cache-check'); // Cleanup function for preview URLs const cleanupPreviewUrls = () => { cachedUploads.forEach(upload => { if (upload.previewUrl && upload.previewUrl.startsWith('blob:')) { URL.revokeObjectURL(upload.previewUrl); } }); }; // Select all/none functionality selectAll?.addEventListener('click', () => { notification.querySelectorAll('.restore-checkbox').forEach(cb => cb.checked = true); }); selectNone?.addEventListener('click', () => { notification.querySelectorAll('.restore-checkbox').forEach(cb => cb.checked = false); }); // Restore selected items restoreSelected?.addEventListener('click', async () => { const selectedCheckboxes = notification.querySelectorAll('.restore-checkbox:checked'); const selectedIds = Array.from(selectedCheckboxes).map(cb => cb.id.replace('restore-', '') ); if (selectedIds.length === 0) { this.notify('No items selected for restore', 'warning'); return; } // Restore selected uploads const selectedUploads = cachedUploads.filter(upload => selectedIds.includes(upload.id) ); await this.restoreSelectedUploads(fieldId, selectedUploads); // Cleanup preview URLs cleanupPreviewUrls(); // Remove notification notification.remove(); this.notify(`Restored ${selectedIds.length} item(s)`, 'success'); }); // Delete all cache deleteCache?.addEventListener('click', async () => { if (confirm('This will permanently delete all cached data. Are you sure?')) { await this.clearFieldCache(fieldId); // Cleanup preview URLs cleanupPreviewUrls(); notification.remove(); this.notify('Cache cleared', 'info'); } }); // Dismiss notification dismiss?.addEventListener('click', () => { // Cleanup preview URLs cleanupPreviewUrls(); notification.remove(); }); } async restoreSelectedUploads(fieldId, selectedUploads) { const field = this.fields.get(fieldId); if (!field) return; let operations = new Set(); for (const cachedUpload of selectedUploads) { try { console.log('upload', cachedUpload); const upload = await this.getCachedUpload(fieldId, cachedUpload); operations.add(upload.operationId); // Add to field if (!field.uploads) field.uploads = new Set(); field.uploads.add(upload.id); // Add to main preview (not auto-grouped) this.addImageToPost(upload.id, field.previewGrid, true); } catch (error) { console.error(`Failed to restore upload ${cachedUpload.id}:`, error); } } field.operationId = operations; this.fields.set(fieldId, field); // Update field UI this.maybeLockUploads(fieldId); if (field.type === 'groupable') { field.container.querySelector('.group-display').hidden = false; } } async clearFieldCache(fieldId) { const field = this.fields.get(fieldId); if (!field) return; try { // 1. Clear IndexedDB stores for this field await this.clearFieldIndexedDB(fieldId); // 2. Clear memory cache for field-related data this.clearFieldMemoryCache(fieldId); // 3. Clear HTTP headers related to field uploads this.clearFieldHttpHeaders(fieldId); // 4. Clear any pending metadata updates this.clearFieldMetadata(fieldId); // 5. Clear performance monitoring data this.clearFieldPerformanceData(fieldId); console.log(`Comprehensive cache clearing completed for field: ${fieldId}`); } catch (error) { console.error(`Failed to clear field cache for ${fieldId}:`, error); throw error; } } /** * Clear IndexedDB data specific to a field */ async clearFieldIndexedDB(fieldId) { if (!this.cache.imageDB) return; try { // Clear pending images for this field const pendingImages = await this.cache.getImagesPendingByField(fieldId); for (const image of pendingImages) { await this.cache.removeImagePending(image.id); } // Clear group data for this field await this.cache.clearGroupsForField(fieldId); // Clear any cached field groups const cacheKey = `field_groups_${fieldId}`; await this.cache.removeCacheItem(cacheKey); console.log(`Cleared IndexedDB data for field: ${fieldId}`); } catch (error) { console.error(`Failed to clear IndexedDB for field ${fieldId}:`, error); } } /** * Clear memory cache data related to a field */ clearFieldMemoryCache(fieldId) { if (!this.cache._memoryCache) return; const keysToRemove = []; // Find all memory cache keys related to this field for (const [key, value] of this.cache._memoryCache) { // Check for field-specific cache keys if (key.includes(fieldId) || key.startsWith(`pending_image_`) || key.startsWith(`post_${fieldId}_`) || key.startsWith(`upload_${fieldId}_`) || (value && value.fieldId === fieldId)) { keysToRemove.push(key); } } // Remove identified keys keysToRemove.forEach(key => { this.cache._memoryCache.delete(key); }); console.log(`Cleared ${keysToRemove.length} memory cache entries for field: ${fieldId}`); } /** * Clear HTTP headers related to field operations */ clearFieldHttpHeaders(fieldId) { if (!this.cache.httpHeaders) return; const headersToRemove = []; // Find HTTP headers related to upload operations for this field for (const [key, headerData] of this.cache.httpHeaders) { if (key.includes('uploads') || key.includes(fieldId) || headerData.url?.includes('uploads')) { headersToRemove.push(key); } } // Remove identified headers headersToRemove.forEach(key => { this.cache.httpHeaders.delete(key); }); // Force save the updated headers if (headersToRemove.length > 0) { this.cache.saveHttpHeaders(); console.log(`Cleared ${headersToRemove.length} HTTP headers for field: ${fieldId}`); } } /** * Clear pending metadata updates for a field */ clearFieldMetadata(fieldId) { if (!this.metadataUpdateTimers || !this.metadataPending || !this.metadataQueue) return; const field = this.fields.get(fieldId); if (!field || !field.uploads) return; // Clear metadata timers for uploads in this field for (const uploadId of field.uploads) { // Clear update timers for (const [timerKey, timer] of this.metadataUpdateTimers) { if (timerKey.startsWith(uploadId)) { clearTimeout(timer); this.metadataUpdateTimers.delete(timerKey); } } // Clear pending metadata this.metadataPending.delete(uploadId); this.metadataQueue.delete(uploadId); } console.log(`Cleared metadata updates for field: ${fieldId}`); } /** * Clear performance monitoring data for a field */ clearFieldPerformanceData(fieldId) { if (!this.performanceMonitor || !this.performanceMonitor.metrics) return; const field = this.fields.get(fieldId); if (!field || !field.uploads) return; // Clear performance metrics for uploads in this field for (const uploadId of field.uploads) { this.performanceMonitor.metrics.delete(uploadId); } console.log(`Cleared performance data for field: ${fieldId}`); } /** * Reset field to its initial state */ resetFieldToInitialState(fieldId) { const field = this.fields.get(fieldId); if (!field) return; // Reset field properties to initial values field.uploads = new Set(); field.posts = new Map(); // Replace groups with posts field.status = 'ready'; // Clear any operation IDs for (const [operationId, operation] of this.activeOperations) { if (operation.fieldId === fieldId) { this.activeOperations.delete(operationId); } } // Reset file input if (field.input) { field.input.value = ''; } // Reset hidden value input if (field.hiddenValue) { field.hiddenValue.value = ''; } // Clear any progress indicators const progressBars = field.container.querySelectorAll('.progress'); progressBars.forEach(bar => bar.remove()); // Reset drag state for this field this.initializeDragState(); console.log(`Reset field ${fieldId} to initial state`); } /** * Handle start over action */ async clearCache(fieldId) { // Show confirmation dialog const confirmed = await this.showStartOverConfirmation(); if (!confirmed) return; try { this.a11y.announce('Starting over - clearing all cached data'); // Clear all uploads for this field (existing functionality) this.cleanField(fieldId); // Remove notification this.dismissCacheCheck(fieldId); // Hide group display const field = this.fields.get(fieldId); const groupDisplay = field?.container.querySelector('.group-display'); if (groupDisplay) { groupDisplay.hidden = true; } // Reset field to initial state this.resetFieldToInitialState(fieldId); this.notify('Started fresh - all cached data cleared', 'success'); } catch (error) { this.error.log(error, { component: 'UploadManager', action: 'clearCache', fieldId: fieldId }); this.notify('Failed to clear cache - please try again', 'error'); } } /** * Show confirmation dialog for start over */ async showStartOverConfirmation() { return new Promise((resolve) => { let dialog; if (window.getTemplate) { dialog = window.getTemplate('startOverConfirmation'); } else { // Fallback if template system not available dialog = document.createElement('dialog'); dialog.className = 'start-over-confirmation'; dialog.innerHTML = `
This will permanently delete:
This cannot be undone!