From 42fa8304ddb811b0f725f245130f70c0f5e86a6c Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 04 Nov 2025 06:12:02 +0000
Subject: [PATCH] =Refactored LoginManager to be more extensible and configurable, as well as an AjaxRateLimiter
---
assets/js/concise/UploadManager.js | 4468 +++++++++++++++++++++++++++++------------------------------
1 files changed, 2,202 insertions(+), 2,266 deletions(-)
diff --git a/assets/js/concise/UploadManager.js b/assets/js/concise/UploadManager.js
index 8328177..0fd8f6e 100644
--- a/assets/js/concise/UploadManager.js
+++ b/assets/js/concise/UploadManager.js
@@ -4,60 +4,89 @@
this.queue = window.jvbQueue;
this.a11y = window.jvbA11y;
this.error = window.jvbError;
- this.notifications = window.jvbNotifications;
//Load Datastore
- this.initDB();
+ this.fieldStore = new window.jvbStore({
+ name: 'upload_fields',
+ storeName: 'fieldStates',
+ keyPath: 'id',
+ version: 2,
- //State management
+ indexes: [
+ { name: 'fieldId', keyPath: 'fieldId' },
+ { name: 'timestamp', keyPath: 'timestamp' },
+ { name: 'content', keyPath: 'content' },
+ { name: 'itemId', keyPath: 'itemId' },
+ { name: 'status', keyPath: 'status' }
+ ],
+
+ stripDOMReferences: true,
+ TTL: 86400000*7 // 24 hours -> 1 week
+ });
+
+ this.uploadStore = new window.jvbStore({
+ name: 'uploads',
+ storeName: 'uploads',
+ keyPath: 'id',
+ storeBlobs: true,
+
+ indexes: [
+ { name: 'fieldId', keyPath: 'fieldId' },
+ { name: 'status', keyPath: 'status' },
+ { name: 'groupId', keyPath: 'groupId' },
+ { name: 'attachmentId', keyPath: 'attachmentId' }
+ ],
+ });
+
+ window.jvbUploadBlobs = this.uploadStore;
+
+ // Subscribe to store events
+ this.fieldStore.subscribe(this.handleFieldStoreEvent.bind(this));
+ this.uploadStore.subscribe(this.handleUploadStoreEvent.bind(this));
+
+ //Load Worker
+ this.initWorker();
+
+ // Core data structures
this.fields = new Map();
this.uploads = new Map();
- this.uploadBlobs = new Map();
- this.timeouts = new Map();
- this.selected = new Map();
-
- //Worker
- this.worker = {
- worker: null,
- timeout: null,
- tasks: new Map(),
- restart: {
- count: 0,
- max: 3,
- },
- settings: {
- timeout: 10000, //10 seconds per image
- batchSize: 1,
- maxConcurrent: 3,
- restartAfterTimeout: true
- }
- };
-
- //Groups!
- this.touch = {
- x: null,
- y: null
- }
- this.hasBulkContext = document.querySelector('details.uploader')!==null;
- this.isTouching = false;
this.groups = new Map();
-
+ this.selected = new Map();
+ this.selectionHandlers = new Map();
+ this.previewUrls = new Set();
//Notification and Subscribers
this.subscribers = new Set();
- this.settings = {
- allowedTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'],
- maxFileSize: 5242880,
- maxProcessingTime: 120000, // 2 minutes max for processing
- processingCheckInterval: 5000, // Check every 5 seconds
- smartCompression: true,
- fieldTypes: {
- 'single': { maxFiles: 1, allowMultiple: false },
- 'gallery': { maxFiles: 20, allowMultiple: true },
- 'groupable': { maxFiles: 20, allowMultiple: true }
+ // Controllers (will be initialized based on features)
+ this.dragController = null;
+
+ // Selectors
+ this.selectors = {
+ field: {
+ field: '[data-upload-field]',
+ input: 'input[type="file"]',
+ hiddenValue: 'input[type="hidden"]',
+ dropZone: '.file-upload-container',
+ preview: '.item-grid.preview',
+ progress: '.image-progress'
+ },
+ groups: {
+ container: '.upload-group',
+ grid: '.item-grid.group',
+ header: '.group-header',
+ selectAll: '[name="select-all-group"]',
+ actions: '.group-actions',
+ count: '.selection-controls .info'
+ },
+ items: {
+ item: '[data-upload-id]',
+ checkbox: '[name*="select-item"]',
+ featured: '[name="featured"]',
+ details: 'details'
}
};
+
this.statusMapping = {
'received': 'Image Received',
'local_processing': 'Processing Image...',
@@ -74,192 +103,421 @@
}
async init() {
- this.initElements();
+ // Load existing data
+ await this.loadFields();
+ await this.loadUploads();
+ // Initialize fields
+ this.initializeFields();
+
+ // Set up core listeners
this.initListeners();
- this.initCompressionWorker();
+
this.queue.subscribe((event, operation) => {
- console.log('Operation Endpoint: ', operation.endpoint);
- if (operation.endpoint !== 'uploads') {
+ if (operation.endpoint !== 'uploads' && operation.endpoint !== 'uploads/meta') {
return;
}
+ const fieldId = operation.data instanceof FormData
+ ? operation.data.get('fieldId')
+ : operation.data.fieldId;
switch(event) {
case 'cancel-operation':
- this.clearField(operation.data.get('field_key'));
+ if (fieldId) {
+ this.clearField(fieldId);
+ }
break;
case 'operation-status':
- console.log('Operation Data: ',operation.data);
- const fieldId = operation.data?.field_key ||
- (operation.data instanceof FormData ?
- operation.data.get('field_key') : null);
-
if (fieldId) {
- console.log('Updating field status:', fieldId, operation.status);
this.updateFieldStatus(fieldId, operation.status);
}
break;
+ case 'operation-complete':
+ const results = operation.result?.data || [];
+ results.forEach(result => {
+ const upload = this.uploads.get(result.upload_id);
+ if (upload) {
+ upload.attachmentId = result.attachment_id;
+ upload.status = 'completed';
+ this.uploads.set(upload.id, upload);
+ }
+ });
+ if (fieldId) {
+ this.cleanField(fieldId);
+ }
+ break;
}
+
});
- await this.checkPendingUploads();
- this.scanFields();
+
+ window.addEventListener('beforeunload', () => {
+ this.cleanupAllPreviewUrls();
+ });
}
- initElements() {
- this.selectors = {
- field: {
- field: '.field.image',
- dropZone: '.file-upload-container',
- preview: '.item-grid.preview',
- hiddenValue: 'input[type="hidden"]',
- progress: {
- progress: '.progress',
- details: '.progress .details',
- fill: '.progress .fill',
- count: '.progress .count'
- },
+ initWorker() {
+ this.worker = {
+ worker: null,
+ timeout: null,
+ tasks: new Map(),
+ restart: {
+ count: 0,
+ max: 3,
},
- item: {
- img: 'img',
- progress: {
- progress: '.progress',
- details: '.progress .details',
- fill: '.progress .fill',
- count: '.progress .count'
- },
- status: '.status',
- select: '[name*="select-item"]',
- actions: '.item-actions',
- featured: '[name="featured"]',
- meta: '.upload-meta'
- },
- groups: {
- container: '.item-grid.groups',
- display: '.group-display',
- selectAll: '#select-all-uploads',
- actions: '.selection-actions',
- info: '.selection-controls .info',
- count: '.selection-count',
- group: '.upload-group',
- empty: '.empty-group'
+ settings: {
+ timeout: 10000, //10 seconds per image
+ batchSize: 1,
+ maxConcurrent: 3,
+ restartAfterTimeout: true
}
};
- this.ui = {};
}
- scanFields() {
- document.querySelectorAll(this.selectors.field.field).forEach(uploader => {
+ /**
+ * Initialize all upload fields on the page
+ */
+ initializeFields() {
+ const fields = document.querySelectorAll(this.selectors.field.field);
+ fields.forEach(uploader => {
this.registerUploader(uploader);
});
}
- /**
- *
- * @param {HTMLElement} uploader
- * @param {object} options
- * @param {string} options.id Uploader field ID: defaults to uploader.dataset.fieldId
- * @param {string} options.type Uploader type: defaults to uploader.dataset.type
- * @param {number} options.maxFiles Maximum files to allow: defaults to type defaults
- * @param {boolean} options.multiple Whether to allow multiple uploads
- * @param {number} options.itemID The post or term ID this is for.
- * @param {string} options.mode
- * @returns {string}
- */
- registerUploader(uploader, options = {}) {
- //Determine if this is for a post, term, content uploader, or option
- let key = uploader.dataset['uploader']??this.determineKey(uploader);
+ scanFields(container) {
+ const fields = container.querySelectorAll(this.selectors.field.field);
+ fields.forEach(uploader => {
+ this.registerUploader(uploader);
+ });
+ }
- uploader.dataset['uploader'] = key;
+ registerUploader(uploader) {
+ const fieldId = this.determineFieldId(uploader);
+ const config = this.extractFieldConfig(uploader);
- if (!this.fields.has(key)) {
- let type = uploader.dataset.type;
+ // Create field data structure
+ const field = {
+ id: fieldId,
+ config: config,
+ element: uploader,
+ ui: this.buildFieldUI(uploader),
+ uploads: new Set(),
+ groups: new Set(),
+ state: 'ready',
+ };
- let typeConfig = this.settings.fieldTypes[type]??this.settings.fieldTypes['single'];
- let config = {
- key: key,
- name: uploader.dataset.field,
- ui: {},
- type: type,
- maxFiles: typeConfig.maxFiles,
- multiple: typeConfig.allowMultiple,
- content: uploader.dataset.content??uploader.closest('dialog')?.dataset.content??uploader.closest('form').dataset.save??false,
- itemID: uploader.dataset.itemID??uploader.closest('dialog')?.dataset.itemID??false,
- context: uploader.dataset.context??uploader.closest('dialog')?.dataset.context??false,
- mode: uploader.dataset.mode??'direct',
- ... options
- };
+ this.fields.set(fieldId, field);
+ uploader.dataset.uploader = fieldId;
+ this.addFieldSelectionHandler(fieldId);
- config.ui = window.uiFromSelectors(this.selectors, uploader);
- config.ui.groups.groups = new Map();
-
- this.selected.set(key, new Set());
- this.fields.set(key, config);
- if(config.type === 'groupable' && !this.hasGroups) {
- this.initGroupListeners();
- }
+ if (config.destination === 'post_group' && !this.dragController) {
+ this.initGroupFeatures();
}
- return key;
+
+ return fieldId;
}
/**
- * Builds a key from the uploader, built from the Content Type, ItemID, and FieldName
- * @param uploader
- * @returns {string}
+ * Extract configuration from field element
*/
- determineKey(uploader) {
- let content = uploader.dataset.content??uploader.closest('dialog')?.dataset.content??uploader.closest('form').dataset.save??'';
- let itemID = uploader.dataset.itemID??uploader.closest('dialog')?.dataset.itemID??'';
- let field = uploader.dataset.field;
- return `${content}_${itemID}_${field}`;
+ extractFieldConfig(fieldElement) {
+ return {
+ destination: fieldElement.dataset.destination || 'meta',
+ content: fieldElement.dataset.content || null,
+ mode: fieldElement.dataset.mode || 'direct',
+ type: fieldElement.dataset.type || 'single',
+ name: fieldElement.dataset.field, // Field name for meta
+ itemID: fieldElement.dataset.itemId || 0, // Post/term/user ID
+ maxFiles: parseInt(fieldElement.dataset.maxFiles) || 999,
+ subtype: fieldElement.dataset.subtype || 'image'
+ };
}
/**
- *
- * @param {HTMLElement} element
+ * Build UI element references for a field
*/
- getFieldIdFromElement(element) {
- let field = element.closest('.field.image');
- if (!field) {
+ buildFieldUI(fieldElement) {
+ let UI = {
+ field: fieldElement,
+ input: fieldElement.querySelector(this.selectors.field.input),
+ dropZone: fieldElement.querySelector(this.selectors.field.dropZone),
+ preview: fieldElement.querySelector(this.selectors.field.preview),
+ progress: {
+ progress: fieldElement.querySelector(this.selectors.field.progress),
+ bar: fieldElement.querySelector('.bar'),
+ fill: fieldElement.querySelector('.fill'),
+ details: fieldElement.querySelector('.details'),
+ text: fieldElement.querySelector('.details .text'),
+ count: fieldElement.querySelector('.details .count')
+ }
+ };
+
+ let display = fieldElement.querySelector('.group-display');
+ if (display) {
+ UI.groups = {
+ display: display,
+ container: fieldElement.querySelector('.item-grid.groups'),
+ empty: fieldElement.querySelector('.empty-group'),
+ groups: new Map()
+ };
+ }
+
+ return UI;
+ }
+
+ /**
+ * Set up core event listeners
+ */
+ initListeners() {
+ this.clickHandler = this.handleClick.bind(this);
+ this.changeHandler = this.handleChange.bind(this);
+
+ document.addEventListener('click', this.clickHandler);
+ document.addEventListener('change', this.changeHandler);
+
+ // External file drops
+ this.dragEnterHandler = this.handleExternalDragEnter.bind(this);
+ this.dragLeaveHandler = this.handleExternalDragLeave.bind(this);
+ this.dragOverHandler = this.handleExternalDragOver.bind(this);
+ this.dropHandler = this.handleExternalDrop.bind(this);
+
+ document.addEventListener('dragenter', this.dragEnterHandler);
+ document.addEventListener('dragleave', this.dragLeaveHandler);
+ document.addEventListener('dragover', this.dragOverHandler);
+ document.addEventListener('drop', this.dropHandler);
+ }
+
+ /**
+ * Initialize group-specific features (drag & drop for rearranging)
+ */
+ initGroupFeatures() {
+ // Initialize drag controller for rearranging items
+ this.dragController = new window.jvbDragHandler({
+ // What can be dragged
+ draggableSelector: this.selectors.items.item,
+
+ // Where items can be dropped
+ dropTargetSelector: `${this.selectors.field.preview}, ${this.selectors.groups.grid}, .empty-group`,
+
+ // Don't start drag on interactive elements
+ ignoreSelector: 'input:not(.upload-select), button, select, textarea, details, summary, a',
+ previewElement: 'img, video, .icon',
+
+ // Extract upload ID from element
+ getItemId: (element) => {
+ return element.dataset.uploadId;
+ },
+
+ // Get selected items for multi-drag
+ getSelectedItems: (element) => {
+ const fieldId = this.getFieldIdFromElement(element);
+ const uploadId = element.dataset.uploadId;
+ const selected = this.getCurrentSelection(fieldId);
+
+ if (selected && selected.includes(uploadId)) {
+ return selected;
+ }
+
+ return [uploadId];
+ },
+
+ // Validate drop location
+ validateDrop: (itemIds, targetElement) => {
+ const targetFieldId = this.getFieldIdFromElement(targetElement);
+ const itemElement = document.querySelector(`[data-upload-id="${itemIds[0]}"]`);
+ const itemFieldId = this.getFieldIdFromElement(itemElement);
+
+ return targetFieldId === itemFieldId;
+ },
+
+ // Handle successful drop
+ onDrop: (itemIds, targetElement) => {
+ this.handleItemDrop(itemIds, targetElement);
+ targetElement.scrollIntoView({behavior:'smooth', block:'center'});
+ },
+
+ // Optional callbacks
+ onDragStart: (itemIds) => {
+ },
+
+ onDragEnd: (itemIds, success) => {
+ if (success) {
+ // Clear selection after successful move
+ const itemElement = document.querySelector(`[data-upload-id="${itemIds[0]}"]`);
+ const fieldId = this.getFieldIdFromElement(itemElement);
+ const handler = this.selectionHandlers.get(fieldId);
+ handler?.clearSelection();
+ }
+ },
+
+ // Preview options
+ previewOptions: {
+ multiOffset: { x: -60, y: -80 },
+ singleOffset: { x: -50, y: -60 },
+ showCount: true
+ }
+ });
+ }
+
+ /*******************************************************************************
+ * EXTERNAL FILE DROP HANDLERS (for new uploads from desktop)
+ *******************************************************************************/
+
+ handleExternalDragLeave(e) {
+ const dropZone = e.target.closest(this.selectors.field.dropZone);
+ if (dropZone && !dropZone.contains(e.relatedTarget)) {
+ dropZone.classList.remove('dragover');
+ }
+ }
+ handleExternalDragEnter(e) {
+ if (!e.dataTransfer.types.includes('Files')) {
return;
}
- return field.dataset.uploader??this.determineKey(field);
+
+ const dropZone = e.target.closest(this.selectors.field.dropZone);
+
+ if (dropZone) {
+ e.preventDefault();
+ dropZone.classList.add('dragover');
+ }
}
- getFieldFromElement(element) {
- let id = this.getFieldIdFromElement(element);
- return (this.fields.has(id)) ? this.fields.get(id) : false;
+ handleExternalDragOver(e) {
+ if (!e.dataTransfer.types.includes('Files')) return;
+
+ const dropZone = e.target.closest(this.selectors.field.dropZone);
+ if (dropZone) {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'copy';
+ }
}
- getUploadFromElement(element) {
- let id = this.getUploadIdFromElement(element);
- return (this.uploads.has(id)) ? this.uploads.get(id) : false;
+ handleExternalDrop(e) {
+ const dropZone = e.target.closest(this.selectors.field.dropZone);
+
+ if (!dropZone) return;
+
+ e.preventDefault();
+ dropZone.classList.remove('dragover');
+
+ const files = Array.from(e.dataTransfer.files);
+
+ if (files.length === 0) return;
+
+ const fieldId = this.getFieldIdFromElement(dropZone);
+
+ if (fieldId) {
+ this.processFiles(fieldId, files);
+ this.a11y.announce(`${files.length} file(s) dropped for upload`);
+ } else {
+ console.error('No field ID found for drop zone');
+ }
}
- getUploadIdFromElement(element) {
- let upload = element.closest('[data-upload-id]');
- return upload?.dataset.uploadId || null;
- }
+ /*******************************************************************************
+ * ITEM DROP HANDLER (for rearranging existing uploads)
+ *******************************************************************************/
- getGroupFromElement(element) {
- let groupId = this.getGroupIdFromElement(element);
- return (this.groups.has(groupId)) ? this.groups.get(groupId) : false;
- }
- getGroupIdFromElement(element) {
- return element.dataset.groupId??element.closest('[data-group-id]')?.dataset.groupId??element.closest(':has([data-group-id])')?.querySelector('[data-group-id]')?.dataset.groupId??null;
- }
+ /**
+ * Handle items being dropped (called by DragController)
+ */
+ handleItemDrop(itemIds, targetElement) {
+ const isPreviewDrop = targetElement.classList.contains('preview');
+ let actualTarget = targetElement;
- getModalType(field) {
- // Safety check for field.ui
- if (!field || !field.ui || !field.ui.field || !field.ui.field.field) {
- return null;
+ // Handle drop on empty group placeholder
+ if (targetElement.classList.contains('empty-group')) {
+ const fieldId = this.getFieldIdFromElement(targetElement);
+ const group = this.createGroup(fieldId);
+
+ if (!group) {
+ console.error('Failed to create group');
+ return;
+ }
+
+ actualTarget = group.grid;
}
- const dialog = field.ui.field.field.closest('dialog');
- if (!dialog) return null;
+ // Move each item to target
+ itemIds.forEach(uploadId => {
+ if (isPreviewDrop) {
+ // Moving back to preview (ungrouping)
+ this.removeFromGroup(uploadId);
+ } else {
+ // Moving to a group
+ this.addToGroup(uploadId, actualTarget);
+ }
+ });
- if (dialog.classList.contains('edit')) return 'edit';
- if (dialog.classList.contains('create')) return 'create';
- if (dialog.classList.contains('bulkEdit')) return 'bulkEdit';
+ // Persist state
+ const fieldId = this.getFieldIdFromElement(targetElement);
+ this.schedulePersistance(fieldId);
- return dialog.className;
+ // Announce for accessibility
+ const message = itemIds.length > 1
+ ? `Moved ${itemIds.length} items`
+ : 'Moved item';
+ this.a11y.announce(message);
+ }
+
+ /*******************************************************************************
+ * CLICK HANDLERS
+ *******************************************************************************/
+
+ handleClick(e) {
+ // File input triggers
+ if (e.target.matches(this.selectors.field.dropZone) ||
+ e.target.closest(this.selectors.field.dropZone)) {
+ const dropZone = e.target.closest(this.selectors.field.dropZone);
+ if (dropZone && !e.target.matches('input, button, a')) {
+ const input = dropZone.querySelector(this.selectors.field.input);
+ input?.click();
+ }
+ }
+
+ // Group actions
+ const actionButton = e.target.closest('[data-action]');
+ if (actionButton) {
+ this.handleAction(actionButton);
+ }
+ }
+
+ handleChange(e) {
+ const fieldId = this.getFieldIdFromElement(e.target);
+ // File input change
+ if (e.target.matches(this.selectors.field.input)) {
+ const fieldId = this.getFieldIdFromElement(e.target);
+ const files = Array.from(e.target.files);
+
+ if (files.length > 0 && fieldId) {
+ this.processFiles(fieldId, files);
+ }
+ }
+
+ // Meta field changes
+ if (fieldId) {
+ if (this.fields.get(fieldId).config.destination === 'post_group') {
+ this.handleGroupMetaChange(e.target);
+ } else {
+ this.queueUploadMeta(e);
+ }
+ }
+ }
+
+ /********************************************************************************
+ UTILITY
+ ********************************************************************************/
+ getCurrentSelection(fieldId) {
+ let selected = [];
+ for (let [key, handler] of this.selectionHandlers) {
+ if ((fieldId === key || key.includes(fieldId)) && handler.selectedItems.size > 0) {
+ selected = selected.concat([... handler.selectedItems]);
+ }
+ }
+ return selected;
+ }
+
+ getSubtypeFromMime(mimeType) {
+ if (mimeType.startsWith('image/')) return 'image';
+ if (mimeType.startsWith('video/')) return 'video';
+ return 'document';
}
getStatusText(status) {
@@ -270,7 +528,6 @@
return window.getIcon(this.queue.icons[status]);
}
getStatusProgress(status) {
- console.log('Getting status progress for: ', status);
switch (status) {
case 'local_processing':
return 28;
@@ -289,1133 +546,518 @@
}
}
- /******************************************************************************
- LISTENERS
- ******************************************************************************/
- initListeners() {
- this.clickHandler = this.handleClick.bind(this);
- this.changeHandler = this.handleChange.bind(this);
-
- if (this.hasBulkContext) {
- this.pasteHandler = this.handlePaste.bind(this);
- document.addEventListener('paste', this.pasteHandler);
+ getModalType(field) {
+ // Return cached value if available
+ if (field._cachedModalType !== undefined) {
+ return field._cachedModalType;
}
-
- document.addEventListener('click', this.clickHandler);
- document.addEventListener('change', this.changeHandler);
- window.addEventListener('beforeunload', this.handleBeforeUnload.bind(this));
- }
- clearListeners() {
- document.removeEventListener('click', this.clickHandler);
- document.removeEventListener('change', this.changeHandler);
- if (this.hasBulkContext) {
- document.removeEventListener('paste', this.pasteHandler);
- }
- }
-
- initGroupListeners() {
- this.hasGroups = true;
-
- this.dragStartHandler = this.handleDragStart.bind(this);
- this.dragEndHandler = this.handleDragEnd.bind(this);
- this.dragEnterHandler = this.handleDragEnter.bind(this);
- this.dragOverHandler = this.handleDragOver.bind(this);
- this.dragLeaveHandler = this.handleDragLeave.bind(this);
- this.dropHandler = this.handleDrop.bind(this);
-
- this.touchStartHandler = this.handleTouchStart.bind(this);
- this.touchMoveHandler = this.handleTouchMove.bind(this);
- this.touchEndHandler = this.handleTouchEnd.bind(this);
- this.touchCancelHandler = this.handleTouchCancel.bind(this);
-
- document.addEventListener('dragstart', this.dragStartHandler);
- document.addEventListener('dragend', this.dragEndHandler);
- document.addEventListener('dragenter', this.dragEnterHandler);
- document.addEventListener('dragover', this.dragOverHandler);
- document.addEventListener('dragleave', this.dragLeaveHandler);
- document.addEventListener('drop', this.dropHandler);
-
- document.addEventListener('touchstart', this.touchStartHandler);
- document.addEventListener('touchmove', this.touchMoveHandler);
- document.addEventListener('touchend', this.touchEndHandler);
- document.addEventListener('touchcancel', this.touchCancelHandler);
- }
- clearGroupListeners() {
- document.removeEventListener('dragstart', this.dragStartHandler);
- document.removeEventListener('dragend', this.dragEndHandler);
- document.removeEventListener('dragenter', this.dragEnterHandler);
- document.removeEventListener('dragover', this.dragOverHandler);
- document.removeEventListener('dragleave', this.dragLeaveHandler);
- document.removeEventListener('drop', this.dropHandler);
-
- document.removeEventListener('touchstart', this.touchStartHandler);
- document.removeEventListener('touchmove', this.touchMoveHandler);
- document.removeEventListener('touchend', this.touchEndHandler);
- document.removeEventListener('touchcancel', this.touchCancelHandler);
- }
-
- handleClick(e) {
- if (!e.target.closest(this.selectors.field.field)) {
- return;
+ // Safety check for field.element
+ if (!field || !field.element) {
+ field._cachedModalType = null;
+ return null;
}
- if (window.targetCheck(e, '.restart-uploads')) {
- e.preventDefault();
- const fieldId = this.getFieldIdFromElement(e.target);
- this.restartUploads(fieldId);
- } else if (window.targetCheck(e, '.dismiss-cache-restore')) {
- e.preventDefault();
- const notification = e.target.closest('.upload-recovery-notification');
- if (notification) notification.remove();
- } else if (window.targetCheck(e, '#select-all-uploads')) {
- e.preventDefault();
- this.handleSelectAll(e.target);
- } else if (window.targetCheck(e, '.upload-select')) {
- const isShiftClick = e.shiftKey && this.lastClickedUpload;
- if (isShiftClick) {
- e.preventDefault();
- this.handleRangeSelection(e.target, e);
- } else {
- this.updateSelection(e);
- }
- } else if (window.targetCheck(e, '.create-from-selection')) {
- e.preventDefault();
- let group = this.createGroup(this.getFieldFromElement(e.target));
- this.addSelectionToGroup(group);
- } else if (window.targetCheck(e, '.remove-selection')) {
- e.preventDefault();
- this.removeSelection(e.target);
- } else if (window.targetCheck(e, '.add-to-group, .add-selection-to-group')) {
- e.preventDefault();
- this.addSelectionToGroup(e.target);
- } else if (window.targetCheck(e, '.remove-group')) {
- e.preventDefault();
- const groupElement = e.target.closest('.upload-group');
- if (groupElement) {
- let field = this.getFieldFromElement(groupElement);
- this.removeGroup(groupElement, true);
- }
- } else if (window.targetCheck(e, '.remove')) {
- e.preventDefault();
- const uploadId = this.getUploadIdFromElement(e.target);
- const fieldId = this.getFieldIdFromElement(e.target);
- if (uploadId && fieldId) {
- this.removeUpload(fieldId, uploadId);
- }
- } else if (window.targetCheck(e, '.submit-uploads')) {
- e.preventDefault();
- const fieldId = this.getFieldIdFromElement(e.target);
- this.submitUploads(fieldId);
- } else if (window.targetCheck(e, '.retry-upload')) {
- e.preventDefault();
- const uploadId = this.getUploadIdFromElement(e.target);
- this.retryUpload(uploadId);
- }
- }
- handleChange(e) {
- if (!e.target.closest(this.selectors.field.field) || e.target.classList.contains(this.selectors.field.hiddenValue)) {
- return;
- }
- e.preventDefault();
-
- if (window.targetCheck(e, '[type="file"]')) {
- console.log(this.fields);
- let field = this.getFieldFromElement(e.target);
- console.log(field);
- if (!field) {
- console.warn('File change on unregistered field: ', field.key)
- return;
- }
-
- const files = Array.from(e.target.files);
- if (files.length === 0) return;
-
- this.processFiles(field.key, files);
- e.target.value = '';
- } else if (e.target.name.includes('select-')) {
- this.updateSelection(e);
- } else if (e.target.closest('.upload-meta')) {
- e.preventDefault();
- let name = e.target.name;
- let value = e.target.value;
- let upload = this.getUploadFromElement(e.target);
- upload.changes[name] = value;
- this.uploads.set(upload.id, upload);
- this.persistFieldState(upload.fieldId);
-
- //It's meta!
- //TODO:
- //Step 1) determine whether the images have already been sent to the server. If not, we must wait until they have been
- //Step 2) Queue the Meta changes. No need to wait, the Queue.js will handle any debouncing/timeouts
- //Ensure the dependencies have all operations stored to the field that the images were uploaded with (can be multiple)
- //Send to server for processing
- } else if (e.target.closest('.group.fields')) {
- let group = this.getGroupFromElement(e.target);
- let name = e.target.name;
- group.changes[name] = e.target.value;
-
- this.persistFieldState(group.fieldId);
- this.groups.set(group.id, group);
- }
- }
-
- handlePaste(e) {
- window.debouncer.schedule(
- 'imagePaste',
- () => {
- const items = Array.from(e.clipboardData.items);
- const imageItems = items.filter(item => item.type.startsWith('image/'));
-
- if (imageItems.length === 0) return;
-
- e.preventDefault();
-
- const fieldId = this.getFieldIdFromElement(e.target);
- if (!fieldId) return;
-
- // Convert clipboard items to files
- const files = [];
- imageItems.forEach((item, index) => {
- const file = item.getAsFile();
- if (file) {
- // Rename for clarity
- const newFile = new File([file], `pasted_image_${index + 1}.png`, {
- type: file.type,
- lastModified: Date.now()
- });
- files.push(newFile);
- }
- });
-
- if (files.length > 0) {
- this.processFiles(fieldId, files);
- }
- },
- 100
- );
- }
-
- isTouchOnFormElement(target) {
- // Check if target is a form element or inside one
- const formElements = [
- 'input', 'button', 'label', 'select', 'textarea',
- ];
-
- return formElements.some(selector => {
- return target.matches(selector) || target.closest(selector);
- });
- }
- /**** DRAG AND TOUCH *****/
- startDragOperation(config) {
- const {
- primaryElement,
- sourceType,
- startPosition,
- event
- } = config;
-
- const uploadId = this.getUploadIdFromElement(primaryElement);
- const fieldId = this.getFieldIdFromElement(primaryElement);
-
- // Determine what items to drag
- const draggedItems = this.getDraggedItems(primaryElement);
-
- // Initialize drag state
- this.dragState = {
- primaryItem: uploadId,
- draggedItems: draggedItems,
- isDragging: true,
- isMultiDrag: draggedItems.length > 1,
- fieldId: fieldId,
- sourceType: sourceType,
- startTime: Date.now(),
- startPosition: startPosition,
- currentPosition: startPosition,
- currentTarget: null,
- validTarget: null,
- dragPreview: null,
- touchId: sourceType === 'touch' ? event.touches[0]?.identifier : null,
- touchMoved: false
- };
-
- // Create drag preview
- this.createDragPreview(primaryElement);
-
- // Apply dragging state
- this.applyDraggingState(true);
-
- const announceText = this.dragState.isMultiDrag
- ? `Started dragging ${draggedItems.length} items`
- : 'Started dragging item';
-
- this.a11y.announce(announceText);
- this.provideDragFeedback('start');
-
- return true;
- }
-
- updateDragOperation(position, elementUnderPointer) {
- if (!this.dragState.isDragging) return;
-
- const { sourceType, startPosition } = this.dragState;
-
- // Update position
- this.dragState.currentPosition = position;
-
- // Check for significant movement (touch)
- if (sourceType === 'touch' && !this.dragState.touchMoved) {
- const deltaX = Math.abs(position.x - startPosition.x);
- const deltaY = Math.abs(position.y - startPosition.y);
-
- if (deltaX > 10 || deltaY > 10) {
- this.dragState.touchMoved = true;
- }
+ const dialog = field.element.closest('dialog');
+ if (!dialog) {
+ field._cachedModalType = null;
+ return null;
}
- // Update preview and target
- this.updateDragPreview(position);
- this.updateDropTarget(elementUnderPointer);
- }
+ let modalType = null;
+ if (dialog.classList.contains('edit')) modalType = 'edit';
+ else if (dialog.classList.contains('create')) modalType = 'create';
+ else if (dialog.classList.contains('bulkEdit')) modalType = 'bulkEdit';
+ else modalType = dialog.className;
- 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')) {
- let group = this.createGroup(fieldId);
- actualTarget = group.querySelector('.item-grid');
- isPreviewDrop = false;
- }
-
- // Use existing addImageToGroup 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.addImageToGroup(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 = [];
- }
-
- /**
- * 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;
- }
-
- styleDragPreview(preview, isMulti = false) {
- preview.style.cssText = `
- position: fixed;
- z-index: 10000;
- pointer-events: none;
- opacity: 0.9;
- transform: scale(1.05);
- transition: transform 0.2s ease;
- ${isMulti ? `
- width: 120px;
- height: 120px;
- background: white;
- border-radius: 8px;
- box-shadow: 0 8px 32px rgba(0,0,0,0.3);
- padding: 4px;
- ` : `
- border-radius: 4px;
- box-shadow: 0 4px 16px rgba(0,0,0,0.2);
- `}
- `;
-
- // Add dragging class for additional styling
- preview.classList.add('drag-preview', 'is-dragging');
- if (isMulti) {
- preview.classList.add('multi-item');
- }
- }
-
- /**
- * 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;
- }
- /**
- * 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.getFieldIdFromElement(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]);
- }
- }
-
- 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.getFieldIdFromElement(uploadContainer);
- if (fieldId) {
- this.processFiles(fieldId, files);
- this.a11y.announce(`${files.length} file(s) dropped for upload`);
- }
- }
- }
- }
-
- handleDragEnd(e) {
- if (!this.dragState.isDragging) return;
-
- // Find the element under the final drop position
- const elementUnderDrop = document.elementFromPoint(
- this.dragState.currentPosition?.x || e.clientX,
- this.dragState.currentPosition?.y || e.clientY
- );
-
- this.endDragOperation(elementUnderDrop);
- }
- /*********
- * TOUCH HANDLERS
- ********/
- handleTouchStart(e) {
- if (!window.targetCheck(e, '.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
- }
- }
-
- 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');
- }
+ // Cache the result
+ field._cachedModalType = modalType;
+ return modalType;
}
/*******************************************************************************
- QUEUE INTEGRATION
+ * GROUP ACTIONS
*******************************************************************************/
- async submitUploads(fieldId) {
+
+ handleAction(button) {
+ const action = button.dataset.action;
+ const fieldId = this.getFieldIdFromElement(button);
+ switch(action) {
+ case 'add-to-group':
+ this.handleAddToGroup(button);
+ break;
+ case 'delete-group':
+ this.handleDeleteGroup(button);
+ break;
+ case 'delete-upload':
+ case 'remove-from-group':
+ this.handleRemoveItem(button);
+ break;
+ case 'upload':
+ //upload groups
+ let field = this.fields.get(fieldId);
+ field.element.closest('details').open = false;
+ document.body.classList.add('uploading');
+
+ this.submitUploads(fieldId);
+ break;
+ case 'restore':
+ this.handleRestoreUploads().then(()=>{});
+ break;
+ case 'clear-cache':
+ if (!confirm(`Save these uploads for later?`)) {
+ this.cleanupStoredUploads();
+ }
+ this.cleanupRestore();
+ break;
+ }
+ }
+
+ handleAddToGroup(button) {
+ const fieldElement = button.closest(this.selectors.field.field);
+ const fieldId = fieldElement?.dataset.uploader;
+
+ if (!fieldId) return;
+
+ const selected = this.selected.get(fieldId);
+
+ if (!selected || selected.size === 0) {
+ // Create empty group
+ this.createGroup(fieldId);
+ } else {
+ // Create group with selected items
+ const group = this.createGroup(fieldId);
+ if (!group) return;
+
+ selected.forEach(uploadId => {
+ this.addToGroup(uploadId, group.grid);
+ });
+
+ // Clear selection
+ const handler = this.selectionHandlers.get(fieldId);
+ handler?.clearSelection();
+
+ this.a11y.announce(`Created group with ${selected.size} items`);
+ }
+
+ this.schedulePersistance(fieldId);
+ }
+
+ handleDeleteGroup(button) {
+ const group = button.closest(this.selectors.groups.container);
+ if (!group) return;
+
+ const groupId = group.dataset.groupId;
+ const fieldId = this.getFieldIdFromElement(group);
+
+ if (!confirm('Delete this group? Items will be moved back to the upload area.')) {
+ return;
+ }
+
+ // Move items back to preview
+ const items = group.querySelectorAll(this.selectors.items.item);
+ items.forEach(item => {
+ const uploadId = item.dataset.uploadId;
+ this.removeFromGroup(uploadId);
+ });
+
+ // Remove group
+ this.deleteGroup(groupId);
+
+ this.a11y.announce('Group deleted, items returned to upload area');
+ this.schedulePersistance(fieldId);
+ }
+
+ handleRemoveItem(button) {
+ const item = button.closest(this.selectors.items.item);
+ if (!item) return;
+
+ const uploadId = item.dataset.uploadId;
+ const fieldId = this.getFieldIdFromElement(item);
+
+ if (!confirm('Remove this item?')) {
+ return;
+ }
+
+ this.removeUpload(fieldId, uploadId);
+ this.a11y.announce('Item removed');
+ this.schedulePersistance(fieldId);
+ }
+
+ /*******************************************************************************
+ * SELECTION MANAGEMENT
+ *******************************************************************************/
+
+ /**
+ * Add selection handler for a field
+ */
+ addFieldSelectionHandler(fieldId) {
+ if (this.selectionHandlers.has(fieldId)) {
+ return this.selectionHandlers.get(fieldId);
+ }
+
const field = this.fields.get(fieldId);
if (!field) return;
- // Check if there are uploads to submit
- const pendingUploads = Array.from(field.uploads || [])
- .map(id => this.uploads.get(id))
- .filter(upload => upload &&
- (upload.status === 'processed' ||
- upload.status === 'processed-original'));
+ const container = field.ui.field;
+ if (!container) return;
- if (pendingUploads.length === 0) {
- this.notifications.add('No uploads ready to submit', 'warning');
- return;
+ const handler = new window.jvbHandleSelection({
+ container: container,
+ ui: {
+ selectAll: container.querySelector('[name="select-all-uploads"]'),
+ bulkControls: container.querySelector('.selection-actions'),
+ count: container.querySelector('.selection-count')
+ },
+ itemSelector: '[data-upload-id]',
+ checkboxSelector: '[name*="select-item"]'
+ });
+
+ // Subscribe to selection changes
+ handler.subscribe((event, data) => {
+ switch(event) {
+ case 'item-selected':
+ case 'item-deselected':
+ case 'range-selected':
+ this.selected.set(fieldId, data.selectedItems);
+ break;
+ case 'select-all':
+ this.handleSelectAll(data.container, data.selected);
+ break;
+ }
+ });
+
+ this.selectionHandlers.set(fieldId, handler);
+ return handler;
+ }
+
+ /**
+ * Add selection handler for a group
+ */
+ addGroupSelectionHandler(fieldId, groupId) {
+ const handlerKey = `${fieldId}_${groupId}`;
+
+ if (this.selectionHandlers.has(handlerKey)) {
+ return this.selectionHandlers.get(handlerKey);
}
- // Queue the uploads
- try {
+ const group = this.groups.get(groupId);
+ if (!group) return;
+
+ const handler = new window.jvbHandleSelection({
+ container: group.element,
+ ui: {
+ selectAll: group.element.querySelector(this.selectors.groups.selectAll),
+ bulkControls: group.element.querySelector(this.selectors.groups.actions),
+ count: group.element.querySelector(this.selectors.groups.count)
+ },
+ itemSelector: '[data-upload-id]',
+ checkboxSelector: '[name*="select-item"]'
+ });
+
+ handler.subscribe((event, data) => {
+ switch(event) {
+ case 'item-selected':
+ case 'item-deselected':
+ case 'range-selected':
+ this.selected.set(fieldId, data.selectedItems);
+ break;
+ case 'select-all':
+ this.handleSelectAll(data.container, data.selected);
+ break;
+ }
+ });
+
+ this.selectionHandlers.set(handlerKey, handler);
+ return handler;
+ }
+
+ handleSelectAll(container, selected) {
+ }
+
+ /*******************************************************************************
+ * HELPER METHODS
+ *******************************************************************************/
+
+ determineFieldId(fieldElement) {
+ const content = fieldElement.dataset.content ||
+ fieldElement.closest('dialog')?.dataset.content ||
+ fieldElement.closest('form')?.dataset.save || '';
+ const itemID = fieldElement.dataset.itemId ||
+ fieldElement.closest('dialog')?.dataset.itemId || '';
+ const field = fieldElement.dataset.field || '';
+
+ return `${content}_${itemID}_${field}`;
+ }
+
+ getFromElement(element, type) {
+ const map = {
+ 'field': { selector: this.selectors.field.field, key: 'uploader', store: this.fields },
+ 'upload': { selector: this.selectors.items.item, key: 'uploadId', store: this.uploads },
+ 'group': { selector: this.selectors.groups.container, key: 'groupId', store: this.groups }
+ };
+
+ const config = map[type];
+ if (!config) return null;
+
+ const el = element.closest(config.selector);
+ if (!el) return null;
+
+ const id = el.dataset[config.key];
+ return config.store.get(id);
+ }
+ getFieldFromElement(el) { return this.getFromElement(el, 'field'); }
+ getUploadFromElement(el) { return this.getFromElement(el, 'upload'); }
+ getGroupFromElement(el) { return this.getFromElement(el, 'group'); }
+
+ getFieldIdFromElement(el) { return this.getFromElement(el, 'field')?.id ?? null};
+ getUploadIdFromElement(el) {return this.getFromElement(el, 'upload')?.id ?? null};
+ getGroupIdFromElement(el) {return this.getFromElement(el, 'group')?.id ?? null};
+
+
+ /*******************************************************************************
+ * FILE PROCESSING
+ *******************************************************************************/
+ async processFiles(fieldId, files) {
+ const field = this.fields.get(fieldId);
+ if (!field) return;
+
+ // Hide upload container, show group display
+ if (field.ui.dropZone) {
+ field.ui.dropZone.hidden = true;
+ }
+ if (field.ui.groups.display) {
+ field.ui.groups.display.hidden = false;
+ }
+
+ const totalFiles = files.length;
+ let processedCount = 0;
+
+ // Show initial progress
+ this.updateUploadProgress(fieldId, 0, totalFiles, 'Processing files...');
+
+ // Initialize field uploads set if needed
+ if (!field.uploads) {
+ field.uploads = new Set();
+ }
+
+ // Process files
+ const processPromises = Array.from(files).map(async (file, index) => {
+ try {
+
+ // Create upload ID
+ const uploadId = `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+
+ // Create upload data
+ const uploadData = {
+ id: uploadId,
+ attachment_id: null,
+ fieldId: fieldId,
+ originalFile: file,
+ processedFile: null,
+ preview: null,
+ status: 'local_processing',
+ element: null,
+ location: null,
+ meta: {
+ originalName: file.name,
+ size: file.size,
+ type: file.type
+ }
+ };
+
+ // Create preview URL
+ uploadData.preview = this.createPreviewUrl(file);
+
+ // Process the file (resize if image)
+ if (file.type.startsWith('image/')) {
+ uploadData.processedFile = await this.processImage(file, field.subtype);
+ } else {
+ uploadData.processedFile = file;
+ }
+
+ // Store blob data separately in IndexedDB
+ await this.uploadStore.saveBlob(uploadId, uploadData.processedFile || file);
+
+ // Create DOM element
+ const subtype = this.getSubtypeFromMime(file.type);
+ uploadData.element = this.createUploadElement({
+ ...uploadData,
+ subtype: subtype
+ }, field.config.destination === 'post_group');
+
+ // Show progress on the item
+ this.showUploadProgress(uploadId, true);
+ this.updateUploadItemProgress(uploadId, 50, 'local_processing');
+
+ // Add to preview grid
+ if (field.ui.preview) {
+ field.ui.preview.appendChild(uploadData.element);
+ uploadData.location = field.ui.preview;
+ }
+
+ // Store upload
+ this.uploads.set(uploadId, uploadData);
+ field.uploads.add(uploadId);
+
+ // Update progress
+ processedCount++;
+ this.updateUploadProgress(fieldId, processedCount, totalFiles, 'Processing files...');
+ this.updateUploadItemProgress(uploadId, 100, 'processed');
+ uploadData.status = 'processed';
+
+ // Fade out item progress after a moment
+ setTimeout(() => {
+ this.showUploadProgress(uploadId, false);
+ }, 1000);
+
+ return uploadId;
+
+ } catch (error) {
+ console.error('Error processing file:', file.name, error);
+ processedCount++;
+ this.updateUploadProgress(fieldId, processedCount, totalFiles, 'Processing files...');
+ return null;
+ }
+ });
+
+ // Wait for all files to process
+ await Promise.all(processPromises);
+
+ this.updateFieldState(fieldId);
+ // Cache the state (now without DOM references)
+ await this.schedulePersistance(fieldId);
+
+ // Queue for upload if in direct mode
+ if (field.config.destination !== 'post_group') {
await this.queueUpload(fieldId);
- this.notifications.add(`Submitting ${pendingUploads.length} upload(s)`, 'info');
- } catch (error) {
- this.error.log(error, {
- component: 'UploadManager',
- action: 'submitUploads',
- fieldId
- });
- this.notifications.add('Failed to submit uploads', 'error');
+ // Lock uploads if max reached
+ this.maybeLockUploads(fieldId);
+ }
+
+ }
+
+ updateFieldState(fieldId) {
+ const field = this.fields.get(fieldId);
+ if (!field || !field.ui.field) return;
+
+ const container = field.ui.field;
+ const uploadCount = field.uploads?.size || 0;
+ const hasGroups = field.ui.groups?.container?.querySelectorAll('.upload-group').length > 0;
+
+ // Set data attributes for CSS targeting
+ container.dataset.hasUploads = uploadCount > 0 ? 'true' : 'false';
+ container.dataset.uploadCount = uploadCount.toString();
+ container.dataset.hasGroups = hasGroups ? 'true' : 'false';
+
+ // Update ARIA labels for accessibility
+ if (field.ui.preview) {
+ field.ui.preview.setAttribute('aria-label',
+ `Upload preview area with ${uploadCount} item${uploadCount !== 1 ? 's' : ''}`
+ );
}
}
- async retryUpload(uploadId) {
+
+ updateUploadProgress(fieldId, current, total, message) {
+ const field = this.fields.get(fieldId);
+ if (!field?.ui?.progress?.progress) return;
+
+ const progress = field.ui.progress;
+ const percent = total > 0 ? (current / total) * 100 : 0;
+
+ if (progress.fill) {
+ progress.fill.style.width = `${percent}%`;
+ }
+ if (progress.text) {
+ progress.text.textContent = message;
+ }
+ if (progress.count) {
+ progress.count.textContent = `${current}/${total}`;
+ }
+
+ progress.progress.hidden = (current === total);
+ }
+
+ updateFieldStatus(fieldId, status) {
+ const field = this.fields.get(fieldId);
+ if (!field) return;
+
+ field.state = status;
+ // Update UI based on status
+ }
+
+ updateUploadStatus(uploadId, status) {
const upload = this.uploads.get(uploadId);
if (!upload) return;
- const field = this.fields.get(upload.fieldId);
- if (!field) return;
-
- try {
- // Reset status
- this.updateUploadStatus(uploadId, 'received');
-
- // If we have the processed file, skip to queuing
- if (upload.processedFile) {
- this.updateUploadStatus(uploadId, 'processed');
- await this.queueUpload(upload.fieldId);
- } else if (upload.originalFile) {
- // Reprocess the file
- const reprocessed = await this.processFile(upload.fieldId, upload.originalFile);
- if (reprocessed) {
- await this.queueUpload(upload.fieldId);
- }
- } else {
- throw new Error('No file data available for retry');
- }
-
- this.notifications.add('Retrying upload...', 'info');
- } catch (error) {
- this.error.log(error, {
- component: 'UploadManager',
- action: 'retryUpload',
- uploadId
- });
- this.notifications.add('Failed to retry upload', 'error');
- }
- }
- async restartUploads(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field?.uploads) return;
-
- const failedUploads = Array.from(field.uploads)
- .map(id => this.uploads.get(id))
- .filter(upload => upload && upload.status === 'failed');
-
- if (failedUploads.length === 0) {
- this.notifications.add('No failed uploads to restart', 'info');
- return;
- }
-
- for (const upload of failedUploads) {
- await this.retryUpload(upload.id);
- }
-
- this.notifications.add(`Restarting ${failedUploads.length} upload(s)`, 'info');
- }
- async queueUpload(fieldId) {
- //Further cache it, or is it already cached at this point?
- const field = this.fields.get(fieldId);
- if (!field?.uploads) return;
-
- const uploads = Array.from(field.uploads);
- if (uploads.length === 0) {
- return;
- }
-
- const data = this.prepareUploadData(field, uploads);
- this.a11y.announce('Queuing for upload');
- let img = (uploads.length === 1) ? 'image' : 'images';
- const operation = {
- endpoint: 'uploads',
- method: 'POST',
- data: data,
- title: `Uploading ${uploads.length} ${img} to server...`,
- popup: `Uploading ${uploads.length} ${img}...`,
- canMerge: false,
- headers: {
- 'action_nonce': jvbSettings.dash
- },
- append: '_upload'
- }
- try {
- const operationId = await this.queue.addToQueue(operation);
-
- uploads.forEach(uploadId => {
- let upload = this.uploads.get(uploadId);
- if (!upload) {
- return;
- }
- upload.operationId = operationId;
- this.updateUploadStatus(uploadId, 'queued');
- });
- field.operationId = operationId;
-
- return operationId;
- } catch (error) {
- throw error;
- } finally {
- this.persistFieldState(field.key);
- }
+ upload.status = status;
+ this.updateUploadUI(uploadId);
}
- prepareUploadData(field, uploads) {
- console.log('Preparing Upload:', field);
- const formData = new FormData();
- formData.append('content', field.content);
- formData.append('mode', field.mode);
- formData.append('field_name', field.name);
- formData.append('field_key', field.key);
- formData.append('field_type', field.type);
- formData.append('item_id', field.itemID); //post, term, or user id
- formData.append('context', field.context); //post, term, or user
- let uploadMap = [];
- uploads.forEach(uploadId => {
- let upload = this.uploads.get(uploadId);
- if (upload) {
- const fileToUpload = upload.processedFile || upload.originalFile;
- if (fileToUpload) {
- formData.append('files[]', fileToUpload);
- uploadMap.push(upload.id);
- } else {
- console.warn(`No file for upload ${uploadId}`);
- }
- } else {
- console.warn(`Upload ${uploadId} not found in uploads map`);
- }
- });
- formData.append('upload_map', uploadMap);
+ updateUploadUI(uploadId) {
+ const upload = this.uploads.get(uploadId);
+ if (!upload?.element) return;
- console.log('Final FormData:');
- for (let pair of formData.entries()) {
- console.log(pair[0], pair[1]);
- }
+ // Update status classes
+ upload.element.className = upload.element.className.replace(/status-[\w-]+/g, '');
+ upload.element.classList.add(`status-${upload.status}`);
- return formData;
- }
-
- async queueImageMeta(e) {
- const upload = this.getUploadFromElement(element);
- if (!upload) return;
-
- const field = this.fields.get(upload.fieldId);
- if (!field) return;
-
- // Collect meta data from the form
- const metaContainer = element.closest('.upload-meta');
- if (!metaContainer) return;
-
- const metaData = {
- title: metaContainer.querySelector('[name="title"]')?.value || '',
- alt_text: metaContainer.querySelector('[name="alt_text"]')?.value || '',
- caption: metaContainer.querySelector('[name="caption"]')?.value || '',
- description: metaContainer.querySelector('[name="description"]')?.value || ''
- };
-
- // Update upload meta
- upload.meta = { ...upload.meta, ...metaData };
- this.uploads.set(upload.id, upload);
-
- // Mark that we have meta changes
- this.hasMetaChanges = true;
-
- // Determine if upload has been sent to server
- const isOnServer = upload.status === 'completed' && upload.attachmentId;
-
- if (isOnServer) {
- // Queue immediate update
- await this.sendMetaUpdate(upload);
- } else if (upload.operationId) {
- // Wait for upload to complete, then send meta
- this.queueDependentMetaUpdate(upload);
- } else {
- // Upload hasn't been queued yet, meta will be sent with initial upload
- this.persistFieldState(field.key);
+ // Update progress if showing
+ const progress = upload.element.querySelector('.progress');
+ if (progress) {
+ this.updateUploadItemProgress(uploadId,
+ this.getStatusProgress(upload.status),
+ upload.status
+ );
}
}
/**
- * Send meta update to server
+ * Show/hide progress indicator on individual upload items
*/
- async sendMetaUpdate(upload) {
- const formData = new FormData();
- formData.append('attachment_id', upload.attachmentId);
- formData.append('title', upload.meta.title);
- formData.append('alt_text', upload.meta.alt_text);
- formData.append('caption', upload.meta.caption);
- formData.append('description', upload.meta.description);
+ showUploadProgress(uploadId, show = true) {
+ const upload = this.uploads.get(uploadId);
+ if (!upload || !upload.element) return;
- const operation = {
- endpoint: 'uploads/meta',
- method: 'POST',
- data: formData,
- title: `Updating metadata for ${upload.meta.originalName}`,
- canMerge: true,
- headers: {
- 'action_nonce': jvbSettings.dash
+ const progressEl = upload.element.querySelector('.progress');
+ if (progressEl) {
+ if (show) {
+ progressEl.style.removeProperty('animation');
+ progressEl.hidden = false;
+ } else {
+ progressEl.style.animation = 'fadeOut var(--transition-base)';
+ setTimeout(() => {
+ progressEl.hidden = true;
+ }, 300);
}
- };
-
- try {
- await this.queue.addToQueue(operation);
- this.notifications.add('Metadata updated', 'success');
- } catch (error) {
- this.error.log(error, {
- component: 'UploadManager',
- action: 'sendMetaUpdate',
- uploadId: upload.id
- });
}
}
/**
- * Queue meta update that depends on upload completion
+ * Update individual upload progress bar
*/
- queueDependentMetaUpdate(upload) {
- const operation = {
- endpoint: 'uploads/meta',
- method: 'POST',
- dependencies: [upload.operationId],
- data: () => {
- // This function will be called when dependencies are resolved
- const formData = new FormData();
- formData.append('operation_id', upload.operationId);
- formData.append('upload_id', upload.id);
- formData.append('title', upload.meta.title);
- formData.append('alt_text', upload.meta.alt_text);
- formData.append('caption', upload.meta.caption);
- formData.append('description', upload.meta.description);
- return formData;
- },
- title: `Updating metadata after upload`,
- canMerge: true,
- headers: {
- 'action_nonce': jvbSettings.dash
- }
- };
+ updateUploadItemProgress(uploadId, percent, status = null) {
+ const upload = this.uploads.get(uploadId);
+ if (!upload || !upload.element) return;
- this.queue.addToQueue(operation);
+ const progressEl = upload.element.querySelector('.progress');
+ if (!progressEl) return;
+
+ const fill = progressEl.querySelector('.fill');
+ const details = progressEl.querySelector('.details');
+ const icon = progressEl.querySelector('.icon');
+
+ if (fill) {
+ fill.style.width = `${percent}%`;
+ }
+
+ if (status && details) {
+ details.textContent = this.getStatusText(status);
+ }
+
+ if (status && icon) {
+ icon.innerHTML = this.getStatusIcon(status).outerHTML;
+ }
}
- /*******************************************************************************
- IMAGE PROCESSING
- *******************************************************************************/
- 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;
-
- 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);
-
- this.maybeLockUploads(fieldId);
-
- if (field.groupDisplay) {
- field.groupDisplay.hidden = false;
- }
- if (processedUploads.length > 0) {
- await this.queueUpload(fieldId);
- }
-
- this.hideUploadProgress(fieldId);
-
- this.a11y.announce(`Processed ${processedUploads.length} of ${validFiles.length} files`);
- }
-
checkFieldLimits(fieldId, additionalFiles) {
const field = this.fields.get(fieldId);
if (!field) return false;
@@ -1423,18 +1065,9 @@
const currentCount = field.uploads?.size || 0;
const totalCount = currentCount + additionalFiles;
- if (totalCount > field.maxFiles) {
- this.notifications.add(
- `Cannot add ${additionalFiles} files. Max ${field.maxFiles} allowed, currently have ${currentCount}.`,
- 'warning'
- );
- return false;
- }
+ return totalCount <= field.maxFiles;
- return true;
- }
- generateUploadId() {
- return `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+
}
validateFile(file, field) {
// Type validation
@@ -1464,101 +1097,14 @@
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
- async processBatch(fieldId, files) {
- const results = [];
- const processingQueue = [];
- const maxConcurrent = this.worker.settings.maxConcurrent;
-
- let total = files.length;
- for (let i = 0; i < files.length; i++) {
- this.updateUploadProgress(fieldId, i, total);
- // Wait if we've reached max concurrent processing
- if (processingQueue.length >= maxConcurrent) {
- await Promise.race(processingQueue);
- }
-
- const processPromise = this.processFile(fieldId, files[i])
- .then(upload => {
- // Remove from processing queue
- const index = processingQueue.indexOf(processPromise);
- if (index > -1) processingQueue.splice(index, 1);
-
- if (upload) results.push(upload);
- return upload;
- })
- .catch(error => {
- console.error(`Failed to process ${files[i].name}:`, error);
- // Remove from processing queue
- const index = processingQueue.indexOf(processPromise);
- if (index > -1) processingQueue.splice(index, 1);
- return null;
- });
-
- processingQueue.push(processPromise);
+ shouldProcessClientSide(file, subtype) {
+ // Only process images client-side
+ if (subtype === 'image' && file.type.startsWith('image/')) {
+ return true;
}
- // Wait for remaining files
- await Promise.all(processingQueue);
- return results;
- }
-
- async processFile(fieldId, file) {
- const field = this.fields.get(fieldId);
-
- const upload = await this.setUpload(fieldId, file);
- const uploadId = upload.id;
- try {
- // Update UI immediately
- this.addImageToGroup(uploadId);
- this.updateUploadStatus(uploadId, 'local_processing');
-
- // Attempt to process the image
- let processedFile = null;
- let processingFailed = false;
-
- try {
- processedFile = await this.processImage(file, uploadId);
- } catch (error) {
- console.warn(`Processing failed for ${file.name}, using original:`, error);
- processingFailed = true;
- processedFile = file; // Use original
- }
-
- // Update upload with processed file
- upload.processedFile = processedFile;
- upload.processingFailed = processingFailed;
-
- // Update status
- this.updateUploadStatus(uploadId, 'processed');
-
- // Save to uploads map
- this.uploads.set(uploadId, upload);
-
- // Persist state
- if (field && field.key) {
- await this.persistFieldState(field.key);
- }
-
- const message = processingFailed
- ? `${file.name} added (original format)`
- : `${file.name} processed and ready`;
- this.a11y.announce(message);
-
- return upload;
-
- } catch (error) {
- // Clean up failed upload
- this.cleanupFailedUpload(uploadId, field.key);
-
- this.error.log(error, {
- component: 'UploadManager',
- action: 'processFile',
- uploadId,
- fileName: file.name
- });
-
- return null;
- }
+ // Videos and documents go straight to server
+ return false;
}
async processImage(file, uploadId) {
@@ -1705,7 +1251,7 @@
};
try {
- objectUrl = URL.createObjectURL(file);
+ objectUrl = this.createPreviewUrl(file);
img.src = objectUrl;
} catch (error) {
cleanup();
@@ -1833,8 +1379,6 @@
* Restart compression worker
*/
restartCompressionWorker() {
- console.log('Restarting compression worker...');
-
// Terminate existing worker
if (this.worker.worker) {
this.worker.worker.terminate();
@@ -1912,7 +1456,7 @@
`;
const blob = new Blob([workerScript], { type: 'application/javascript' });
- this.worker.worker = new Worker(URL.createObjectURL(blob));
+ this.worker.worker = new Worker(this.createPreviewUrl(blob));
} catch (error) {
console.warn('Failed to initialize compression worker:', error);
@@ -1949,120 +1493,62 @@
return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0;
}
- /**
- * Clean up failed upload
- */
- cleanupFailedUpload(uploadId, fieldId) {
- const field = this.fields.get(fieldId);
- if (field?.uploads) {
- field.uploads.delete(uploadId);
- }
-
- const upload = this.uploads.get(uploadId);
- if (upload) {
- // Clean up preview URL
- if (upload.preview?.startsWith('blob:')) {
- URL.revokeObjectURL(upload.preview);
- }
-
- // Remove element
- upload.element?.remove();
-
- // Remove from uploads
- this.uploads.delete(uploadId);
- }
-
- // Remove from active tasks
- this.worker.tasks.delete(uploadId);
+ createPreviewUrl(file) {
+ const url = URL.createObjectURL(file);
+ // Track for cleanup
+ if (!this.previewUrls) this.previewUrls = new Set();
+ this.previewUrls.add(url);
+ return url;
}
- /*******************************************************************************
- UI FUNCTIONALITY
- *******************************************************************************/
- /**
- * Update upload status correctly
- */
- updateUploadStatus(uploadId, status) {
- console.log('Updating upload status for: ', uploadId);
- let upload = this.uploads.get(uploadId);
- if(!upload) {
- return;
- }
- upload.status = status;
- this.updateImageUI(upload.id);
- this.persistFieldState(upload.fieldId);
- }
- updateImageUI(uploadId) {
- console.log('Updating image UI: ', uploadId);
- const upload = this.uploads.get(uploadId);
- console.log(upload);
- if (!upload?.element) return;
-
-
- const progressEl = upload.element.querySelector('.progress');
- const itemEl = upload.element;
-
- console.log('Updating Upload UI:', upload);
- // Update status class on item for CSS styling
- if (itemEl) {
- itemEl.className = itemEl.className.replace(/status-[\w-]+/g, '');
- itemEl.classList.add(`status-${upload.status}`);
- }
-
- if (progressEl) {
- let icon = this.getStatusIcon(upload.status);
- let message = this.getStatusText(upload.status);
- let progress = this.getStatusProgress(upload.status);
-
- const fill = progressEl.querySelector('.fill');
- const itemIcon = progressEl.querySelector('span.icon');
- const itemMessage = progressEl.querySelector('span.details');
-
- if (fill) {
- fill.style.width = `${progress}%`;
- }
- if (itemMessage) itemMessage.textContent = message;
- if (itemIcon) {
- window.removeChildren(itemIcon);
- itemIcon.append(icon);
- }
-
- if (upload.status === 'completed') {
- setTimeout(() => {
- if (progressEl) {
- window.fade(progressEl, false);
- }
- }, 1000);
- }
+ revokePreviewUrl(url) {
+ if (url?.startsWith('blob:')) {
+ URL.revokeObjectURL(url);
+ this.previewUrls?.delete(url);
}
}
- /**
- * Hide the uploader drop zone if we have reached our limit
- */
+
maybeLockUploads(fieldId) {
const field = this.fields.get(fieldId);
- if (!field) return;
+ if (!field?.ui?.dropZone) return;
- // Hide/show drop zone based on file count
- if (field.ui.field.dropZone) {
- field.ui.field.dropZone.hidden = field.uploads && field.uploads.size >= field.maxFiles;
+ if (field.config.destination === 'post_group') {
+ return;
}
+
+ const uploadCount = field.uploads?.size || 0;
+ const maxFiles = field.config?.maxFiles || 999;
+
+ // Hide dropzone if at max files
+ field.ui.dropZone.hidden = uploadCount >= maxFiles;
+
+ // Update field state
+ field.element.classList.toggle('at-max-uploads', uploadCount >= maxFiles);
}
- createImageElement(upload, draggable = false) {
+ createUploadElement(upload, draggable = false) {
let image = window.getTemplate('uploadItem');
if (!image) {
console.error('Image template not found');
return;
}
image.dataset.uploadId = upload.id;
+ if (upload.originalFile) {
+ image.dataset.subtype = this.getSubtypeFromMime(upload.originalFile.type);
+ }
+
+
image.querySelector('[name="featured"]').value = upload.id;
let [
featured,
img,
+ video,
+ preview,
details
] = [
image.querySelector('[name="featured"]'),
image.querySelector('img'),
+ image.querySelector('video'),
+ image.querySelector('label > span'),
image.querySelector('details')
];
[
@@ -2074,6 +1560,45 @@
upload.preview,
upload.originalFile?.name ?? upload.meta?.originalName ?? '',
];
+
+ switch (image.dataset.subtype) {
+ case 'image':
+ [
+ img.src,
+ img.alt
+ ] = [
+ upload.preview,
+ upload.originalFile?.name ?? upload.meta?.originalName?? ''
+ ];
+ video.remove();
+ preview.remove();
+ break;
+ case 'video':
+ video.src = upload.preview;
+ img.remove();
+ preview.remove();
+ break;
+ case 'document':
+ const fileName = upload.originalFile?.name ?? upload.meta?.originalName ?? '';
+ const extension = fileName.split('.').pop()?.toLowerCase() ?? '';
+ const iconMap = {
+ 'pdf': 'file-pdf',
+ 'csv': 'file-csv',
+ 'doc': 'file-doc',
+ 'docx': 'file-doc',
+ 'txt': 'file-txt',
+ 'xls': 'file-xls',
+ 'xlsx': 'file-xls'
+ };
+
+ const icon = window.getIcon(iconMap[extension] || 'file');
+
+ preview.innerText = upload.originalFile.name;
+ preview.prepend(icon);
+ img.remove();
+ video.remove();
+ break;
+ }
if (details) {
let template = window.getTemplate('uploadMeta');
if (template){
@@ -2097,345 +1622,740 @@
return image;
}
-
- updateUploadProgress(fieldId, current, total, message) {
- const field = this.fields.get(fieldId);
- if (!field) return;
-
- let progressBar = field.ui.field.progress.progress;
-
- // Create progress bar if it doesn't exist
- if (!progressBar) {
- progressBar = window.getTemplate('imageProgress');
-
- // 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 = field.ui.field.progress.fill;
- const progressMessage = field.ui.field.progress.details;
- const progressCount = field.ui.field.progress.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.ui.field.progress.progress;
- if (progressBar) {
- window.fade(progressBar, false);
- }
- }
/*******************************************************************************
- INDEXEDDB CACHE FUNCTIONALITY
- *******************************************************************************/
- async initDB() {
- if (!('indexedDB' in window)) return;
+ * QUEUE INTEGRATION
+ *******************************************************************************/
+ async submitUploads(fieldId) {
+ const field = this.fields.get(fieldId);
+ if (!field?.uploads || field.uploads.size === 0) {
+ return;
+ }
- const request = indexedDB.open(`jvb_uploads_db`, 1);
+ let uploads = Array.from(field.uploads);
+ if (uploads.length === 0) {
+ this.error.log('No uploads to upload', {
+ component: 'UploadManager',
+ action: 'submitGroupedUploads',
+ fieldId: fieldId
+ });
+ return;
+ }
- request.onupgradeneeded = (e) => {
- const db = e.target.result;
- if (!db.objectStoreNames.contains('fieldStates')) {
- const store = db.createObjectStore('fieldStates', { keyPath: 'fieldId' });
- store.createIndex('timestamp', 'timestamp', { unique: false });
- store.createIndex('content', 'content', { unique: false });
- store.createIndex('itemId', 'itemId', { unique: false });
+ const fieldGroups = this.getFieldGroups(fieldId);
+
+ if (fieldGroups.length === 0) {
+ this.error.log('No groups created for post_group upload', {
+ component: 'UploadManager',
+ action: 'submitGroupedUploads',
+ fieldId: fieldId
+ });
+ return;
+ }
+
+ // Build posts array from groups
+ const posts = [];
+ const formData = new FormData();
+ let uploadMap = [];
+
+ uploads = uploads.map((upload) => {
+ return this.uploads.get(upload);
+ });
+
+ fieldGroups.forEach((group, groupIndex) => {
+ const post = {
+ images: [],
+ fields: {}
+ };
+ for (let [name, value] of Object.entries(group.changes)) {
+ post.fields[name] = value;
}
- // Blob storage remains separate for performance
- if (!db.objectStoreNames.contains('uploadBlobs')) {
- db.createObjectStore('uploadBlobs', { keyPath: 'uploadId' });
+ let groupUploads = uploads.filter((upload) => {
+ return upload['groupId'] === group.id;
+ });
+
+ groupUploads.forEach((upload) => {
+ if (upload) {
+ const fileToUpload = upload.processedFile || upload.originalFile;
+ if (fileToUpload) {
+ formData.append('files[]', fileToUpload);
+
+ const imageData = {
+ upload_id: upload.id,
+ index: uploadMap.length
+ };
+ post.images.push(imageData);
+ uploadMap.push(upload.id);
+ }
+ }
+ });
+ // Add images for this group
+ // group.uploads.forEach(uploadId => {
+ // const upload = this.uploads.get(uploadId);
+ // if (upload) {
+ // const fileToUpload = upload.processedFile || upload.originalFile;
+ // if (fileToUpload) {
+ // formData.append('files[]', fileToUpload);
+ //
+ // const imageData = {
+ // upload_id: upload.id,
+ // index: uploadMap.length
+ // };
+ //
+ // // Check if this is the featured image
+ // const radioInput = upload.element?.querySelector('[name="featured"]');
+ // if (radioInput?.checked) {
+ // post.fields.featured = upload.id;
+ // }
+ //
+ // post.images.push(imageData);
+ // uploadMap.push(upload.id);
+ // }
+ // }
+ // });
+
+ posts.push(post);
+ });
+
+ //Each remaining upload (without a groupId) becomes its own post
+ let remainingUploads = uploads.filter((upload) => {
+ return !Object.hasOwn(upload, 'groupId');
+ });
+
+ remainingUploads.forEach((upload) => {
+ if (upload) {
+
+ const post = {
+ images: [],
+ fields: {}
+ };
+ const fileToUpload = upload.processedFile || upload.originalFile;
+ if (fileToUpload) {
+ formData.append('files[]', fileToUpload);
+
+ const imageData = {
+ upload_id: upload.id,
+ index: uploadMap.length
+ };
+ post.images.push(imageData);
+ uploadMap.push(upload.id);
+ }
+ posts.push(post);
+ }
+ });
+
+
+ // Add metadata to FormData
+ formData.append('content', field.config.content);
+ formData.append('user', field.config.itemID); // Assuming itemID is user ID
+ formData.append('posts', JSON.stringify(posts));
+ formData.append('upload_ids', JSON.stringify(uploadMap));
+
+ for (const [key, value] of formData.entries()) {
+ console.log(key, value);
+ }
+ const operation = {
+ endpoint: 'uploads/groups',
+ method: 'POST',
+ data: formData,
+ title: `Creating ${posts.length} ${field.config.content}${posts.length > 1 ? 's' : ''} from uploads...`,
+ popup: `Creating ${posts.length} post${posts.length > 1 ? 's' : ''}...`,
+ canMerge: false,
+ headers: {
+ 'action_nonce': jvbSettings.dash
+ },
+ append: '_upload',
+ };
+
+ try {
+ const operationId = await this.queue.addToQueue(operation);
+
+ uploads.forEach(uploadId => {
+ let upload = this.uploads.get(uploadId);
+ if (upload) {
+ upload.operationId = operationId;
+ this.updateUploadStatus(uploadId, 'queued');
+ }
+ });
+
+ field.operationId = operationId;
+ this.a11y.announce(`Creating ${posts.length} post${posts.length > 1 ? 's' : ''} from your uploads`);
+
+ return operationId;
+ } catch (error) {
+ this.error.log(error, {
+ component: 'UploadManager',
+ action: 'submitGroupedUploads',
+ fieldId: fieldId
+ });
+ throw error;
+ } finally {
+ this.schedulePersistance(field.id);
+ }
+ }
+
+ async queueUpload(fieldId) {
+ const field = this.fields.get(fieldId);
+ if (!field?.uploads) return;
+
+ const uploads = Array.from(field.uploads);
+ if (uploads.length === 0) {
+ return;
+ }
+
+ const data = this.prepareUploadData(field, uploads);
+ this.a11y.announce('Queuing for upload');
+ let img = (uploads.length === 1) ? 'file' : 'files';
+ const operation = {
+ endpoint: 'uploads',
+ method: 'POST',
+ data: data,
+ title: `Uploading ${uploads.length} ${img} to server...`,
+ popup: `Uploading ${uploads.length} ${img}...`,
+ canMerge: false,
+ headers: {
+ 'action_nonce': jvbSettings.dash
+ },
+ append: '_upload'
+ }
+ try {
+ const operationId = await this.queue.addToQueue(operation);
+
+ uploads.forEach(uploadId => {
+ let upload = this.uploads.get(uploadId);
+ if (!upload) {
+ return;
+ }
+ upload.operationId = operationId;
+ this.updateUploadStatus(uploadId, 'queued');
+ });
+ field.operationId = operationId;
+
+ return operationId;
+ } catch (error) {
+ throw error;
+ } finally {
+ this.schedulePersistance(field.id);
+ }
+ }
+
+ prepareUploadData(field, uploads) {
+
+ const formData = new FormData();
+ formData.append('content', field.config.content);
+ formData.append('mode', field.config.mode);
+ formData.append('field_name', field.config.name);
+ formData.append('fieldId', field.id);
+ formData.append('field_type', field.config.type);
+ formData.append('subtype', field.config.subtype);
+ formData.append('item_id', field.config.itemID); //post, term, or user id
+ formData.append('destination', field.config.destination || 'meta'); //meta, post, post_group
+ let uploadMap = [];
+
+ const fieldGroups = this.getFieldGroups(field.id);
+ if (field.config.destination === 'post_group' && fieldGroups.length > 0) {
+ // User has created groups
+ let groups = [];
+ let titles = [];
+ let featuredImages = [];
+
+ fieldGroups.forEach(group => {
+ let groupUploadIndices = [];
+ let featuredIndex = null;
+
+ group.uploads.forEach(uploadId => {
+ let upload = this.uploads.get(uploadId);
+ if (upload) {
+ const fileToUpload = upload.processedFile || upload.originalFile;
+ if (fileToUpload) {
+ formData.append('files[]', fileToUpload);
+ const fileIndex = uploadMap.length;
+ uploadMap.push(upload.id);
+ groupUploadIndices.push(upload.id);
+
+ // Check if this is the featured image
+ const radioInput = upload.element?.querySelector('[name="featured"]');
+ if (radioInput?.checked) {
+ featuredIndex = upload.id;
+ }
+ }
+ }
+ });
+
+ groups.push(groupUploadIndices);
+ titles.push(group.title || '');
+ featuredImages.push(featuredIndex);
+ });
+
+ formData.append('groups', JSON.stringify(groups));
+ formData.append('group_titles', JSON.stringify(titles));
+ formData.append('featured_images', JSON.stringify(featuredImages));
+ } else {
+ // No groups - just append all files
+ uploads.forEach(uploadId => {
+ let upload = this.uploads.get(uploadId);
+ if (upload) {
+ const fileToUpload = upload.processedFile || upload.originalFile;
+ if (fileToUpload) {
+ formData.append('files[]', fileToUpload);
+ uploadMap.push(upload.id);
+ }
+ }
+ });
+ }
+ formData.append('upload_ids', JSON.stringify(uploadMap));
+
+ // console.log('Final FormData:');
+ // for (let pair of formData.entries()) {
+ // console.log(pair[0], pair[1]);
+ // }
+
+ return formData;
+ }
+
+ getFieldGroups(fieldId) {
+ const groups = [];
+
+ this.groups.forEach((groupData, groupId) => {
+ if (groupData.fieldId === fieldId) {
+ const field = this.fields.get(fieldId);
+ const groupElement = field?.ui?.groups?.groups?.get(groupId);
+
+ groups.push({
+ id: groupId,
+ uploads: Array.from(groupData.uploads || new Set()),
+ changes: groupData.changes || {},
+ element: groupElement || null
+ });
+ }
+ });
+
+ return groups;
+ }
+
+ async queueUploadMeta(e) {
+ const upload = this.getUploadFromElement(e.target);
+ if (!upload) return;
+
+ const field = this.fields.get(upload.fieldId);
+ if (!field) return;
+
+ const container = e.target.closest('.upload-meta');
+ if (!container) return;
+
+ let data = {};
+ data[e.target.name] = e.target.value;
+
+ upload.meta = {
+ ...upload.meta,
+ ... data
+ };
+
+ let queueData = {};
+ //If there is an attachment ID, use that: else, use our generated upload id
+ queueData[upload.attachmentId??upload.id] = upload.meta;
+
+ const operation = {
+ endpoint: 'uploads/meta',
+ method: 'POST',
+ data: queueData,
+ title: `Updating meta`,
+ canMerge: true,
+ headers: {
+ 'action_nonce': jvbSettings.dash
}
};
- request.onsuccess = (e) => {
- this.db = e.target.result;
- this.loadFields();
- };
-
- request.onerror = (e) => {
- console.error('IndexedDB error:', e);
- };
- }
-
- async loadFields() {
- if (!this.db) return;
-
- return new Promise((resolve) => {
- const tx = this.db.transaction(['fieldStates', 'uploadBlobs'], 'readonly');
- const fieldStates = tx.objectStore('fieldStates');
- const blobStore = tx.objectStore('uploadBlobs');
- const request = fieldStates.getAll();
-
- request.onsuccess = (e) => {
- e.target.result.forEach(field => {
- let uploads = field.uploads;
- let uploadIds = uploads.map(upload => upload.id);
- field.uploads = new Set(uploadIds);
- this.fields.set(field.key, field);
- uploads.forEach(upload => {
- this.uploads.set(upload.id, upload);
- });
- });
- this.notify('uploads-loaded', { items: Array.from(this.uploads.values()) });
- resolve();
- };
-
- const blobRequest = blobStore.getAll();
-
- blobRequest.onsuccess = (e) => {
- e.target.result.forEach(item => {
- this.uploadBlobs.set(item.id, item);
- });
- this.notify('blobs-loaded', { items: Array.from(this.uploadBlobs.values()) });
- resolve();
- };
- });
- }
-
- getUpload(uploadId) {
- return this.uploads.get(uploadId);
- }
-
- clearField(fieldId) {
- let uploads = Array.from(this.fields.uploads);
- uploads.forEach(upload => {
- this.uploads.delete(upload);
- });
- this.fields.delete(fieldId);
- if (this.db) {
- const tx = this.db.transaction(['fieldStates', 'uploadBlobs'], 'readwrite');
- tx.objectStore('fieldStates').delete(fieldId);
- uploads.forEach(upload => {
- tx.objectStore('uploadBlobs').delete(upload);
+ try {
+ await this.queue.addToQueue(operation);
+ } catch (error) {
+ this.error.log(error, {
+ component: 'UploadManager',
+ action: 'sendMetaUpdate',
+ uploadId: upload.id
});
}
}
+ /*******************************************************************************
+ * GROUP MANAGEMENT
+ *******************************************************************************/
- updateFieldStatus(fieldId, status) {
- const field = this.fields.get(fieldId);
- if (!field) return;
-
- field.uploads.forEach(upload => {
- console.log('Attempting to set upload to status: ', status);
- this.updateUploadStatus(upload, status);
- });
-
- // Update UI based on status
- const container = field.ui.field.field;
- if (container) {
- container.dataset.uploadStatus = status;
-
- // Show/hide relevant UI elements
- const submitBtn = container.querySelector('.submit-uploads');
- if (submitBtn) {
- submitBtn.disabled = status === 'uploading' || status === 'processing';
- }
- }
- }
-
- /**
- * Handle successful upload completion
- */
- handleUploadComplete(operation) {
- const response = operation.response;
- if (!response?.uploads) return;
-
- // Map server IDs to uploads
- response.uploads.forEach(serverUpload => {
- const upload = this.uploads.get(serverUpload.upload_id);
- if (upload) {
- upload.attachmentId = serverUpload.attachment_id;
- this.updateUploadStatus(serverUpload.upload_id, 'completed');
- this.uploads.set(upload.id, upload);
-
- // Clear from cache since it's now on server
- this.clearUpload(upload.id);
- }
- });
-
- // Persist updated field state
- const fieldKey = operation.data.get('field_key');
- if (fieldKey) {
- this.persistFieldState(fieldKey);
- }
- }
-
- /**
- * Clear individual upload from cache after successful server upload
- */
- async clearUpload(uploadId) {
- const upload = this.uploads.get(uploadId);
- if (!upload) return;
-
- // Clean up preview URL
- if (upload.preview?.startsWith('blob:')) {
- URL.revokeObjectURL(upload.preview);
+ createGroup(fieldKey, groupId = null) {
+ const field = this.fields.get(fieldKey);
+ if (!field) {
+ console.error('Field not found:', fieldKey);
+ return null;
}
- this.persistFieldState(upload.fieldId);
- // Remove from memory
- this.uploads.delete(uploadId);
- this.uploadBlobs.delete(uploadId);
-
- // Remove from IndexedDB
- if (this.db) {
- const tx = this.db.transaction(['uploadBlobs'], 'readwrite');
- await tx.objectStore('uploadBlobs').delete(uploadId);
+ if (!groupId) {
+ groupId = `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
- }
- /**
- * Store upload with DataStore integration
- */
- async setUpload(fieldId, file, uploadId = null) {
- if (!uploadId) {
- uploadId = this.generateUploadId();
+ const groupElement = this.createGroupElement(groupId, fieldKey);
+ if (!groupElement) {
+ console.error('Failed to create group element');
+ return null;
}
- const upload = {
- id: uploadId,
- fieldId: fieldId,
- groupId: null,
- originalFile: file,
- processedFile: null,
- status: 'received',
- progress: { percent: 0, message: 'Received...' },
- preview: URL.createObjectURL(file),
- createdAt: Date.now(),
- meta: {
- title: '',
- alt_text: '',
- caption: '',
- originalName: file.name,
- originalType: file.type,
- originalSize: file.size
- },
+
+ // Store in field UI Map
+ if (!field.ui.groups) {
+ field.ui.groups = {
+ groups: new Map(),
+ container: null,
+ empty: null,
+ display: null
+ };
+ }
+
+ field.ui.groups.groups.set(groupId, groupElement);
+
+ // Insert into DOM
+ if (field.ui.groups.container && field.ui.groups.empty) {
+ field.ui.groups.container.insertBefore(groupElement, field.ui.groups.empty);
+ } else if (field.ui.groups.container) {
+ field.ui.groups.container.appendChild(groupElement);
+ }
+
+ // Create group object
+ const group = {
+ id: groupId,
+ fieldId: fieldKey,
+ element: groupElement,
+ grid: groupElement.querySelector('.item-grid.group'),
+ uploads: new Set(),
changes: {}
};
- // Add to field
- const field = this.fields.get(fieldId);
- if (!field) {
- console.error(`Field ${fieldId} not found`);
- return null;
- }
- if (!field.uploads) field.uploads = new Set();
- field.uploads.add(uploadId);
+ // Store group
+ this.groups.set(groupId, group);
- upload.element = this.createImageElement(upload, field.type==='groupable');
- upload.ui = window.uiFromSelectors(this.selectors.item, upload.element);
+ // Initialize selection handler for this group
+ this.addGroupSelectionHandler(fieldKey, groupId);
- // Store in memory
- this.uploads.set(uploadId, upload);
- this.updateImageUI(uploadId);
+ // Persist state
+ this.schedulePersistance(fieldKey);
- // Persist to DataStore
- await this.persistFieldState(fieldId);
-
- return upload;
+ return group;
}
- getFieldUploads(fieldId, stripElements) {
+ createGroupElement(groupId, fieldId) {
+ let groupElement = window.getTemplate('imageGroup');
+ if (!groupElement) return;
+
+ groupElement.dataset.groupId = groupId;
+ groupElement.dataset.fieldId = fieldId;
+
+ let fields = window.getTemplate('groupMetadata');
+ const fieldsContainer = groupElement.querySelector('.fields');
+ if (fieldsContainer && fields) {
+ fieldsContainer.append(fields);
+
+ // Set unique IDs and names for form fields
+ const titleInput = fieldsContainer.querySelector('[name="post_title"]');
+ const excerptInput = fieldsContainer.querySelector('[name="post_excerpt"]');
+
+ if (titleInput) {
+ titleInput.id = `${groupId}_title`;
+ titleInput.name = `${groupId}[post_title]`;
+ }
+ if (excerptInput) {
+ excerptInput.id = `${groupId}_excerpt`;
+ excerptInput.name = `${groupId}[post_excerpt]`;
+ }
+ let field = this.fields.get(fieldId);
+ if (field.config.content !== '') {
+ let summary = groupElement.querySelector('summary');
+ summary.textContent = field.config.content + ' Fields';
+ }
+ } else {
+ groupElement.querySelector('details').remove();
+ }
+
+ const gridContainer = groupElement.querySelector('.item-grid.group');
+ if (gridContainer) {
+ gridContainer.dataset.groupId = groupId;
+ }
+
+ return groupElement;
+ }
+
+ deleteGroup(groupId, confirm = true) {
+ let group = this.groups.get(groupId);
+ if (!group) {
+ return;
+ }
+
+ let keepUploads = true;
+ if (confirm && group.uploads && group.uploads.size > 0) {
+ keepUploads = !window.confirm('Delete uploads in group?');
+ }
+
+ if (confirm && keepUploads) {
+ // Move any remaining uploads back to preview
+ if (group.uploads && group.uploads.size > 0) {
+ Array.from(group.uploads).forEach(uploadId => {
+ this.addImageToGroup(uploadId, null, false);
+ });
+ }
+ }
+
+ // Remove from groups Map
+ this.groups.delete(groupId);
+
+ // Remove DOM element
+ let groupElement = group.element;
+ if (groupElement) {
+ groupElement.remove();
+ this.a11y.announce('Group removed');
+ }
+
+ this.schedulePersistance(group.fieldId);
+ }
+
+ addToGroup(uploadId, target = null, persist = true) {
+ let upload = this.uploads.get(uploadId);
+ if(!upload) {
+ return;
+ }
+ let field = this.fields.get(upload.fieldId);
+ if (!field) {
+ return;
+ }
+
+ //Already in the Preview Grid, or already in the group we're moving to
+ if ((!target && upload.location === field.ui.preview) || target === upload.location) {
+ return;
+ }
+
+ // Remove from previous location
+ if (upload.location) {
+ let groupId = upload.location.dataset.groupId;
+ if (groupId) {
+ let group = this.groups.get(groupId);
+ if (group && group.uploads) {
+ group.uploads.delete(uploadId);
+
+ if (group.uploads.size === 0) {
+ this.deleteGroup(groupId);
+ }
+ }
+ }
+ }
+
+ const checkbox = upload.element.querySelector('[name*="select-item"]');
+ if (checkbox) {
+ checkbox.checked = false;
+ }
+
+ let featured = upload.element.querySelector('[name="featured"]');
+ featured.hidden = !target;
+
+
+ //If no target, it's going to the preview grid
+ if (!target) {
+ target = field.ui.preview;
+ upload.groupId = null;
+ } else if (!target.classList.contains('item-grid') || !target.classList.contains('preview')) {
+ // It's a group target
+ let groupId = target.dataset.groupId;
+ featured.name = groupId+'_'+featured.name;
+ let group = this.groups.get(groupId);
+ if (!group) {
+ group = this.createGroup(upload.fieldId);
+ target = group.grid;
+ groupId = group.id;
+ }
+ if (group) {
+ group.uploads.add(uploadId);
+ upload.groupId = groupId;
+ }
+
+ }
+
+ upload.location = target;
+ target.append(upload.element);
+
+ if (persist) {
+ this.schedulePersistance(field.id);
+ }
+ }
+
+ removeFromGroup(uploadId) {
+ const upload = this.uploads.get(uploadId);
+ if (!upload) return;
+
+ const field = this.fields.get(upload.fieldId);
+ if (!field) return;
+
+ // Remove from current group if in one
+ if (upload.groupId) {
+ const group = this.groups.get(upload.groupId);
+ if (group?.uploads) {
+ group.uploads.delete(uploadId);
+
+ // Delete empty group
+ if (group.uploads.size === 0) {
+ this.deleteGroup(upload.groupId, false);
+ }
+ }
+ upload.groupId = null;
+ }
+
+ // Move back to preview
+ if (field.ui?.preview) {
+ field.ui.preview.appendChild(upload.element);
+ upload.location = field.ui.preview;
+ }
+
+ // Hide featured radio
+ const featured = upload.element.querySelector('[name="featured"]');
+ if (featured) {
+ featured.hidden = true;
+ featured.checked = false;
+ }
+ }
+
+ removeUpload(fieldId, uploadId) {
const field = this.fields.get(fieldId);
- console.log('Got field uploads: ', field);
- if (!field?.uploads) return [];
+ const upload = this.uploads.get(uploadId);
+
+ if (!field || !upload) return;
+
+ // Remove from field
+ field.uploads?.delete(uploadId);
+
+ // Remove from group if grouped
+ if (upload.groupId) {
+ const group = this.groups.get(upload.groupId);
+ if (group && group.uploads) {
+ group.uploads.delete(uploadId);
+
+ if (group.uploads.size === 0) {
+ this.removeGroup(upload.groupId);
+ }
+ }
+ }
+
+ // Clean up element
+ upload.element?.remove();
+
+ // Clean up memory
+ this.clearUpload(uploadId);
+
+ // Update field state after removal
+ this.updateFieldState(fieldId);
+
+ // Update UI
+ this.maybeLockUploads(fieldId);
+ const handler = this.selectionHandlers.get(field.id);
+ if (handler) {
+ handler.deselect(uploadId);
+ }
+
+ this.a11y.announce('Upload removed');
+ }
+
+ /*******************************************************************************
+ * STATE MANAGEMENT
+ *******************************************************************************/
+ schedulePersistance(fieldId) {
+ const key = `persist_${fieldId}`;
+ window.debouncer.schedule(
+ key,
+ () => this.persistFieldState(fieldId),
+ 1000
+ );
+ }
+
+ async persistFieldState(fieldId) {
+ const field = this.fields.get(fieldId);
+ if (!field) return;
+
+ // Convert Sets to Arrays for storage
+ const fieldData = {
+ ...field,
+ id: fieldId, // Use as primary key
+ fieldId: fieldId,
+ uploads: Array.from(field.uploads || []).map(uploadId => {
+ return this.uploads.get(uploadId);;
+ }),
+ groups: Array.from(this.groups.entries())
+ .filter(([id, data]) => data.fieldId === fieldId && data.uploads && data.uploads.size > 0)
+ .map(([id, data]) => ({
+ id: data.id,
+ uploads: Array.from(data.uploads),
+ changes: data.changes || {}
+ })),
+
+ // Context for restoration
+ context: {
+ url: this.normalizeUrl(window.location.href),
+ fullUrl: window.location.href,
+ modalType: this.getModalType(field),
+ formId: field.formId,
+ fieldSelector: `.field.upload[data-field="${field.config.name}"]`
+ },
+ timestamp: Date.now()
+ };
+
+ // Save to store
+ await this.fieldStore.save(fieldData);
+ }
+ normalizeUrl(url) {
+ try {
+ const urlObj = new URL(url);
+ // Return just the origin + pathname (no query string or hash)
+ return urlObj.origin + urlObj.pathname;
+ } catch (e) {
+ return url;
+ }
+ }
+
+ /**
+ * Get uploads for a field, optionally cleaned for storage
+ * @param {string} fieldId
+ * @param {boolean} clean - Remove DOM references for IndexedDB storage
+ * @returns {Array}
+ */
+ getFieldUploads(fieldId, clean = false) {
+ const field = this.fields.get(fieldId);
+ if (!field || !field.uploads) return [];
return Array.from(field.uploads)
- .map(id => {
- let upload = this.uploads.get(id);
+ .map(uploadId => {
+ const upload = this.uploads.get(uploadId);
if (!upload) return null;
- if (stripElements) {
- // Create a clean copy without DOM references
- const { element, ui, ...cleanUpload } = upload;
- upload = cleanUpload;
+
+ if (clean) {
+ // Return cleaned version without DOM references or blob URLs
+ return {
+ id: upload.id,
+ fieldId: upload.fieldId,
+ status: upload.status,
+ // DON'T include preview (blob URL)
+ // DON'T include originalFile or processedFile (in blob storage)
+ attachmentId: upload.attachmentId,
+ operationId: upload.operationId,
+ groupId: upload.groupId || null,
+ changes: upload.changes || {}, // ← ADD: Include changes
+ meta: {
+ originalName: upload.meta?.originalName || upload.originalFile?.name,
+ size: upload.meta?.size || upload.originalFile?.size,
+ type: upload.meta?.type || upload.originalFile?.type,
+ title: upload.meta?.title,
+ alt: upload.meta?.alt,
+ caption: upload.meta?.caption
+ }
+ };
}
+
+ // Return full upload object
return upload;
})
.filter(Boolean);
}
- /**
- * Persist upload to DataStore
- */
- async persistFieldState(fieldId) {
- if (!this.db) return;
-
- const field = this.fields.get(fieldId);
- if (!field) return;
-
- // Create clean field config without UI references
- const { ui, container, dropZone, previewGrid, selectAll, selectActions, selectInfo, selectCount, groupDisplay, ...cleanConfig } = field;
-
- const fieldState = {
- fieldId: fieldId,
- timestamp: Date.now(),
-
- config: {
- key: cleanConfig.key,
- id: cleanConfig.id,
- name: cleanConfig.name,
- type: cleanConfig.type,
- content: cleanConfig.content,
- itemID: cleanConfig.itemID,
- context: cleanConfig.context,
- mode: cleanConfig.mode,
- maxFiles: cleanConfig.maxFiles,
- multiple: cleanConfig.multiple
- },
-
- // Recovery context
- context: {
- url: window.location.href,
- modalType: this.getModalType(field),
- formId: field.formId
- },
-
- // Uploads with their group associations (cleaned)
- uploads: this.getFieldUploads(fieldId, true),
-
- // Groups structure (ensure these are also cleaned)
- groups: Array.from(this.groups.entries())
- .filter(([id, data]) => data.fieldId === fieldId && data.uploads.size > 0)
- .map(([id, data]) => ({
- id: data.id,
- uploads: Array.from(data.uploads),
- meta: data.meta,
- changes: data.changes
- }))
- };
-
- const tx = this.db.transaction(['fieldStates'], 'readwrite');
- await tx.objectStore('fieldStates').put(fieldState);
- }
- /*******************************************************************************
- RESTORE FUNCTIONALITY
- *******************************************************************************/
- async checkPendingUploads() {
+ async checkForStoredUploads() {
if (!this.db) return;
const tx = this.db.transaction(['fieldStates'], 'readonly');
@@ -2446,12 +2366,29 @@
request.onsuccess = () => resolve(request.result);
});
+ //
+ // allFieldStates.forEach(field => {
+ // console.log(`Field ${field.fieldId} has ${field.uploads.length} uploads:`);
+ // field.uploads.forEach((upload, idx) => {
+ // console.log(` Upload ${idx}:`, {
+ // id: upload.id,
+ // status: upload.status,
+ // operationId: upload.operationId,
+ // hasOperationId: !!upload.operationId
+ // });
+ // });
+ // });
+
// Filter for pending uploads (not yet sent to server)
const pendingFields = allFieldStates.filter(field =>
field.uploads.some(upload =>
- upload.status === 'processing' ||
- upload.status === 'processed' ||
- upload.status === 'pending'
+ // If no operationId, it hasn't been sent to server yet
+ !upload.operationId &&
+ // And it's been processed locally
+ (upload.status === 'completed' ||
+ upload.status === 'processed' ||
+ upload.status === 'local_processing' ||
+ upload.status === 'processed-original')
)
);
@@ -2461,132 +2398,359 @@
this.showRecoveryNotification(pendingFields);
}
- showRecoveryNotification(pendingFields) {
- const totalUploads = pendingFields.reduce((sum, field) => sum + field.uploads.length, 0);
+ async handleRestoreUploads() {
+ let notification = document.querySelector('dialog.restore-uploads');
+ if (!notification) {
+ return;
+ }
- let notification = window.getTemplate('restoreNotification');
- [
- notification.querySelector('.restore-details').textContent,
- ] = [
- `${totalUploads} upload(s) from ${pendingFields.length} field(s) can be recovered.`
- ];
+ const selectedUploads = this.getSelectedRestorationUploads(notification);
+ if (selectedUploads.length === 0) {
+ return;
+ }
+ await this.restoreSelectedUploads(selectedUploads);
- pendingFields.forEach(field => {
- console.log(field);
- let template = window.getTemplate('restoreField');
- field.uploads.forEach(upload => {
- let uploadItem = window.getTemplate('restoreItem');
- [
- uploadItem.querySelector('img').src
- ] = [
- upload.preview
- ];
- template.append(uploadItem);
- });
- notification.append(template);
- });
-
-
- // Add event handlers
- notification.querySelector('[data-action="restore"]').addEventListener('click', () => {
- this.restoreFieldStates(pendingFields);
- notification.remove();
- });
-
- notification.querySelector('[data-action="dismiss"]').addEventListener('click', () => {
- this.notifications.add('Uploads saved for later restoration', 'info');
- notification.remove();
- });
-
- notification.querySelector('[data-action="clear"]').addEventListener('click', () => {
- this.clearCachedUploads(pendingFields);
- notification.remove();
- });
-
- document.body.appendChild(notification);
+ this.cleanupRestore();
}
- async restoreFieldStates(fieldStates) {
- // Group by URL
- const byUrl = new Map();
- fieldStates.forEach(field => {
- if (!byUrl.has(field.context.url)) {
- byUrl.set(field.context.url, []);
+ getSelectedRestorationUploads(notificationEl) {
+ let selected = [];
+ const checkboxes = notificationEl.querySelectorAll('[type=checkbox]:checked');
+
+ checkboxes.forEach(checkbox => {
+ const item = checkbox.closest('.item');
+ if (item) {
+ selected.push({
+ uploadId: item.dataset.uploadId,
+ fieldId: item.dataset.fieldId
+ });
}
- byUrl.get(field.context.url).push(field);
});
- // If all on current page, restore directly
- if (byUrl.size === 1 && byUrl.has(window.location.href)) {
- for (const fieldState of fieldStates) {
+ return selected;
+ }
+
+ handleGroupMetaChange(input) {
+ let group = this.getGroupFromElement(input);
+ if (!group) {
+ return;
+ }
+ if (!Object.hasOwn(group, 'changes')) {
+ group.changes = {};
+ }
+ let name = input.name;
+ if (name.includes('group')) {
+ let replace = group.id+'_';
+ let replace2 = group.id+'[';
+ name = name.replace(replace, '').replace(replace2,'').replace(']', '');
+ }
+ group.changes[`${name}`] = input.value;
+ this.groups.set(group.id, group);
+ this.schedulePersistance(group.fieldId);
+ }
+
+
+ /*******************************************************************************
+ * RESTORING UPLOADS
+ *******************************************************************************/
+ async showRecoveryNotification(pendingFields) {
+ const totalUploads = pendingFields.reduce((sum, field) => sum + field.uploads.length, 0);
+ const totalGroups = pendingFields.reduce((sum, field) =>
+ sum + (field.groups?.length || 0), 0);
+
+ let notification = window.getTemplate('restoreNotification');
+ if (!notification) {
+ console.error('Restore notification template not found');
+ return;
+ }
+
+ // Build appropriate message
+ let message;
+ if (totalGroups > 0) {
+ let group = totalGroups > 1 ? 'groups' : 'group';
+ let upload = totalUploads > 1 ? 'uploads' : 'upload';
+ message = `${totalGroups} ${group} with ${totalUploads} ${upload} can be restored.`;
+ } else {
+ message = `${totalUploads} upload(s) from ${pendingFields.length} field(s) can be recovered.`;
+ }
+
+ const detailsEl = notification.querySelector('.restore-details');
+ if (detailsEl) {
+ detailsEl.textContent = message;
+ }
+
+ // Build the restoration preview
+ for (const field of pendingFields) {
+ let fieldTemplate = window.getTemplate('restoreField');
+ if (!fieldTemplate) continue;
+
+ // Set field name/title
+ const titleEl = fieldTemplate.querySelector('h3');
+ if (titleEl) {
+ titleEl.textContent = field.config.name || 'Unnamed Field';
+ }
+
+ const itemGrid = fieldTemplate.querySelector('.item-grid.restore');
+
+ // Process each upload
+ for (const upload of field.uploads) {
+
+ let uploadItem = window.getTemplate('uploadItem');
+ if (!uploadItem) continue;
+ //
+ // const imgEl = uploadItem.querySelector('img');
+ // const placeholderEl = uploadItem.querySelector('.image-placeholder');
+ //
+ const blobData = await this.uploadStore.getBlob(upload.id);
+
+
+ if (blobData) {
+ try {
+ // Create new blob URL from stored data
+ const blob = new Blob([blobData.data], { type: blobData.type });
+ const previewUrl = this.createPreviewUrl(blob);
+
+ let [
+ featured,
+ img,
+ video,
+ preview,
+ details
+ ] = [
+ uploadItem.querySelector('[name="featured"]'),
+ uploadItem.querySelector('img'),
+ uploadItem.querySelector('video'),
+ uploadItem.querySelector('label > span'),
+ uploadItem.querySelector('details')
+ ];
+
+ uploadItem.dataset.uploadId = upload.id;
+
+
+ uploadItem.dataset.fieldId = field.id;
+
+ let subtype = this.getSubtypeFromMime(blobData.type);
+ uploadItem.dataset.subtype = subtype;
+ switch (subtype) {
+ case 'image':
+ [
+ img.src,
+ img.alt
+ ] = [
+ previewUrl,
+ upload.originalFile?.name ?? upload.meta?.originalName?? ''
+ ];
+ video.remove();
+ preview.remove();
+ break;
+ case 'video':
+ video.src = previewUrl;
+ img.remove();
+ preview.remove();
+ break;
+ case 'document':
+ let extension = '';
+ let icon;
+ switch (extension) {
+ case 'pdf':
+ icon = window.getIcon('file-pdf');
+ break;
+ case 'csv':
+ icon = window.getIcon('file-csv');
+ break;
+ case 'doc':
+ icon = window.getIcon('file-doc');
+ break;
+ case 'txt':
+ icon = window.getIcon('file-txt');
+ break;
+ case 'xls':
+ icon = window.getIcon('file-xls');
+ break;
+ default:
+ icon = window.getIcon('file');
+ break;
+ }
+
+ preview.innerText = upload.originalFile.name;
+ preview.prepend(icon);
+ img.remove();
+ video.remove();
+ break;
+ }
+
+ // Store URL for cleanup later
+ uploadItem.dataset.previewUrl = previewUrl;
+ } catch (error) {
+ console.warn('Failed to create preview for upload:', upload.id, error);
+ }
+ }
+
+ // Set upload metadata
+ const nameEl = uploadItem.querySelector('summary span');
+ if (nameEl) {
+ nameEl.textContent = upload.meta?.originalName || 'Unknown file';
+ }
+
+ const metaEl = uploadItem.querySelector('details');
+ if (metaEl && upload.meta) {
+ metaEl.textContent = `${this.formatBytes(upload.meta.size)} • ${upload.meta.type}`;
+ }
+
+ // Update input IDs safely
+ uploadItem.querySelectorAll('input').forEach(input => {
+ let id = input.id;
+ if (id) {
+ let newId = id + upload.id;
+ let label = input.parentNode.querySelector(`label[for="${id}"]`);
+ input.id = newId;
+ if (label) {
+ label.htmlFor = newId;
+ }
+ }
+ });
+
+ if (itemGrid) {
+ itemGrid.appendChild(uploadItem);
+ }
+ }
+
+ notification.querySelector('.wrap').appendChild(itemGrid);
+ }
+
+ document.querySelector('.field.upload').appendChild(notification);
+ notification = document.querySelector('dialog.restore-uploads');
+ this.restoreModal = new window.jvbModal(notification);
+ this.restoreSelection = new window.jvbHandleSelection({
+ container: notification,
+ ui: {
+ selectAll: notification.querySelector('#select-all-restore'),
+ count: notification.querySelector('.selection-count'),
+ },
+ });
+
+ this.restoreModal.handleOpen();
+
+ }
+
+ async restoreSelectedUploads(selectedUploads) {
+ // Group by field
+ const byField = new Map();
+ selectedUploads.forEach(item => {
+ if (!byField.has(item.fieldId)) {
+ byField.set(item.fieldId, []);
+ }
+ byField.get(item.fieldId).push(item.uploadId);
+ });
+
+ // Get full field states from IndexedDB
+ if (!this.db) {
+ // this.notifications.add('Cannot restore: Database not available', 'error');
+ return;
+ }
+
+ const tx = this.db.transaction(['fieldStates'], 'readonly');
+ const store = tx.objectStore('fieldStates');
+
+ for (const [fieldId, uploadIds] of byField.entries()) {
+ const request = store.get(fieldId);
+ const fieldState = await new Promise(resolve => {
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => resolve(null);
+ });
+
+ if (fieldState) {
+ // Filter to only selected uploads
+ fieldState.uploads = fieldState.uploads.filter(u => uploadIds.includes(u.id));
await this.restoreField(fieldState);
}
- this.notifications.add(`Restored ${fieldStates.length} field(s)`, 'success');
- } else {
- // Store intent to restore and navigate
- sessionStorage.setItem('jvb_restore_uploads', JSON.stringify(fieldStates));
-
- // Navigate to first URL
- const firstUrl = byUrl.keys().next().value;
- if (window.location.href !== firstUrl) {
- window.location.href = firstUrl;
- }
}
+
+ // this.notifications.add(`Restored ${selectedUploads.length} upload(s)`, 'success');
}
async restoreField(fieldState) {
- const { config, context, uploads, groups } = fieldState;
+ const { config, context, uploads, groups, id } = fieldState; // ← Use 'id'
// If in a modal, open it first
if (context.modalType) {
await this.openModalForRestore(context);
}
- // Find the field element
- const fieldElement = document.querySelector(
- `.field.image[data-field-id="${config.id}"]`
- );
+ // Find field element
+ let fieldElement = document.querySelector(`.field.upload[data-field="${config.name}"]`);
if (!fieldElement) {
- console.warn(`Field ${config.id} not found for restoration`);
+ const uploaderKey = `${config.content}_${config.itemID}_${config.name}`;
+ fieldElement = document.querySelector(`.field.upload[data-uploader="${uploaderKey}"]`);
+ }
+
+ if (!fieldElement) {
+ console.warn(`Field ${config.name} not found for restoration`, config);
return;
}
- // Register the field
- const fieldKey = this.registerUploader(fieldElement, config);
+ // Register the field if not already registered
+ let fieldKey = fieldElement.dataset.uploader;
+ if (!fieldKey || !this.fields.has(fieldKey)) {
+ fieldKey = this.registerUploader(fieldElement, config);
+ }
+
const field = this.fields.get(fieldKey);
+ if (!field) {
+ console.error('Failed to register field for restoration');
+ return;
+ }
+
+ // Merge saved state back into field
+ field.state = fieldState.state || 'ready';
+
+ // Rebuild UI references
+ field.ui = this.buildFieldUI(fieldElement);
+
+ if (field.ui.groups?.display) {
+ field.ui.groups.display.hidden = false;
+ }
+
+ // Restore groups
+ if (groups && groups.length > 0) {
+ await this.restoreGroups(fieldKey, groups);
+ }
// Restore uploads
for (const uploadData of uploads) {
await this.restoreUpload(field, uploadData);
}
- // Restore groups
- if (groups && groups.length > 0) {
- await this.restoreGroups(field, groups, uploads);
- }
-
// Update UI
+ this.updateFieldState(fieldKey);
this.maybeLockUploads(fieldKey);
// Queue for upload if needed
- if (config.mode === 'direct') {
+ if (config.mode === 'direct' && config.destination !== 'post_group') {
await this.queueUpload(fieldKey);
}
}
async restoreUpload(field, uploadData) {
- // Reconstruct the file from blob data
- const blobData = await this.getBlobData(uploadData.id);
- let file = null;
+ // Try to get blob data from IndexedDB
+ const blobData = await this.uploadStore.getBlob(uploadData.id);
if (blobData) {
- file = new File(
- [blobData.data],
- blobData.name,
- { type: blobData.type, lastModified: blobData.lastModified }
- );
+ const file = blobData.data instanceof File
+ ? blobData.data
+ : new File(
+ [blobData.data],
+ blobData.name,
+ { type: blobData.type, lastModified: blobData.lastModified }
+ );
+
+ uploadData.originalFile = file;
uploadData.processedFile = file;
+ uploadData.preview = this.createPreviewUrl(file);
+ } else {
+ console.warn('Blob data not found for upload:', uploadData.id);
+ return; // Skip this upload if we can't restore the file
}
// Add to field
@@ -2594,61 +2758,59 @@
field.uploads.add(uploadData.id);
// Recreate DOM element
- uploadData.element = this.createImageElement(uploadData, field.type === 'groupable');
+ const subtype = this.getSubtypeFromMime(uploadData.originalFile.type);
+ uploadData.element = this.createUploadElement({
+ ...uploadData,
+ subtype: subtype
+ }, field.config.destination === 'post_group');
// Restore to correct location
- const location = uploadData.groupId
- ? field.ui.groups.groups.get(uploadData.groupId)
- : field.ui.field.preview;
+ let location;
+ if (uploadData.groupId && field.ui.groups.groups.has(uploadData.groupId)) {
+ location = field.ui.groups.groups.get(uploadData.groupId).querySelector('.item-grid');
+ } else {
+ location = field.ui.preview;
+ }
if (location) {
- location.append(uploadData.element);
+ location.appendChild(uploadData.element);
uploadData.location = location;
}
// Store in memory
this.uploads.set(uploadData.id, uploadData);
- }
-
- async restoreGroups(field, groups, uploads) {
- for (const groupData of groups) {
- // Create group element
- const groupElement = this.createGroupElement(groupData.id, field.key);
- field.ui.groups.groups.set(groupData.id, groupElement);
- field.ui.groups.container.insertBefore(groupElement, field.ui.groups.empty);
-
- // Create group Set
- const groupSet = new Set(groupData.uploadIds);
- this.groups.set(groupData.id, groupSet);
-
- // Restore group metadata
- if (groupData.meta) {
- this.groupsMeta.set(groupData.id, groupData.meta);
- // TODO: Populate meta fields in groupElement
+ if (uploadData.groupId) {
+ const group = this.groups.get(uploadData.groupId);
+ if (group && group.uploads) {
+ group.uploads.add(uploadData.id);
}
-
- // Move uploads to group
- groupData.uploadIds.forEach(uploadId => {
- const upload = uploads.find(u => u.id === uploadId);
- if (upload && upload.element) {
- groupElement.querySelector('.item-grid').append(upload.element);
- upload.location = groupElement.querySelector('.item-grid');
- upload.groupId = groupData.id;
- }
- });
}
}
- async getBlobData(uploadId) {
- if (!this.db) return null;
+ async restoreGroups(fieldKey, groups) {
+ for (const groupData of groups) {
+ // Use createGroup which properly initializes EVERYTHING including selection handlers
+ const group = this.createGroup(fieldKey, groupData.id);
- const tx = this.db.transaction(['uploadBlobs'], 'readonly');
- const request = tx.objectStore('uploadBlobs').get(uploadId);
+ if (group) {
+ // Update the group metadata from saved state
+ if (groupData.meta) {
+ group.meta = { ...groupData.meta };
+ }
+ if (groupData.changes) {
+ group.changes = { ...groupData.changes };
+ }
- return new Promise(resolve => {
- request.onsuccess = () => resolve(request.result);
- request.onerror = () => resolve(null);
- });
+
+ // If you saved group titles, restore them
+ if (groupData.title) {
+ const titleInput = group.element.querySelector('[name*="post_title"]');
+ if (titleInput) {
+ titleInput.value = groupData.title;
+ }
+ }
+ }
+ }
}
async openModalForRestore(context) {
@@ -2678,370 +2840,68 @@
}
}
- async clearCachedUploads(fieldStates) {
- if (!this.db) return;
-
- const tx = this.db.transaction(['fieldStates', 'uploadBlobs'], 'readwrite');
-
- for (const field of fieldStates) {
- // Delete field state
- await tx.objectStore('fieldStates').delete(field.fieldId);
-
- // Delete all associated blobs
- for (const upload of field.uploads) {
- await tx.objectStore('uploadBlobs').delete(upload.id);
-
- // Clean up preview URLs
- if (upload.preview?.startsWith('blob:')) {
- URL.revokeObjectURL(upload.preview);
- }
- }
- }
-
- this.notifications.add('Cached uploads cleared', 'info');
- }
-
-// Check for restoration intent on page load
- async checkRestorationIntent() {
- const restoreData = sessionStorage.getItem('jvb_restore_uploads');
- if (!restoreData) return;
-
- const fieldStates = JSON.parse(restoreData);
- const currentUrlFields = fieldStates.filter(f => f.context.url === window.location.href);
-
- if (currentUrlFields.length > 0) {
- for (const fieldState of currentUrlFields) {
- await this.restoreField(fieldState);
- }
-
- // Remove restored fields from session storage
- const remaining = fieldStates.filter(f => f.context.url !== window.location.href);
- if (remaining.length > 0) {
- sessionStorage.setItem('jvb_restore_uploads', JSON.stringify(remaining));
- } else {
- sessionStorage.removeItem('jvb_restore_uploads');
- }
-
- this.notifications.add(`Restored ${currentUrlFields.length} field(s)`, 'success');
- }
- }
/*******************************************************************************
- GROUP FUNCTIONALITY
- Includes selection, dragging, and grouping logic
- *******************************************************************************/
- /**
- *
- * @param {string} uploadId as defined by setUpload
- * @param {HTMLElement|null} target The target location
- * @param {boolean} persist whethet to cache this change
- */
- addImageToGroup(uploadId, target = null, persist = true) {
- let upload = this.getUpload(uploadId);
- if(!upload) {
- return;
- }
- let field = this.fields.get(upload.fieldId);
- if (!field) {
- return;
- }
- //Already in the Preview Grid, or already in the group we're moving to
- if (!target && upload.location === field.ui.field.preview || target === upload.location) {
- return;
- }
+ INDEXEDDB CACHE FUNCTIONALITY
+ *******************************************************************************/
+ handleFieldStoreEvent(event, data) {
+ switch(event) {
+ case 'data-loaded':
- if (upload.location) {
- let groupId = upload.location.dataset.groupId;
- if (groupId) {
- let group = this.groups.get(groupId);
- if (group) {
- group.delete(uploadId);
- if (group.size === 0) {
- this.removeGroup(groupId);
- }
- }
- }
+ break;
+ case 'item-saved':
+ console.log(`Field state saved: ${data.key}`);
+ break;
}
+ }
- upload.element.querySelector('[name="featured"]').hidden = !target;
- //If no target, it's going to the preview grid
- if (!target) {
- target = field.ui.field.preview;
+ handleUploadStoreEvent(event, data) {
+ switch(event) {
+ case 'data-loaded':
+ this.checkForStoredUploads();
+ break;
+ case 'item-saved':
+ this.showSaveIndicator(data.key);
+ break;
+ }
+ }
+ async saveUpload(upload) {
+ // Use the processed file if available, otherwise original
+ const fileToStore = upload.processedFile || upload.originalFile || upload.file;
+
+ if (fileToStore instanceof File || fileToStore instanceof Blob) {
+ await this.uploadStore.saveBlob(upload.id, fileToStore);
+
+ // Don't store file objects in main store
+ const { file, originalFile, processedFile, ...cleanUpload } = upload;
+ await this.uploadStore.save(cleanUpload);
} else {
- let groupId = target.dataset.groupId;
- let group = this.groups.get(groupId);
- if (!group) {
- group = this.createGroup(upload.fieldId);
+ await this.uploadStore.save(upload);
+ }
+ }
+
+ async loadFields() {
+ // Load all field states from the store
+ const fields = await this.fieldStore.getAll();
+
+ fields.forEach(field => {
+ // Reconstruct upload sets
+ if (field.uploads && Array.isArray(field.uploads)) {
+ field.uploads = new Set(field.uploads.map(u => u.id));
}
- group.uploads.add(uploadId);
- }
-
-
- target.append(upload.element);
- if (persist) {
- this.persistFieldState(field.key);
- }
- }
-
- addSelectionToGroup(target) {
- let field = this.getFieldFromElement(target);
- if (!field) {
- return;
- }
- if (this.selected.get(field.key).size === 0) {
- return;
- }
- let group = this.getGroupFromElement(target);
- if (!group) {
- group = this.createGroup(field.key);
- }
-
- Array.from(this.selected).forEach(uploadId => {
- this.addImageToGroup(uploadId, group.grid, false);
- });
-
- this.persistFieldState(group.fieldId);
- }
-
-
- /**
- * Remove an empty group from the field
- * @param {string} groupId - The group to remove
- * @param {boolean} confirm - ask for confirmation
- */
- removeGroup(groupId, confirm = false) {
- let group = this.groups.get(groupId);
- if (!group) {
- return;
- }
- if (confirm) {
- if(!window.confirm('This will delete this group. Any uploads in this group will return to the main grid. Are you sure?')){
- return;
- }
- }
-
- if (group.uploads.size > 0) {
- Array.from(group.uploads).forEach(upload => {
- this.addImageToGroup(upload);
- });
- }
-
- let groupElement = group.element;
- // Remove DOM element
- if (groupElement) {
- window.fade(groupElement, false);
- this.a11y.announce('Empty group removed');
- }
-
- this.persistFieldState(group.fieldId);
- }
-
- createGroup(fieldId) {
- let field = this.fields.get(fieldId);
- if(!field) {
- return;
- }
- let index = field.ui.groups.size;
- field.ui.groups.groups.set(`group-${index}`, this.createGroupElement(`group-${index}`, fieldId));
- let group = field.ui.groups.groups.get(`group-${index}`);
- field.ui.groups.container.insertAfter(group, field.ui.groups.empty);
- let groupConfig = {
- fieldId: field.key,
- id: `group-${index}`,
- element: group,
- grid: group.querySelector('.item-grid'),
- uploads: new Set(),
- meta: {
- post_title: '',
- post_excerpt: '',
- },
- changes: {},
- };
- this.groups.set(`group-${index}`, groupConfig);
- return groupConfig;
- }
-
- createGroupElement(groupId, fieldId) {
- let post = window.getTemplate('imageGroup');
- if (!post) {
- return;
- }
- post.dataset.groupId = groupId;
- post.dataset.fieldId = fieldId;
- let fields = window.getTemplate('groupMetaData');
- post.querySelector('.fields')?.append(fields);
-
- return post;
- }
-
- /**
- * Handle select all functionality
- */
- handleSelectAll(element, checked = null) {
- const field = this.getFieldFromElement(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;
- }
-
- updateSelection(e) {
- let field = this.getFieldFromElement(e.target);
- let upload = this.getUploadFromElement(e.target);
- if (!field || ! upload) {
- console.log('No field or upload found...');
- return;
- }
-
- this.lastClickedUpload = upload.id;
- let action = e.target.checked;
- if (action) {
- this.selected.get(field.key).add(upload.id);
- } else {
- this.selected.get(field.key).delete(upload.id);
- }
- }
-
- updateSelectAll(element) {
- const field = this.getFieldFromElement(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) {
- let field = this.getFieldFromElement(element);
- if (!field) {
- return;
- }
- return Array.from(this.selected.get(field.key)??[]);
- }
-
-
- handleRangeSelection(currentElement, event) {
- const field = this.getFieldFromElement(currentElement);
- if (!field) return;
-
- const currentUploadId = this.getUploadIdFromElement(currentElement);
- if (!currentUploadId || !this.lastClickedUpload) return;
-
- // Get all upload items in the preview grid
- const container = currentElement.closest('.item-grid');
- const allItems = Array.from(container.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.getFieldIdFromElement(button);
-
- const selectedUploads = this.getSelectedUploads(button);
- if (selectedUploads.length === 0) {
- this.notify('No uploads selected', 'warning');
- return;
- }
-
- selectedUploads.forEach(upload => {
- this.removeUpload(fieldId, upload);
+ this.fields.set(field.fieldId, field);
});
}
- removeUpload(fieldId, uploadId) {
- const field = this.fields.get(fieldId);
- const upload = this.uploads.get(uploadId);
-
- if (!field || !upload) return;
-
- // Remove from field
- field.uploads?.delete(uploadId);
-
- // Remove from group if grouped
- if (upload.groupId) {
- const group = this.groups.get(upload.groupId);
- group?.delete(uploadId);
- }
-
- // Clean up element
- upload.element?.remove();
-
- // Clean up memory
- this.clearUpload(uploadId);
-
- // Update UI
- this.maybeLockUploads(fieldId);
- this.updateSelectAll(field.ui.field.field);
-
- this.a11y.announce('Upload removed');
+ async loadUploads() {
+ const uploads = await this.uploadStore.getAll();
+ uploads.forEach(upload => {
+ this.uploads.set(upload.id, upload);
+ });
}
/**************************************************************************
- META
- Handled separately, in case it is edited in the middle of processing images
- **************************************************************************/
-
- /**************************************************************************
SUBSCRIBERS
- **************************************************************************/
+ **************************************************************************/
/**
* Event system
*/
@@ -3053,35 +2913,111 @@
notify(event, data) {
this.subscribers.forEach(cb => cb(event, data));
}
+ /*******************************************************************************
+ * CLEANUP
+ *******************************************************************************/
- handleBeforeUnload(e) {
- // Check for any uploads in processing or pending state
- const unsavedUploads = Array.from(this.uploads.values()).filter(upload =>
- upload.status === 'processing' ||
- upload.status === 'pending' ||
- upload.status === 'uploading'
- );
+ destroy() {
+ // Remove core listeners
+ document.removeEventListener('click', this.clickHandler);
+ document.removeEventListener('change', this.changeHandler);
+ document.removeEventListener('dragenter', this.dragEnterHandler);
+ document.removeEventListener('dragleave', this.dragLeaveHandler);
+ document.removeEventListener('dragover', this.dragOverHandler);
+ document.removeEventListener('drop', this.dropHandler);
- if (unsavedUploads.length > 0) {
- const message = 'You have uploads in progress. Are you sure you want to leave?';
- e.preventDefault();
- e.returnValue = message;
- return message;
+ // Destroy drag controller
+ if (this.dragController) {
+ this.dragController.destroy();
}
- }
- /**************************************************************************
- CLEANUP
- **************************************************************************/
- cleanup() {
- this.clearListeners();
- if (this.hasGroups) {
- this.clearGroupListeners();
- }
- this.compressionWorker = null;
+
+ // Destroy selection handlers
+ this.selectionHandlers.forEach(handler => handler.destroy());
+ this.selectionHandlers.clear();
+
+ this.cleanupAllPreviewUrls();
+
+ // Clear data
+ this.fields.clear();
+ this.uploads.clear();
+ this.groups.clear();
+ this.selected.clear();
this.subscribers.clear();
}
+
+ cleanupRestore() {
+ this.restoreModal.handleClose();
+ this.restoreSelection.destroy();
+ this.restoreSelection = null;
+ this.restoreModal.destroy();
+ this.restoreModal.modal.remove();
+ this.restoreModal = null;
+ }
+
+ async cleanupStoredUploads() {
+ this.fieldStore.clear();
+ this.uploadStore.clear();
+ }
+
+ /**
+ * Clear all uploads for a field and cleanup resources
+ */
+ async clearField(fieldId) {
+ // Clear from stores
+ await this.fieldStore.delete(fieldId);
+
+ // Clear related uploads
+ const field = this.fields.get(fieldId);
+ if (field?.uploads) {
+ for (const uploadId of field.uploads) {
+ await this.uploadStore.delete(uploadId);
+ }
+ }
+
+ // Clear from memory
+ this.fields.delete(fieldId);
+ }
+
+ async clearUpload(uploadId, persist = true) {
+ const upload = this.uploads.get(uploadId);
+ if (!upload) return;
+
+ // Clean up preview URL using helper
+ this.revokePreviewUrl(upload.preview);
+
+ // Clean up element preview URL
+ if (upload.element) {
+ const previewUrl = upload.element.dataset.previewUrl;
+ this.revokePreviewUrl(previewUrl);
+ delete upload.element.dataset.previewUrl;
+ }
+
+ if (persist) {
+ await this.schedulePersistance(upload.fieldId);
+ }
+
+ // Remove from memory
+ this.uploads.delete(uploadId);
+
+ // Remove from IndexedDB
+ this.uploadStore.delete(uploadId);
+ this.uploadStore.delete(uploadId, 'blobs');
+ }
+ cleanupAllPreviewUrls() {
+ if (this.previewUrls) {
+ this.previewUrls.forEach(url => {
+ try {
+ URL.revokeObjectURL(url);
+ } catch (e) {
+ // Ignore errors during cleanup
+ }
+ });
+ this.previewUrls.clear();
+ }
+ }
}
+// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
window.jvbUploads = new UploadManager();
});
--
Gitblit v1.10.0