From d7dbe7fee362d587dfc334135d9581b6216a4295 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sun, 23 Nov 2025 04:13:56 +0000
Subject: [PATCH] =Timeline block, and feed block updated. DataStore.js refactored to not block rendering
---
assets/js/concise/UploadManager.js | 3920 ++++++++++++++++++++++++++++-------------------------------
1 files changed, 1,871 insertions(+), 2,049 deletions(-)
diff --git a/assets/js/concise/UploadManager.js b/assets/js/concise/UploadManager.js
index b59101c..59a0d5f 100644
--- a/assets/js/concise/UploadManager.js
+++ b/assets/js/concise/UploadManager.js
@@ -1,42 +1,51 @@
+/**
+ * UploadManager - Refactored for clarity
+ *
+ * Architecture:
+ * - DataStores (fieldStore, uploadStore) = Recovery cache only, cleared after successful upload
+ * - Maps (uploadElements, fieldElements) = Runtime DOM references
+ * - Upload data flows: File → Process → Queue → Server → Clean up stores
+ */
class UploadManager {
constructor() {
- //Load dependencies
+ // Load dependencies
this.queue = window.jvbQueue;
this.a11y = window.jvbA11y;
this.error = window.jvbError;
- //Load Datastore
- this.fieldStore = new window.jvbStore({
- name: 'upload_fields',
- storeName: 'fieldStates',
- keyPath: 'id',
- version: 2,
+ // RECOVERY STORES - Cleared after successful upload
+ this.fieldStore = window.jvbStore.register(
+ 'upload_fields',
+ {
+ storeName: 'fieldStates',
+ keyPath: 'id',
+ version: 2,
+ indexes: [
+ { name: 'fieldId', keyPath: 'fieldId' },
+ { name: 'timestamp', keyPath: 'timestamp' },
+ { name: 'content', keyPath: 'content' },
+ { name: 'itemId', keyPath: 'itemId' },
+ { name: 'status', keyPath: 'status' }
+ ],
+ TTL: 86400000 * 7, // 1 week
+ delayFetch: true
+ });
- 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' }
- ],
- });
+ this.uploadStore = window.jvbStore.register(
+ 'uploads',
+ {
+ 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' }
+ ],
+ delayFetch: true
+ });
window.jvbUploadBlobs = this.uploadStore;
@@ -44,20 +53,24 @@
this.fieldStore.subscribe(this.handleFieldStoreEvent.bind(this));
this.uploadStore.subscribe(this.handleUploadStoreEvent.bind(this));
- //Load Worker
- this.initWorker();
+ // RUNTIME DATA - DOM references and ephemeral state
+ this.uploadElements = new Map(); // uploadId → { element, preview, location }
+ this.fieldElements = new Map(); // fieldId → { element, ui, config }
+ this.groupElements = new Map(); // groupId → { element, grid, fieldId }
- // Core data structures
- this.fields = new Map();
- this.uploads = new Map();
- this.groups = new Map();
+ // Selection and UI state
this.selected = new Map();
this.selectionHandlers = new Map();
this.previewUrls = new Set();
- //Notification and Subscribers
+ this.sortableInstances = new Map();
+
+ // Worker for image processing
+ this.initWorker();
+
+ // Notification subscribers
this.subscribers = new Set();
- // Controllers (will be initialized based on features)
+ // Controllers
this.dragController = null;
// Selectors
@@ -65,7 +78,6 @@
field: {
field: '[data-upload-field]',
input: 'input[type="file"]',
- hiddenValue: 'input[type="hidden"]',
dropZone: '.file-upload-container',
preview: '.item-grid.preview',
progress: '.image-progress'
@@ -86,7 +98,6 @@
}
};
-
this.statusMapping = {
'received': 'Image Received',
'local_processing': 'Processing Image...',
@@ -100,66 +111,48 @@
};
// Sortable configuration
- this.sortableInstances = new Map();
this.sortableConfig = {
animation: 150,
draggable: '.item',
- handle: '.select-item-label, img', // Can drag by image or checkbox label
+ handle: '.select-item, img',
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
dragClass: 'sortable-drag',
- onEnd: (evt) => {
- this.handleReorder(evt);
- }
+ onEnd: (evt) => this.handleReorder(evt)
};
this.init();
}
async init() {
- // Load existing data
- await this.loadFields();
- await this.loadUploads();
- // Initialize fields
this.initializeFields();
-
- // Set up core listeners
this.initListeners();
+ // Queue integration - handle completion/failure
this.queue.subscribe((event, operation) => {
- if (operation.endpoint !== 'uploads' && operation.endpoint !== 'uploads/meta') {
+ if (!['uploads', 'uploads/meta', 'uploads/groups'].includes(operation.endpoint)) {
return;
}
+
const fieldId = operation.data instanceof FormData
? operation.data.get('fieldId')
- : operation.data.fieldId;
+ : operation.data?.fieldId;
+
switch(event) {
case 'cancel-operation':
- if (fieldId) {
- this.clearField(fieldId);
- }
+ if (fieldId) this.handleOperationCancelled(fieldId);
break;
case 'operation-status':
- if (fieldId) {
- this.updateFieldStatus(fieldId, operation.status);
- }
+ if (fieldId) 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);
- }
+ this.handleOperationComplete(operation, fieldId);
+ break;
+ case 'operation-failed':
+ case 'operation-failed-permanent':
+ this.handleOperationFailed(operation, fieldId);
break;
}
-
});
window.addEventListener('beforeunload', () => {
@@ -172,12 +165,9 @@
worker: null,
timeout: null,
tasks: new Map(),
- restart: {
- count: 0,
- max: 3,
- },
+ restart: { count: 0, max: 3 },
settings: {
- timeout: 10000, //10 seconds per image
+ timeout: 10000,
batchSize: 1,
maxConcurrent: 3,
restartAfterTimeout: true
@@ -185,71 +175,68 @@
};
}
- /**
- * Initialize all upload fields on the page
- */
+ /*******************************************************************************
+ * FIELD MANAGEMENT
+ *******************************************************************************/
+
initializeFields() {
const fields = document.querySelectorAll(this.selectors.field.field);
- fields.forEach(uploader => {
- this.registerUploader(uploader);
- });
+ fields.forEach(uploader => this.registerUploader(uploader));
}
scanFields(container) {
const fields = container.querySelectorAll(this.selectors.field.field);
- fields.forEach(uploader => {
- this.registerUploader(uploader);
- });
+ fields.forEach(uploader => this.registerUploader(uploader));
}
registerUploader(uploader) {
const fieldId = this.determineFieldId(uploader);
const config = this.extractFieldConfig(uploader);
+ const ui = this.buildFieldUI(uploader);
- // Create field data structure
- const field = {
+ // Store field data with Sets for runtime
+ const fieldData = {
id: fieldId,
config: config,
- element: uploader,
- ui: this.buildFieldUI(uploader),
uploads: new Set(),
- groups: new Set(),
+ groups: [],
state: 'ready',
+ timestamp: Date.now()
};
- this.fields.set(fieldId, field);
+ // Save to store (will convert Sets to Arrays automatically)
+ this.fieldStore.save(fieldData);
+
+ // Store DOM references separately
+ this.fieldElements.set(fieldId, { element: uploader, ui, config });
+
uploader.dataset.uploader = fieldId;
this.addFieldSelectionHandler(fieldId);
if (config.destination === 'post_group' && !this.dragController) {
this.initGroupFeatures();
}
+
if (config.type !== 'single') {
- this.initSortable(field);
+ this.initSortable(fieldId);
}
return fieldId;
}
- /**
- * Extract configuration from field element
- */
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
+ name: fieldElement.dataset.field,
+ itemID: fieldElement.dataset.itemId || 0,
maxFiles: parseInt(fieldElement.dataset.maxFiles) || 999,
subtype: fieldElement.dataset.subtype || 'image'
};
}
- /**
- * Build UI element references for a field
- */
buildFieldUI(fieldElement) {
let UI = {
field: fieldElement,
@@ -279,92 +266,129 @@
return UI;
}
- /**
- * Set up core event listeners
- */
- initListeners() {
- this.clickHandler = this.handleClick.bind(this);
- this.changeHandler = this.handleChange.bind(this);
+ /*******************************************************************************
+ * SORTABLE INITIALIZATION
+ *******************************************************************************/
- document.addEventListener('click', this.clickHandler);
- document.addEventListener('change', this.changeHandler);
+ initSortable(fieldId) {
+ if (!window.Sortable) return;
- // 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);
+ const fieldEl = this.fieldElements.get(fieldId);
+ if (!fieldEl) return;
- document.addEventListener('dragenter', this.dragEnterHandler);
- document.addEventListener('dragleave', this.dragLeaveHandler);
- document.addEventListener('dragover', this.dragOverHandler);
- document.addEventListener('drop', this.dropHandler);
+ // Find all sortable grids (preview + group grids, but not restore grids)
+ const grids = fieldEl.element.querySelectorAll('.item-grid.preview, .item-grid.group');
+
+ grids.forEach((grid) => {
+ // Skip if already initialized
+ if (grid.sortableInstance) return;
+
+ const isGroupGrid = grid.classList.contains('group');
+ const gridId = isGroupGrid
+ ? `${fieldId}-group-${grid.closest('.upload-group')?.dataset.groupId}`
+ : `${fieldId}-preview`;
+
+ const sortableInstance = new Sortable(grid, {
+ ...this.sortableConfig,
+ group: {
+ name: fieldId,
+ pull: true,
+ put: true
+ },
+
+ onAdd: (evt) => {
+ // Re-enable when items added
+ this.updateSortableState(evt.to);
+ },
+ onRemove: (evt) => {
+ // Disable source if now empty
+ this.updateSortableState(evt.from);
+ }
+ });
+
+ // Store reference on element for easy access
+ grid.sortableInstance = sortableInstance;
+ this.sortableInstances.set(gridId, sortableInstance);
+ });
}
/**
- * Initialize group-specific features (drag & drop for rearranging)
+ * Update sortable enabled/disabled state based on item count
*/
+ updateSortableState(grid) {
+ const sortable = grid?.sortableInstance;
+ if (!sortable) return;
+
+ const hasItems = grid.querySelectorAll('.item').length > 0;
+ sortable.option('disabled', !hasItems);
+ }
+
+ /**
+ * Refresh sortable for a field (call after adding/removing items dynamically)
+ */
+ refreshSortable(fieldId) {
+ const fieldEl = this.fieldElements.get(fieldId);
+ if (!fieldEl) return;
+
+ const grids = fieldEl.element.querySelectorAll('.item-grid.preview, .item-grid.group');
+ grids.forEach(grid => this.updateSortableState(grid));
+ }
+
+ handleReorder(evt) {
+ const grid = evt.to;
+ const fieldWrapper = grid.closest('.field, .upload');
+ if (!fieldWrapper) return;
+
+ let hiddenInput = fieldWrapper.querySelector('input[type="hidden"]');
+ let items = Array.from(fieldWrapper.querySelectorAll('.item')).map(upload => upload.dataset.id);
+ console.log(items);
+ hiddenInput.value = items.join(',');
+
+ if (window.jvbA11y) {
+ window.jvbA11y.announce('Item reordered');
+ }
+
+ fieldWrapper.dispatchEvent(new CustomEvent('jvb-items-reordered', {
+ detail: { from: evt.from, to: evt.to, oldIndex: evt.oldIndex, newIndex: evt.newIndex },
+ bubbles: true
+ }));
+ }
+
+ /*******************************************************************************
+ * DRAG & DROP INITIALIZATION
+ *******************************************************************************/
+
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
+ getItemId: (element) => element.dataset.uploadId,
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];
+ return (selected && selected.includes(uploadId)) ? selected : [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 },
@@ -373,93 +397,38 @@
});
}
- initSortable(field) {
- if (!window.Sortable) return;
-
- // Main grid
- const mainGrid = field.element.querySelector('.item-grid:not(.group)');
- if (mainGrid) {
- this.sortableInstances.set(`${field.id}-main`,
- new Sortable(mainGrid, {
- ...this.sortableConfig,
- group: {
- name: field.id,
- pull: true,
- put: true
- }
- })
- );
- }
-
- // Group grids (for selection mode with grouping)
- const groupGrids = field.element.querySelectorAll('.item-grid.group');
- groupGrids.forEach((grid, index) => {
- this.sortableInstances.set(`${field.id}-group-${index}`,
- new Sortable(grid, {
- ...this.sortableConfig,
- group: {
- name: field.id,
- pull: true,
- put: true
- }
- })
- );
- });
- }
-
-// Add reorder handler
- handleReorder(evt) {
- const grid = evt.to;
- const fieldWrapper = grid.closest('.field, .upload');
- if (!fieldWrapper) return;
-
- const form = fieldWrapper.closest('form');
- if (!form) return;
-
- // Get form config if available
- const formId = form.dataset.formId;
- if (formId && window.jvbForms) {
- const formConfig = window.jvbForms.forms?.get(formId);
- if (formConfig?.options.autosave) {
- // Trigger autosave after reordering
- window.jvbForms.scheduleSave(formConfig, 1000);
- }
- }
-
- // Announce for accessibility
- if (window.jvbA11y) {
- window.jvbA11y.announce('Item reordered');
- }
-
- // Trigger custom event
- fieldWrapper.dispatchEvent(new CustomEvent('jvb-items-reordered', {
- detail: {
- from: evt.from,
- to: evt.to,
- oldIndex: evt.oldIndex,
- newIndex: evt.newIndex
- },
- bubbles: true
- }));
- }
-
/*******************************************************************************
- * EXTERNAL FILE DROP HANDLERS (for new uploads from desktop)
+ * FILE DROP HANDLERS
*******************************************************************************/
+ initListeners() {
+ this.clickHandler = this.handleClick.bind(this);
+ this.changeHandler = this.handleChange.bind(this);
+
+ document.addEventListener('click', this.clickHandler);
+ document.addEventListener('change', this.changeHandler);
+
+ this.dragEnterHandler = this.handleExternalDragEnter.bind(this);
+ this.dragLeaveHandler = this.handleExternalDragLeave.bind(this);
+ this.dragOverHandler = this.handleExternalDragOver.bind(this);
+ this.dropHandler = this.handleExternalDrop.bind(this);
+
+ document.addEventListener('dragenter', this.dragEnterHandler);
+ document.addEventListener('dragleave', this.dragLeaveHandler);
+ document.addEventListener('dragover', this.dragOverHandler);
+ document.addEventListener('drop', this.dropHandler);
+ }
+
handleExternalDragLeave(e) {
const dropZone = e.target.closest(this.selectors.field.dropZone);
if (dropZone && !dropZone.contains(e.relatedTarget)) {
dropZone.classList.remove('dragover');
}
}
+
handleExternalDragEnter(e) {
- if (!e.dataTransfer.types.includes('Files')) {
- return;
- }
-
+ if (!e.dataTransfer.types.includes('Files')) return;
const dropZone = e.target.closest(this.selectors.field.dropZone);
-
if (dropZone) {
e.preventDefault();
dropZone.classList.add('dragover');
@@ -468,7 +437,6 @@
handleExternalDragOver(e) {
if (!e.dataTransfer.types.includes('Files')) return;
-
const dropZone = e.target.closest(this.selectors.field.dropZone);
if (dropZone) {
e.preventDefault();
@@ -478,33 +446,25 @@
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');
}
}
/*******************************************************************************
- * ITEM DROP HANDLER (for rearranging existing uploads)
+ * ITEM DROP HANDLER (for rearranging)
*******************************************************************************/
- /**
- * Handle items being dropped (called by DragController)
- */
handleItemDrop(itemIds, targetElement) {
const isPreviewDrop = targetElement.classList.contains('preview');
let actualTarget = targetElement;
@@ -513,43 +473,32 @@
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;
- }
-
+ if (!group) return;
actualTarget = group.grid;
}
- // Move each item to target
+ // Move each item
itemIds.forEach(uploadId => {
if (isPreviewDrop) {
- // Moving back to preview (ungrouping)
this.removeFromGroup(uploadId);
} else {
- // Moving to a group
this.addToGroup(uploadId, actualTarget);
}
});
- // Persist state
const fieldId = this.getFieldIdFromElement(targetElement);
this.schedulePersistance(fieldId);
- // Announce for accessibility
- const message = itemIds.length > 1
- ? `Moved ${itemIds.length} items`
- : 'Moved item';
+ const message = itemIds.length > 1 ? `Moved ${itemIds.length} items` : 'Moved item';
this.a11y.announce(message);
}
/*******************************************************************************
- * CLICK HANDLERS
+ * CLICK & CHANGE HANDLERS
*******************************************************************************/
handleClick(e) {
- // File input triggers
+ // Trigger file input
if (e.target.matches(this.selectors.field.dropZone) ||
e.target.closest(this.selectors.field.dropZone)) {
const dropZone = e.target.closest(this.selectors.field.dropZone);
@@ -568,11 +517,10 @@
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);
}
@@ -580,7 +528,8 @@
// Meta field changes
if (fieldId) {
- if (this.fields.get(fieldId).config.destination === 'post_group') {
+ const fieldData = this.getFieldData(fieldId);
+ if (fieldData?.config.destination === 'post_group') {
this.handleGroupMetaChange(e.target);
} else {
this.queueUploadMeta(e);
@@ -588,363 +537,39 @@
}
}
- /********************************************************************************
- 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) {
- return this.statusMapping[status] || status;
- }
-
- getStatusIcon(status) {
- return window.getIcon(this.queue.icons[status]);
- }
- getStatusProgress(status) {
- switch (status) {
- case 'local_processing':
- return 28;
- case 'queued':
- return 50;
- case 'uploading':
- return 66;
- case 'pending':
- return 75;
- case 'processing':
- return 89;
- case 'completed':
- return 100;
- default:
- return 0;
- }
- }
-
- getModalType(field) {
- // Return cached value if available
- if (field._cachedModalType !== undefined) {
- return field._cachedModalType;
- }
-
- // Safety check for field.element
- if (!field || !field.element) {
- field._cachedModalType = null;
- return null;
- }
-
- const dialog = field.element.closest('dialog');
- if (!dialog) {
- field._cachedModalType = null;
- return null;
- }
-
- 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;
-
- // Cache the result
- field._cachedModalType = modalType;
- return modalType;
- }
- /*******************************************************************************
- * GROUP ACTIONS
- *******************************************************************************/
-
- 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;
-
- const container = field.ui.field;
- if (!container) 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);
- }
-
- 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;
+ async processFiles(fieldId, files) {
+ const fieldData = this.getFieldData(fieldId);
+ const fieldEl = this.fieldElements.get(fieldId);
+ if (!fieldData || !fieldEl) return;
+
+ // Show group display, hide upload zone
+ if (fieldEl.ui.dropZone) {
+ fieldEl.ui.dropZone.hidden = true;
}
- if (field.ui.groups.display) {
- field.ui.groups.display.hidden = false;
+ if (fieldEl.ui.groups?.display) {
+ fieldEl.ui.groups.display.hidden = false;
}
const totalFiles = files.length;
let processedCount = 0;
- // Show initial progress
this.updateUploadProgress(fieldId, 0, totalFiles, 'Processing files...');
- // Initialize field uploads set if needed
- if (!field.uploads) {
- field.uploads = new Set();
- }
-
- // Process files
- const processPromises = Array.from(files).map(async (file, index) => {
+ const processPromises = Array.from(files).map(async (file) => {
try {
-
- // Create upload ID
const uploadId = `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
- // Create upload data
+ // Create upload data (without blob initially)
const uploadData = {
id: uploadId,
- attachment_id: null,
+ attachmentId: null,
fieldId: fieldId,
- originalFile: file,
- processedFile: null,
- preview: null,
status: 'local_processing',
- element: null,
- location: null,
+ groupId: null,
meta: {
originalName: file.name,
size: file.size,
@@ -952,50 +577,55 @@
}
};
- // Create preview URL
- uploadData.preview = this.createPreviewUrl(file);
+ // Process file
+ const preview = this.createPreviewUrl(file);
+ const processedFile = file.type.startsWith('image/')
+ ? await this.processImage(file, fieldData.config.subtype)
+ : 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);
+ // Store blob data as ArrayBuffer
+ await this.saveBlobData(uploadId, processedFile || file);
// Create DOM element
const subtype = this.getSubtypeFromMime(file.type);
- uploadData.element = this.createUploadElement({
- ...uploadData,
+ const element = this.createUploadElement({
+ id: uploadId,
+ preview: preview,
+ meta: uploadData.meta,
subtype: subtype
- }, field.config.destination === 'post_group');
+ }, fieldData.config.destination === 'post_group');
- // Show progress on the item
+ // Show progress
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;
+ if (fieldEl.ui.preview) {
+ fieldEl.ui.preview.appendChild(element);
+
+ // Store runtime element data
+ this.uploadElements.set(uploadId, {
+ element: element,
+ preview: preview,
+ location: fieldEl.ui.preview
+ });
}
- // Store upload
- this.uploads.set(uploadId, uploadData);
- field.uploads.add(uploadId);
+ // Store persistent data
+ uploadData.status = 'processed';
+ await this.uploadStore.save(uploadData);
+
+ // Add to field
+ fieldData.uploads.add(uploadId);
+ await this.saveFieldData(fieldData);
// Update progress
processedCount++;
this.updateUploadProgress(fieldId, processedCount, totalFiles, 'Processing files...');
this.updateUploadItemProgress(uploadId, 100, 'processed');
- uploadData.status = 'processed';
- // Fade out item progress after a moment
- setTimeout(() => {
- this.showUploadProgress(uploadId, false);
- }, 1000);
+ // Fade out progress
+ setTimeout(() => this.showUploadProgress(uploadId, false), 1000);
return uploadId;
@@ -1007,192 +637,21 @@
}
});
- // Wait for all files to process
await Promise.all(processPromises);
this.updateFieldState(fieldId);
- // Cache the state (now without DOM references)
- await this.schedulePersistance(fieldId);
+ this.refreshSortable(fieldId);
// Queue for upload if in direct mode
- if (field.config.destination !== 'post_group') {
+ if (fieldData.config.destination !== 'post_group') {
await this.queueUpload(fieldId);
- // Lock uploads if max reached
this.maybeLockUploads(fieldId);
}
-
}
- updateFieldState(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field || !field.ui.field) 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' : ''}`
- );
- }
- }
-
- 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;
-
- upload.status = status;
- this.updateUploadUI(uploadId);
- }
-
- updateUploadUI(uploadId) {
- const upload = this.uploads.get(uploadId);
- if (!upload?.element) return;
-
- // Update status classes
- upload.element.className = upload.element.className.replace(/status-[\w-]+/g, '');
- upload.element.classList.add(`status-${upload.status}`);
-
- // Update progress if showing
- const progress = upload.element.querySelector('.progress');
- if (progress) {
- this.updateUploadItemProgress(uploadId,
- this.getStatusProgress(upload.status),
- upload.status
- );
- }
- }
-
- /**
- * Show/hide progress indicator on individual upload items
- */
- showUploadProgress(uploadId, show = true) {
- const upload = this.uploads.get(uploadId);
- if (!upload || !upload.element) return;
-
- const progressEl = upload.element.querySelector('.progress');
- if (progressEl) {
- if (show) {
- progressEl.style.removeProperty('animation');
- progressEl.hidden = false;
- } else {
- progressEl.style.animation = 'fadeOut var(--transition-base)';
- setTimeout(() => {
- progressEl.hidden = true;
- }, 300);
- }
- }
- }
-
- /**
- * Update individual upload progress bar
- */
- updateUploadItemProgress(uploadId, percent, status = null) {
- const upload = this.uploads.get(uploadId);
- if (!upload || !upload.element) return;
-
- const progressEl = upload.element.querySelector('.progress');
- if (!progressEl) return;
-
- const fill = progressEl.querySelector('.fill');
- const details = progressEl.querySelector('.details');
- const icon = progressEl.querySelector('.icon');
-
- if (fill) {
- fill.style.width = `${percent}%`;
- }
-
- if (status && details) {
- details.textContent = this.getStatusText(status);
- }
-
- if (status && icon) {
- icon.innerHTML = this.getStatusIcon(status).outerHTML;
- }
- }
- checkFieldLimits(fieldId, additionalFiles) {
- const field = this.fields.get(fieldId);
- if (!field) return false;
-
- const currentCount = field.uploads?.size || 0;
- const totalCount = currentCount + additionalFiles;
-
- return totalCount <= field.maxFiles;
-
-
- }
- validateFile(file, field) {
- // Type validation
- if (!this.settings.allowedTypes.includes(file.type)) {
- this.notify(`Invalid file type: ${file.type}`, 'error');
- return false;
- }
-
- // Size validation
- if (file.size > this.settings.maxFileSize) {
- this.notify(`File too large: ${this.formatBytes(file.size)}`, 'error');
- return false;
- }
-
- return true;
- }
-
- formatBytes(bytes, decimals = 2) {
- if (bytes === 0) return '0 Bytes';
-
- const k = 1024;
- const dm = decimals < 0 ? 0 : decimals;
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
-
- const i = Math.floor(Math.log(bytes) / Math.log(k));
-
- return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
- }
-
- shouldProcessClientSide(file, subtype) {
- // Only process images client-side
- if (subtype === 'image' && file.type.startsWith('image/')) {
- return true;
- }
-
- // Videos and documents go straight to server
- return false;
- }
+ /*******************************************************************************
+ * IMAGE PROCESSING (simplified - keeping existing logic)
+ *******************************************************************************/
async processImage(file, uploadId) {
const timeout = this.worker.settings.timeout;
@@ -1201,27 +660,19 @@
let timeoutId;
let taskCompleted = false;
- // Set timeout
timeoutId = setTimeout(() => {
if (!taskCompleted) {
taskCompleted = true;
-
- // Remove from active tasks
this.worker.tasks.delete(uploadId);
-
- // Maybe restart worker if configured
if (this.worker.settings.restartAfterTimeout) {
this.restartCompressionWorker();
}
-
reject(new Error(`Processing timeout for ${file.name}`));
}
}, timeout);
- // Track this task
this.worker.tasks.set(uploadId, { file, timeoutId });
- // Process image
this.handleProcess(file, uploadId)
.then(result => {
if (!taskCompleted) {
@@ -1243,7 +694,6 @@
}
async handleProcess(file, uploadId) {
- // Skip non-images
if (!file.type.startsWith('image/')) {
return file;
}
@@ -1251,14 +701,11 @@
const maxDimension = this.getMaxDimension();
const quality = 0.85;
- // Try worker first if available
if (this.shouldUseWorker(file)) {
try {
- // Ensure worker is initialized
if (!this.worker.worker) {
this.initCompressionWorker();
}
-
if (this.worker.worker) {
return await this.processWithWorker(file, uploadId, maxDimension, quality);
}
@@ -1267,13 +714,9 @@
}
}
- // Fallback to main thread
return await this.processOnMainThread(file, maxDimension, quality);
}
- /**
- * Process image on main thread with better error handling
- */
async processOnMainThread(file, maxDimension, quality) {
return new Promise((resolve, reject) => {
const img = new Image();
@@ -1288,7 +731,6 @@
URL.revokeObjectURL(objectUrl);
objectUrl = null;
}
- // Explicitly clean up canvas
canvas.width = 1;
canvas.height = 1;
ctx.clearRect(0, 0, 1, 1);
@@ -1300,7 +742,6 @@
canvas.width = width;
canvas.height = height;
- // Enhanced image smoothing
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, width, height);
@@ -1347,67 +788,41 @@
});
}
- /**
- * Get optimal output format
- */
getOptimalFormat(file) {
- // Keep original format for certain types
if (file.type === 'image/gif' || file.type === 'image/svg+xml') {
return file.type;
}
-
- // Use WebP if supported, otherwise JPEG
return this.supportsWebP() ? 'image/webp' : 'image/jpeg';
}
- /**
- * Get optimal quality setting
- */
getOptimalQuality(file, requestedQuality) {
- // Higher quality for smaller files
if (file.size < 500 * 1024) return Math.max(requestedQuality, 0.9);
if (file.size < 2 * 1024 * 1024) return requestedQuality;
-
- // Lower quality for very large files
return Math.min(requestedQuality, 0.8);
}
- /**
- * Generate processed file name
- */
getProcessedFileName(originalFile, outputFormat) {
const baseName = originalFile.name.replace(/\.[^/.]+$/, '');
-
const extensions = {
'image/webp': '.webp',
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif'
};
-
return baseName + (extensions[outputFormat] || '.jpg');
}
- /**
- * Get maximum dimension based on device capabilities
- */
getMaxDimension() {
const screenWidth = window.screen.width;
const devicePixelRatio = window.devicePixelRatio || 1;
-
- // Scale based on device capabilities
if (screenWidth * devicePixelRatio > 2560) return 2400;
if (screenWidth * devicePixelRatio > 1920) return 1920;
return 1200;
}
- /**
- * Determine if we should use Web Worker
- */
shouldUseWorker(file) {
- // Use worker for large files or when available
return this.worker.worker &&
- file.size > 1024 * 1024 && // > 1MB
+ file.size > 1024 * 1024 &&
typeof OffscreenCanvas !== 'undefined';
}
@@ -1418,14 +833,11 @@
return;
}
- // Create unique message ID for this task
const messageId = `${uploadId}_${Date.now()}`;
- // Handler for this specific message
const messageHandler = (e) => {
if (e.data.messageId !== messageId) return;
- // Remove handler
this.worker.worker.removeEventListener('message', messageHandler);
this.worker.worker.removeEventListener('error', errorHandler);
@@ -1447,11 +859,9 @@
reject(new Error(`Worker error: ${error.message}`));
};
- // Add handlers
this.worker.worker.addEventListener('message', messageHandler);
this.worker.worker.addEventListener('error', errorHandler);
- // Send message to worker
this.worker.worker.postMessage({
messageId,
file,
@@ -1462,85 +872,45 @@
});
}
- /**
- * Restart compression worker
- */
restartCompressionWorker() {
- // Terminate existing worker
if (this.worker.worker) {
this.worker.worker.terminate();
this.worker.worker = null;
}
-
- // Clear active tasks
this.worker.tasks.clear();
-
- // Check restart limit
if (this.worker.restart.count >= this.worker.restart.max) {
console.error('Max worker restarts reached, disabling worker');
return;
}
-
this.worker.restart.count++;
-
- // Reinitialize
this.initCompressionWorker();
}
- /**
- * Initialize Web Worker for image compression
- */
initCompressionWorker() {
if (this.worker.worker || typeof Worker === 'undefined') return;
try {
const workerScript = `
- self.onmessage = async function(e) {
- const { messageId, file, maxDimension, quality, outputFormat } = e.data;
-
- try {
- // Create ImageBitmap from file
- const bitmap = await createImageBitmap(file);
-
- // Calculate dimensions
- const scale = Math.min(maxDimension / bitmap.width, maxDimension / bitmap.height, 1);
- const width = Math.round(bitmap.width * scale);
- const height = Math.round(bitmap.height * scale);
-
- // Create OffscreenCanvas
- const canvas = new OffscreenCanvas(width, height);
- const ctx = canvas.getContext('2d');
-
- // Draw and resize
- ctx.imageSmoothingEnabled = true;
- ctx.imageSmoothingQuality = 'high';
- ctx.drawImage(bitmap, 0, 0, width, height);
-
- // Clean up bitmap
- bitmap.close();
-
- // Convert to blob
- const blob = await canvas.convertToBlob({
- type: outputFormat,
- quality: quality
- });
-
- self.postMessage({
- messageId,
- success: true,
- blob: blob,
- format: outputFormat
- });
-
- } catch (error) {
- self.postMessage({
- messageId,
- success: false,
- error: error.message
- });
- }
- };
- `;
+ self.onmessage = async function(e) {
+ const { messageId, file, maxDimension, quality, outputFormat } = e.data;
+ try {
+ const bitmap = await createImageBitmap(file);
+ const scale = Math.min(maxDimension / bitmap.width, maxDimension / bitmap.height, 1);
+ const width = Math.round(bitmap.width * scale);
+ const height = Math.round(bitmap.height * scale);
+ const canvas = new OffscreenCanvas(width, height);
+ const ctx = canvas.getContext('2d');
+ ctx.imageSmoothingEnabled = true;
+ ctx.imageSmoothingQuality = 'high';
+ ctx.drawImage(bitmap, 0, 0, width, height);
+ bitmap.close();
+ const blob = await canvas.convertToBlob({ type: outputFormat, quality: quality });
+ self.postMessage({ messageId, success: true, blob: blob, format: outputFormat });
+ } catch (error) {
+ self.postMessage({ messageId, success: false, error: error.message });
+ }
+ };
+ `;
const blob = new Blob([workerScript], { type: 'application/javascript' });
this.worker.worker = new Worker(this.createPreviewUrl(blob));
@@ -1551,30 +921,18 @@
}
}
- /**
- * Calculate optimal dimensions with aspect ratio preservation
- */
calculateOptimalDimensions(img, maxDimension) {
let { width, height } = img;
-
- // Don't upscale
if (width <= maxDimension && height <= maxDimension) {
return { width, height };
}
-
- // Calculate scale factor
const scale = Math.min(maxDimension / width, maxDimension / height);
-
return {
width: Math.round(width * scale),
height: Math.round(height * scale)
};
}
-
- /**
- * Check WebP support
- */
supportsWebP() {
const canvas = document.createElement('canvas');
return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0;
@@ -1582,7 +940,6 @@
createPreviewUrl(file) {
const url = URL.createObjectURL(file);
- // Track for cleanup
if (!this.previewUrls) this.previewUrls = new Set();
this.previewUrls.add(url);
return url;
@@ -1595,131 +952,19 @@
}
}
- maybeLockUploads(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field?.ui?.dropZone) return;
-
- 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);
- }
- 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')
- ];
- [
- featured.value,
- img.src,
- img.alt
- ] = [
- upload.id,
- upload.preview,
- upload.originalFile?.name ?? upload.meta?.originalName ?? '',
- ];
-
- switch (image.dataset.subtype) {
- case 'image':
- [
- img.src,
- img.alt
- ] = [
- upload.preview,
- upload.originalFile?.name ?? upload.meta?.originalName?? ''
- ];
- video.remove();
- preview.remove();
- break;
- case 'video':
- video.src = upload.preview;
- img.remove();
- preview.remove();
- break;
- case 'document':
- const fileName = upload.originalFile?.name ?? upload.meta?.originalName ?? '';
- const extension = fileName.split('.').pop()?.toLowerCase() ?? '';
- 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){
- details.append(template);
- }
- }
- image.draggable = draggable;
-
- // Update input IDs safely
- image.querySelectorAll('input').forEach(input => {
- let id = input.id;
- if (id) {
- let newId = id + upload.id;
- let label = input.parentNode.querySelector(`label[for="${id}"]`);
- input.id = newId;
- if (label) {
- label.htmlFor = newId;
- }
- }
- });
-
- return image;
- }
/*******************************************************************************
* QUEUE INTEGRATION
*******************************************************************************/
+
async submitUploads(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field?.uploads || field.uploads.size === 0) {
+ const fieldData = this.getFieldData(fieldId);
+ const fieldEl = this.fieldElements.get(fieldId);
+ if (!fieldData?.uploads || fieldData.uploads.size === 0) {
return;
}
- let uploads = Array.from(field.uploads);
- if (uploads.length === 0) {
+ let uploadIds = Array.from(fieldData.uploads);
+ if (uploadIds.length === 0) {
this.error.log('No uploads to upload', {
component: 'UploadManager',
action: 'submitGroupedUploads',
@@ -1744,108 +989,88 @@
const formData = new FormData();
let uploadMap = [];
- uploads = uploads.map((upload) => {
- return this.uploads.get(upload);
- });
-
- fieldGroups.forEach((group, groupIndex) => {
+ // Process each group
+ for (const group of fieldGroups) {
const post = {
images: [],
fields: {}
};
+
+ // Add group metadata
for (let [name, value] of Object.entries(group.changes)) {
post.fields[name] = value;
}
- let groupUploads = uploads.filter((upload) => {
- return upload['groupId'] === group.id;
+ // Get uploads for this group
+ const groupUploadIds = uploadIds.filter(uploadId => {
+ const upload = this.uploadStore.get(uploadId);
+ 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);
+ // Add files for this group
+ for (const uploadId of groupUploadIds) {
+ const file = await this.getBlobData(uploadId);
+ if (file) {
+ formData.append('files[]', file);
const imageData = {
- upload_id: upload.id,
+ upload_id: uploadId,
index: uploadMap.length
};
+
+ // Check if featured
+ const uploadEl = this.uploadElements.get(uploadId);
+ const radioInput = uploadEl?.element?.querySelector('[name="featured"]');
+ if (radioInput?.checked) {
+ post.fields.featured = uploadId;
+ }
+
post.images.push(imageData);
- uploadMap.push(upload.id);
+ uploadMap.push(uploadId);
}
- posts.push(post);
}
+
+ posts.push(post);
+ }
+
+ // Handle remaining uploads (without groupId) - each becomes its own post
+ const remainingUploadIds = uploadIds.filter(uploadId => {
+ const upload = this.uploadStore.get(uploadId);
+ return !upload?.groupId;
});
+ for (const uploadId of remainingUploadIds) {
+ const post = {
+ images: [],
+ fields: {}
+ };
+
+ const file = await this.getBlobData(uploadId);
+ if (file) {
+ formData.append('files[]', file);
+
+ const imageData = {
+ upload_id: uploadId,
+ index: uploadMap.length
+ };
+ post.images.push(imageData);
+ uploadMap.push(uploadId);
+ }
+
+ posts.push(post);
+ }
// Add metadata to FormData
- formData.append('content', field.config.content);
- formData.append('user', field.config.itemID); // Assuming itemID is user ID
+ formData.append('content', fieldData.config.content);
+ formData.append('user', fieldData.config.itemID);
formData.append('posts', JSON.stringify(posts));
formData.append('upload_ids', JSON.stringify(uploadMap));
- 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...`,
+ title: `Creating ${posts.length} ${fieldData.config.content}${posts.length > 1 ? 's' : ''} from uploads...`,
popup: `Creating ${posts.length} post${posts.length > 1 ? 's' : ''}...`,
canMerge: false,
headers: {
@@ -1857,15 +1082,20 @@
try {
const operationId = await this.queue.addToQueue(operation);
- uploads.forEach(uploadId => {
- let upload = this.uploads.get(uploadId);
+ // Update upload statuses
+ uploadIds.forEach(uploadId => {
+ const upload = this.uploadStore.get(uploadId);
if (upload) {
upload.operationId = operationId;
+ upload.status = 'queued';
+ this.uploadStore.save(upload);
this.updateUploadStatus(uploadId, 'queued');
}
});
- field.operationId = operationId;
+ fieldData.operationId = operationId;
+ await this.saveFieldData(fieldData);
+
this.a11y.announce(`Creating ${posts.length} post${posts.length > 1 ? 's' : ''} from your uploads`);
return operationId;
@@ -1876,181 +1106,107 @@
fieldId: fieldId
});
throw error;
- } finally {
- this.schedulePersistance(field.id);
}
}
async queueUpload(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field?.uploads) return;
+ const fieldData = this.getFieldData(fieldId);
+ if (!fieldData?.uploads || fieldData.uploads.size === 0) return;
- const uploads = Array.from(field.uploads);
- if (uploads.length === 0) {
- return;
- }
+ const uploads = Array.from(fieldData.uploads);
+ const data = this.prepareUploadData(fieldData, uploads);
- 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}...`,
+ title: `Uploading ${uploads.length} file${uploads.length > 1 ? 's' : ''} to server...`,
+ popup: `Uploading ${uploads.length} file${uploads.length > 1 ? 's' : ''}...`,
canMerge: false,
- headers: {
- 'action_nonce': jvbSettings.dash
- },
+ headers: { 'action_nonce': jvbSettings.dash },
append: '_upload'
- }
+ };
+
try {
const operationId = await this.queue.addToQueue(operation);
+ // Update upload statuses
uploads.forEach(uploadId => {
- let upload = this.uploads.get(uploadId);
- if (!upload) {
- return;
+ const upload = this.uploadStore.get(uploadId);
+ if (upload) {
+ upload.operationId = operationId;
+ upload.status = 'queued';
+ this.uploadStore.save(upload);
+ this.updateUploadStatus(uploadId, 'queued');
}
- upload.operationId = operationId;
- this.updateUploadStatus(uploadId, 'queued');
});
- field.operationId = operationId;
+
+ fieldData.operationId = operationId;
+ await this.saveFieldData(fieldData);
return operationId;
} catch (error) {
throw error;
- } finally {
- this.schedulePersistance(field.id);
}
}
- prepareUploadData(field, uploads) {
-
+ async prepareUploadData(fieldData, 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
+ formData.append('content', fieldData.config.content);
+ formData.append('mode', fieldData.config.mode);
+ formData.append('field_name', fieldData.config.name);
+ formData.append('fieldId', fieldData.id);
+ formData.append('field_type', fieldData.config.type);
+ formData.append('subtype', fieldData.config.subtype);
+ formData.append('item_id', fieldData.config.itemID);
+ formData.append('destination', fieldData.config.destination || 'meta');
+
let uploadMap = [];
- const 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;
+ const blobPromises = uploads.map(async (uploadId) => {
+ const upload = this.uploadStore.get(uploadId);
+ if (!upload) return;
- 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
- });
+ const file = await this.getBlobData(uploadId);
+ if (file) {
+ formData.append('files[]', file);
+ uploadMap.push(upload.id);
}
});
- return groups;
+ await Promise.all(blobPromises);
+
+ formData.append('upload_ids', JSON.stringify(uploadMap));
+ return formData;
}
async queueUploadMeta(e) {
- const upload = this.getUploadFromElement(e.target);
+ const uploadId = this.getUploadIdFromElement(e.target);
+ const upload = this.uploadStore.get(uploadId);
if (!upload) return;
- const field = this.fields.get(upload.fieldId);
- if (!field) return;
-
- const container = e.target.closest('.upload-meta');
- if (!container) return;
+ const fieldData = this.getFieldData(upload.fieldId);
+ if (!fieldData) return;
let data = {};
data[e.target.name] = e.target.value;
- upload.meta = {
- ...upload.meta,
- ... data
- };
+ upload.meta = { ...upload.meta, ...data };
+ await this.uploadStore.save(upload);
let queueData = {};
- //If there is an attachment ID, use that: else, use our generated upload id
- queueData[upload.attachmentId??upload.id] = upload.meta;
+ 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
- }
+ endpoint: 'uploads/meta',
+ method: 'POST',
+ data: queueData,
+ title: 'Updating meta',
+ canMerge: true,
+ headers: { 'action_nonce': jvbSettings.dash }
};
try {
@@ -2063,441 +1219,109 @@
});
}
}
+
/*******************************************************************************
- * GROUP MANAGEMENT
+ * QUEUE EVENT HANDLERS - CLEANUP AFTER SUCCESS
*******************************************************************************/
- createGroup(fieldKey, groupId = null) {
- const field = this.fields.get(fieldKey);
- if (!field) {
- console.error('Field not found:', fieldKey);
- return null;
- }
+ /**
+ * Handle successful operation completion - CLEAR STORES
+ */
+ async handleOperationComplete(operation, fieldId) {
+ const results = operation.result?.data || operation.serverData?.data || [];
- if (!groupId) {
- groupId = `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
- }
-
- const groupElement = this.createGroupElement(groupId, fieldKey);
- if (!groupElement) {
- console.error('Failed to create group element');
- return null;
- }
-
- // Store in field UI Map
- if (!field.ui.groups) {
- field.ui.groups = {
- groups: new Map(),
- container: null,
- empty: null,
- display: null
- };
- }
-
- field.ui.groups.groups.set(groupId, groupElement);
-
- // Insert into DOM
- if (field.ui.groups.container && field.ui.groups.empty) {
- field.ui.groups.container.insertBefore(groupElement, field.ui.groups.empty);
- } else if (field.ui.groups.container) {
- field.ui.groups.container.appendChild(groupElement);
- }
-
- // Create group object
- const group = {
- id: groupId,
- fieldId: fieldKey,
- element: groupElement,
- grid: groupElement.querySelector('.item-grid.group'),
- uploads: new Set(),
- changes: {}
- };
-
- // Store group
- this.groups.set(groupId, group);
-
- // Initialize selection handler for this group
- this.addGroupSelectionHandler(fieldKey, groupId);
-
- // Persist state
- this.schedulePersistance(fieldKey);
-
- return group;
- }
-
- 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]`;
+ // Update upload statuses with attachment IDs
+ results.forEach(result => {
+ const upload = this.uploadStore.get(result.upload_id);
+ if (upload) {
+ upload.attachmentId = result.attachment_id;
+ upload.status = 'completed';
+ this.uploadStore.save(upload);
+ this.updateUploadStatus(result.upload_id, 'completed');
}
- if (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';
- }
+ });
+
+ if (!fieldId) return;
+
+ const fieldData = this.getFieldData(fieldId);
+ if (!fieldData) return;
+
+ // Clean up completed uploads from stores
+ const completedUploads = Array.from(fieldData.uploads).filter(uploadId => {
+ const upload = this.uploadStore.get(uploadId);
+ return upload?.status === 'completed';
+ });
+
+ for (const uploadId of completedUploads) {
+ await this.clearUpload(uploadId, false);
+ fieldData.uploads.delete(uploadId);
+ }
+
+ // If all uploads complete, clear entire field from stores
+ if (fieldData.uploads.size === 0) {
+ await this.clearFieldFromStores(fieldId);
+ this.a11y.announce('All uploads completed successfully');
} else {
- groupElement.querySelector('details').remove();
+ // Otherwise just update field state
+ await this.saveFieldData(fieldData);
}
- 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);
- 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
- );
- }
+ /**
+ * Handle operation failure
+ */
+ handleOperationFailed(operation, fieldId) {
+ const uploadIds = operation.data instanceof FormData
+ ? JSON.parse(operation.data.get('upload_ids') || '[]')
+ : operation.data.upload_ids || [];
- async persistFieldState(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field) return;
+ uploadIds.forEach(uploadId => {
+ const upload = this.uploadStore.get(uploadId);
+ if (upload) {
+ upload.status = operation.status === 'operation-failed-permanent'
+ ? 'failed_permanent'
+ : 'failed';
+ this.uploadStore.save(upload);
+ this.updateUploadStatus(uploadId, upload.status);
+ }
+ });
- // 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;
+ if (fieldId) {
+ this.updateFieldState(fieldId);
}
}
/**
- * Get uploads for a field, optionally cleaned for storage
- * @param {string} fieldId
- * @param {boolean} clean - Remove DOM references for IndexedDB storage
- * @returns {Array}
+ * Handle operation cancellation
*/
- getFieldUploads(fieldId, clean = false) {
- const field = this.fields.get(fieldId);
- if (!field || !field.uploads) return [];
+ async handleOperationCancelled(fieldId) {
+ const fieldData = this.getFieldData(fieldId); // ✅
+ if (!fieldData) return;
- return Array.from(field.uploads)
- .map(uploadId => {
- const upload = this.uploads.get(uploadId);
- if (!upload) return null;
+ const uploadsArray = fieldData.uploads instanceof Set
+ ? Array.from(fieldData.uploads)
+ : fieldData.uploads;
- 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);
- }
-
- async checkForStoredUploads() {
- if (!this.db) return;
-
- const tx = this.db.transaction(['fieldStates'], 'readonly');
- const fieldStore = tx.objectStore('fieldStates');
-
- const allFieldStates = await new Promise(resolve => {
- const request = fieldStore.getAll();
- request.onsuccess = () => resolve(request.result);
- });
-
- //
- // allFieldStates.forEach(field => {
- // console.log(`Field ${field.fieldId} has ${field.uploads.length} uploads:`);
- // field.uploads.forEach((upload, idx) => {
- // console.log(` Upload ${idx}:`, {
- // id: upload.id,
- // status: upload.status,
- // operationId: upload.operationId,
- // hasOperationId: !!upload.operationId
- // });
- // });
- // });
-
- // Filter for pending uploads (not yet sent to server)
- const pendingFields = allFieldStates.filter(field =>
- field.uploads.some(upload =>
- // If no operationId, it hasn't been sent to server yet
- !upload.operationId &&
- // And it's been processed locally
- (upload.status === 'completed' ||
- upload.status === 'processed' ||
- upload.status === 'local_processing' ||
- upload.status === 'processed-original')
- )
- );
-
- if (pendingFields.length === 0) return;
-
- // Show recovery notification
- this.showRecoveryNotification(pendingFields);
- }
-
- async handleRestoreUploads() {
- let notification = document.querySelector('dialog.restore-uploads');
- if (!notification) {
- return;
+ for (const uploadId of uploadsArray) {
+ await this.clearUpload(uploadId, false);
}
- const selectedUploads = this.getSelectedRestorationUploads(notification);
- if (selectedUploads.length === 0) {
- return;
- }
- await this.restoreSelectedUploads(selectedUploads);
+ await this.clearFieldFromStores(fieldId);
+ this.updateFieldState(fieldId);
+ this.a11y.announce('Upload cancelled');
+ }
- this.cleanupRestore();
+ getFieldGroups(fieldId) {
+ const fieldData = this.getFieldData(fieldId);
+ if (!fieldData?.groups) return [];
+
+ return fieldData.groups.map(group => ({
+ id: group.id,
+ uploads: group.uploads || [],
+ changes: group.changes || {}
+ }));
}
getSelectedRestorationUploads(notificationEl) {
@@ -2517,29 +1341,1347 @@
return selected;
}
- handleGroupMetaChange(input) {
- let group = this.getGroupFromElement(input);
- if (!group) {
- return;
+ async restoreSelectedUploads(selectedUploads) {
+ const byField = new Map();
+ selectedUploads.forEach(item => {
+ if (!byField.has(item.fieldId)) {
+ byField.set(item.fieldId, []);
+ }
+ byField.get(item.fieldId).push(item.uploadId);
+ });
+
+ for (const [fieldId, uploadIds] of byField.entries()) {
+ const fieldState = this.fieldStore.get(fieldId);
+ if (fieldState) {
+ fieldState.uploads = uploadIds;
+ await this.restoreField(fieldState);
+ }
}
- 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);
}
+ async restoreField(fieldState) {
+ const { config, context, uploads, groups, id } = fieldState;
+
+ // If in a modal, open it first
+ if (context?.modalType) {
+ await this.openModalForRestore(context);
+ }
+
+ // Find field element
+ let fieldElement = document.querySelector(`.field.upload[data-field="${config.name}"]`);
+
+ if (!fieldElement) {
+ const uploaderKey = `${config.content}_${config.itemID}_${config.name}`;
+ fieldElement = document.querySelector(`.field.upload[data-uploader="${uploaderKey}"]`);
+ }
+
+ if (!fieldElement) {
+ console.warn(`Field ${config.name} not found for restoration`, config);
+ return;
+ }
+
+ // Register the field if not already registered
+ let fieldKey = fieldElement.dataset.uploader;
+ if (!fieldKey || !this.fieldElements.has(fieldKey)) {
+ fieldKey = this.registerUploader(fieldElement);
+ }
+
+ const fieldEl = this.fieldElements.get(fieldKey);
+ const fieldData = this.getFieldData(fieldKey);
+
+ if (!fieldEl || !fieldData) {
+ console.error('Failed to register field for restoration');
+ return;
+ }
+
+ // Merge saved state back into field
+ fieldData.state = fieldState.state || 'ready';
+
+ // Rebuild UI references if needed
+ if (!fieldEl.ui) {
+ fieldEl.ui = this.buildFieldUI(fieldElement);
+ }
+
+ if (fieldEl.ui.groups?.display) {
+ fieldEl.ui.groups.display.hidden = false;
+ }
+ if (fieldEl.ui.dropZone) {
+ fieldEl.ui.dropZone.hidden = true;
+ }
+
+ // Restore groups first
+ if (groups && groups.length > 0) {
+ await this.restoreGroups(fieldKey, groups);
+ }
+
+ // Handle both Array and Set for uploads
+ const uploadsArray = uploads instanceof Set
+ ? Array.from(uploads)
+ : Array.isArray(uploads)
+ ? uploads
+ : [];
+
+ // Restore uploads
+ for (const uploadId of uploadsArray) {
+ // Get upload data from store
+ const uploadData = this.uploadStore.get(uploadId);
+ if (uploadData) {
+ await this.restoreUpload(fieldKey, uploadData);
+ }
+ }
+
+ // Update field state
+ await this.saveFieldData(fieldData);
+ this.updateFieldState(fieldKey);
+ this.maybeLockUploads(fieldKey);
+ this.refreshSortable(fieldKey);
+
+ // Queue for upload if needed
+ if (config.mode === 'direct' && config.destination !== 'post_group') {
+ await this.queueUpload(fieldKey);
+ }
+ }
+
+ async restoreUpload(fieldId, uploadData) {
+ const fieldEl = this.fieldElements.get(fieldId);
+ const fieldData = this.getFieldData(fieldId);
+
+ if (!fieldEl || !fieldData) {
+ console.error('Field not found for upload restoration:', fieldId);
+ return;
+ }
+
+ // Get reconstructed File from blob data
+ const file = await this.getBlobData(uploadData.id);
+
+ if (!file) {
+ console.warn('Blob data not found for upload:', uploadData.id);
+ return;
+ }
+
+ // Create preview URL
+ const previewUrl = this.createPreviewUrl(file);
+
+ // Recreate DOM element
+ const subtype = this.getSubtypeFromMime(file.type);
+ const element = this.createUploadElement({
+ id: uploadData.id,
+ preview: previewUrl,
+ meta: uploadData.meta || {
+ originalName: file.name,
+ size: file.size,
+ type: file.type
+ },
+ subtype: subtype
+ }, fieldData.config.destination === 'post_group');
+
+ // Determine correct location
+ let location;
+ if (uploadData.groupId) {
+ // Check if group exists
+ const groupEl = this.groupElements.get(uploadData.groupId);
+ if (groupEl?.grid) {
+ location = groupEl.grid;
+
+ // Add to group's upload list
+ const group = fieldData.groups?.find(g => g.id === uploadData.groupId);
+ if (group) {
+ if (!group.uploads) group.uploads = [];
+ if (!group.uploads.includes(uploadData.id)) {
+ group.uploads.push(uploadData.id);
+ }
+ }
+ } else {
+ // Group doesn't exist, add to preview
+ location = fieldEl.ui.preview;
+ uploadData.groupId = null;
+ }
+ } else {
+ // No group, add to preview
+ location = fieldEl.ui.preview;
+ }
+
+ // Add element to DOM
+ if (location) {
+ location.appendChild(element);
+ } else if (fieldEl.ui.preview) {
+ fieldEl.ui.preview.appendChild(element);
+ location = fieldEl.ui.preview;
+ }
+
+ // Store runtime element data
+ this.uploadElements.set(uploadData.id, {
+ element: element,
+ preview: previewUrl,
+ location: location
+ });
+
+ // Add to field uploads
+ if (!fieldData.uploads) fieldData.uploads = new Set();
+ fieldData.uploads.add(uploadData.id);
+
+ // Update upload data in store
+ uploadData.status = 'processed';
+ await this.uploadStore.save(uploadData);
+
+ // Update sortable state for the grid
+ if (location) {
+ this.updateSortableState(location);
+ }
+ }
+
+ async restoreGroups(fieldId, groups) {
+ const fieldEl = this.fieldElements.get(fieldId);
+ const fieldData = this.getFieldData(fieldId);
+
+ if (!fieldEl || !fieldData) {
+ console.error('Field not found for group restoration:', fieldId);
+ return;
+ }
+
+ for (const groupData of groups) {
+ // Create group using existing method which handles all initialization
+ const group = this.createGroup(fieldId, groupData.id);
+
+ if (!group) {
+ console.warn('Failed to create group:', groupData.id);
+ continue;
+ }
+
+ // Find the group in fieldData (createGroup already added it)
+ const storedGroup = fieldData.groups?.find(g => g.id === groupData.id);
+
+ if (storedGroup) {
+ // Restore metadata
+ if (groupData.changes) {
+ storedGroup.changes = { ...groupData.changes };
+ }
+
+ if (groupData.uploads) {
+ storedGroup.uploads = [...groupData.uploads];
+ }
+
+ // Restore form field values if they exist
+ if (groupData.changes) {
+ const titleInput = group.element.querySelector('[name*="post_title"]');
+ const excerptInput = group.element.querySelector('[name*="post_excerpt"]');
+
+ if (titleInput && groupData.changes.post_title) {
+ titleInput.value = groupData.changes.post_title;
+ }
+
+ if (excerptInput && groupData.changes.post_excerpt) {
+ excerptInput.value = groupData.changes.post_excerpt;
+ }
+ }
+ }
+ }
+
+ // Save updated field data
+ await this.saveFieldData(fieldData);
+ }
+
+ async openModalForRestore(context) {
+ if (!context) return;
+
+ const { modalType, itemId } = context;
+
+ // Find and click the appropriate button to open the modal
+ let trigger = null;
+
+ switch(modalType) {
+ case 'create':
+ trigger = document.querySelector('[data-action="create"]');
+ break;
+ case 'edit':
+ // Need to find the specific edit button
+ if (itemId) {
+ trigger = document.querySelector(`[data-action="edit"][data-id="${itemId}"]`);
+ }
+ break;
+ case 'bulkEdit':
+ trigger = document.querySelector('[data-action="bulk-edit"]');
+ break;
+ }
+
+ if (trigger) {
+ trigger.click();
+
+ // Wait for modal to open and render
+ await new Promise(resolve => setTimeout(resolve, 300));
+ } else {
+ console.warn('Modal trigger not found for restoration:', context);
+ }
+ }
+
+ formatBytes(bytes, decimals = 2) {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
+ }
/*******************************************************************************
- * RESTORING UPLOADS
+ * CLEANUP METHODS - AGGRESSIVE CLEANUP AFTER SUCCESS
*******************************************************************************/
+
+ /**
+ * Clear individual upload from stores (called after successful upload)
+ */
+ async clearUpload(uploadId, persist = true) {
+ const uploadEl = this.uploadElements.get(uploadId);
+ if (uploadEl) {
+ this.revokePreviewUrl(uploadEl.preview);
+ if (uploadEl.element) {
+ const previewUrl = uploadEl.element.dataset.previewUrl;
+ this.revokePreviewUrl(previewUrl);
+ delete uploadEl.element.dataset.previewUrl;
+ }
+ }
+
+ // Remove from runtime memory
+ this.uploadElements.delete(uploadId);
+
+ // Remove from store (no separate blob store - it's part of the upload object)
+ await this.uploadStore.delete(uploadId);
+
+ // Update field if needed
+ if (persist) {
+ const upload = this.uploadStore.get(uploadId);
+ if (upload?.fieldId) {
+ await this.schedulePersistance(upload.fieldId);
+ }
+ }
+ }
+
+ /**
+ * Clear entire field from stores (called when all uploads complete)
+ */
+ async clearFieldFromStores(fieldId) {
+ const fieldData = this.getFieldData(fieldId);
+
+ // Clear all related uploads
+ if (fieldData?.uploads) {
+ const uploadsArray = fieldData.uploads instanceof Set
+ ? Array.from(fieldData.uploads)
+ : fieldData.uploads;
+
+ for (const uploadId of uploadsArray) {
+ await this.uploadStore.delete(uploadId);
+ }
+ }
+
+ // Clear field from store
+ await this.fieldStore.delete(fieldId);
+
+ // Keep runtime references (fieldElements, etc) intact for reuse
+ }
+
+ cleanupAllPreviewUrls() {
+ if (this.previewUrls) {
+ this.previewUrls.forEach(url => {
+ try {
+ URL.revokeObjectURL(url);
+ } catch (e) {
+ // Ignore errors during cleanup
+ }
+ });
+ this.previewUrls.clear();
+ }
+ }
+
+ /*******************************************************************************
+ * UI UPDATE METHODS
+ *******************************************************************************/
+
+ updateFieldState(fieldId) {
+ const fieldEl = this.fieldElements.get(fieldId);
+ const fieldData = this.getFieldData(fieldId);
+ if (!fieldEl || !fieldData) return;
+
+ const container = fieldEl.element;
+ const uploadCount = fieldData.uploads?.size || 0;
+ const hasGroups = fieldEl.ui.groups?.container?.querySelectorAll('.upload-group').length > 0;
+
+ container.dataset.hasUploads = uploadCount > 0 ? 'true' : 'false';
+ container.dataset.uploadCount = uploadCount.toString();
+ container.dataset.hasGroups = hasGroups ? 'true' : 'false';
+
+ if (fieldEl.ui.preview) {
+ fieldEl.ui.preview.setAttribute('aria-label',
+ `Upload preview area with ${uploadCount} item${uploadCount !== 1 ? 's' : ''}`
+ );
+ }
+ }
+
+ updateUploadProgress(fieldId, current, total, message) {
+ const fieldEl = this.fieldElements.get(fieldId);
+ if (!fieldEl?.ui?.progress?.progress) return;
+
+ const progress = fieldEl.ui.progress;
+ const percent = total > 0 ? (current / total) * 100 : 0;
+
+ if (progress.fill) progress.fill.style.width = `${percent}%`;
+ if (progress.text) progress.text.textContent = message;
+ if (progress.count) progress.count.textContent = `${current}/${total}`;
+
+ progress.progress.hidden = (current === total);
+ }
+
+ updateFieldStatus(fieldId, status) {
+ const fieldData = this.getFieldData(fieldId);
+ if (!fieldData) return;
+
+ fieldData.state = status;
+ this.saveFieldData(fieldData);
+ }
+
+ updateUploadStatus(uploadId, status) {
+ const upload = this.uploadStore.get(uploadId);
+ if (!upload) return;
+
+ upload.status = status;
+ this.uploadStore.save(upload);
+ this.updateUploadUI(uploadId);
+ }
+
+ updateUploadUI(uploadId) {
+ const uploadEl = this.uploadElements.get(uploadId);
+ const upload = this.uploadStore.get(uploadId);
+ if (!upload || !uploadEl?.element) return;
+
+ uploadEl.element.className = uploadEl.element.className.replace(/status-[\w-]+/g, '');
+ uploadEl.element.classList.add(`status-${upload.status}`);
+
+ const progress = uploadEl.element.querySelector('.progress');
+ if (progress) {
+ this.updateUploadItemProgress(uploadId,
+ this.getStatusProgress(upload.status),
+ upload.status
+ );
+ }
+ }
+
+ showUploadProgress(uploadId, show = true) {
+ const uploadEl = this.uploadElements.get(uploadId);
+ if (!uploadEl?.element) return;
+
+ const progressEl = uploadEl.element.querySelector('.progress');
+ if (progressEl) {
+ if (show) {
+ progressEl.style.removeProperty('animation');
+ progressEl.hidden = false;
+ } else {
+ progressEl.style.animation = 'fadeOut var(--transition-base)';
+ setTimeout(() => { progressEl.hidden = true; }, 300);
+ }
+ }
+ }
+
+ updateUploadItemProgress(uploadId, percent, status = null) {
+ const uploadEl = this.uploadElements.get(uploadId);
+ if (!uploadEl?.element) return;
+
+ const progressEl = uploadEl.element.querySelector('.progress');
+ if (!progressEl) return;
+
+ const fill = progressEl.querySelector('.fill');
+ const details = progressEl.querySelector('.details');
+ const icon = progressEl.querySelector('.icon');
+
+ if (fill) fill.style.width = `${percent}%`;
+ if (status && details) details.textContent = this.getStatusText(status);
+ if (status && icon) icon.innerHTML = this.getStatusIcon(status).outerHTML;
+ }
+
+ maybeLockUploads(fieldId) {
+ const fieldEl = this.fieldElements.get(fieldId);
+ const fieldData = this.getFieldData(fieldId);
+ if (!fieldEl?.ui?.dropZone || !fieldData) return;
+
+ if (fieldData.config.destination === 'post_group') return;
+
+ const uploadCount = fieldData.uploads?.size || 0;
+ const maxFiles = fieldData.config?.maxFiles || 999;
+
+ fieldEl.ui.dropZone.hidden = uploadCount >= maxFiles;
+ fieldEl.element.classList.toggle('at-max-uploads', uploadCount >= maxFiles);
+ }
+
+ /*******************************************************************************
+ * GROUP MANAGEMENT
+ *******************************************************************************/
+
+ createGroup(fieldId, groupId = null) {
+ const fieldData = this.getFieldData(fieldId);
+ const fieldEl = this.fieldElements.get(fieldId);
+ if (!fieldData || !fieldEl) return null;
+
+ if (!groupId) {
+ groupId = `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+ }
+
+ const groupElement = this.createGroupElement(groupId, fieldId);
+ if (!groupElement) return null;
+
+ // Store in field UI Map
+ if (!fieldEl.ui.groups) {
+ fieldEl.ui.groups = {
+ groups: new Map(),
+ container: null,
+ empty: null,
+ display: null
+ };
+ }
+
+ fieldEl.ui.groups.groups.set(groupId, groupElement);
+
+ // Insert into DOM
+ if (fieldEl.ui.groups.container && fieldEl.ui.groups.empty) {
+ fieldEl.ui.groups.container.insertBefore(groupElement, fieldEl.ui.groups.empty);
+ } else if (fieldEl.ui.groups.container) {
+ fieldEl.ui.groups.container.appendChild(groupElement);
+ }
+
+ // Store group element reference
+ const grid = groupElement.querySelector('.item-grid.group');
+ this.groupElements.set(groupId, {
+ element: groupElement,
+ grid: grid,
+ fieldId: fieldId
+ });
+
+ // Add to field groups
+ if (!fieldData.groups) fieldData.groups = [];
+ fieldData.groups.push({
+ id: groupId,
+ uploads: [],
+ changes: {}
+ });
+ this.saveFieldData(fieldData);
+
+ // Initialize selection handler and sortable
+ this.addGroupSelectionHandler(fieldId, groupId);
+
+ // Initialize sortable for this new group grid
+ if (grid) {
+ const sortableInstance = new Sortable(grid, {
+ ...this.sortableConfig,
+ group: { name: fieldId, pull: true, put: true },
+ disabled: true, // Empty initially
+ onAdd: (evt) => this.updateSortableState(evt.to),
+ onRemove: (evt) => this.updateSortableState(evt.from)
+ });
+ grid.sortableInstance = sortableInstance;
+ this.sortableInstances.set(`${fieldId}-group-${groupId}`, sortableInstance);
+ }
+
+ return { id: groupId, element: groupElement, grid: grid };
+ }
+
+ createGroupElement(groupId, fieldId) {
+ let groupElement = window.getTemplate('imageGroup');
+ if (!groupElement) return;
+
+ groupElement.dataset.groupId = groupId;
+ groupElement.dataset.fieldId = fieldId;
+
+ let fields = window.getTemplate('groupMetadata');
+ const fieldsContainer = groupElement.querySelector('.fields');
+ if (fieldsContainer && fields) {
+ fieldsContainer.append(fields);
+
+ const titleInput = fieldsContainer.querySelector('[name="post_title"]');
+ const excerptInput = fieldsContainer.querySelector('[name="post_excerpt"]');
+
+ if (titleInput) {
+ titleInput.id = `${groupId}_title`;
+ titleInput.name = `${groupId}[post_title]`;
+ }
+ if (excerptInput) {
+ excerptInput.id = `${groupId}_excerpt`;
+ excerptInput.name = `${groupId}[post_excerpt]`;
+ }
+
+ const fieldData = this.getFieldData(fieldId);
+ if (fieldData && fieldData.config.content !== '') {
+ let summary = groupElement.querySelector('summary');
+ if (summary) summary.textContent = fieldData.config.content + ' Fields';
+ }
+ } else {
+ groupElement.querySelector('details')?.remove();
+ }
+
+ const gridContainer = groupElement.querySelector('.item-grid.group');
+ if (gridContainer) {
+ gridContainer.dataset.groupId = groupId;
+ }
+
+ return groupElement;
+ }
+
+ deleteGroup(groupId, confirm = true) {
+ const groupEl = this.groupElements.get(groupId);
+ if (!groupEl) return;
+
+ const fieldData = this.getFieldData(groupEl.fieldId);
+ if (!fieldData) return;
+
+ const group = fieldData.groups?.find(g => g.id === groupId);
+ let keepUploads = true;
+
+ if (confirm && group?.uploads?.length > 0) {
+ keepUploads = !window.confirm('Delete uploads in group?');
+ }
+
+ if (confirm && keepUploads && group?.uploads) {
+ // Move uploads back to preview
+ group.uploads.forEach(uploadId => {
+ this.removeFromGroup(uploadId);
+ });
+ }
+
+ // Remove from field groups
+ if (fieldData.groups) {
+ fieldData.groups = fieldData.groups.filter(g => g.id !== groupId);
+ this.saveFieldData(fieldData);
+ }
+
+ // Remove DOM element
+ if (groupEl.element) {
+ groupEl.element.remove();
+ this.a11y.announce('Group removed');
+ }
+
+ // Remove from maps
+ this.groupElements.delete(groupId);
+
+ // Clean up sortable
+ const sortableKey = `${groupEl.fieldId}-group-${groupId}`;
+ const sortable = this.sortableInstances.get(sortableKey);
+ if (sortable?.destroy) {
+ sortable.destroy();
+ }
+ this.sortableInstances.delete(sortableKey);
+
+ this.schedulePersistance(groupEl.fieldId);
+ }
+
+ addToGroup(uploadId, target = null, persist = true) {
+ const upload = this.uploadStore.get(uploadId);
+ const uploadEl = this.uploadElements.get(uploadId);
+ if (!upload || !uploadEl) return;
+
+ const fieldData = this.getFieldData(upload.fieldId);
+ const fieldEl = this.fieldElements.get(upload.fieldId);
+ if (!fieldData || !fieldEl) return;
+
+ // Already in correct location
+ if ((!target && uploadEl.location === fieldEl.ui.preview) || target === uploadEl.location) {
+ return;
+ }
+
+ // Remove from previous group
+ if (upload.groupId) {
+ const group = fieldData.groups?.find(g => g.id === upload.groupId);
+ if (group) {
+ group.uploads = group.uploads.filter(id => id !== uploadId);
+ if (group.uploads.length === 0) {
+ this.deleteGroup(upload.groupId);
+ }
+ }
+ }
+
+ // Clear selection checkbox
+ const checkbox = uploadEl.element.querySelector('[name*="select-item"]');
+ if (checkbox) checkbox.checked = false;
+
+ let featured = uploadEl.element.querySelector('[name="featured"]');
+ if (featured) featured.hidden = !target;
+
+ // Moving to preview or to group
+ if (!target || target.classList.contains('preview')) {
+ target = fieldEl.ui.preview;
+ upload.groupId = null;
+ } else {
+ // Moving to group
+ const groupId = target.dataset.groupId;
+ if (featured) featured.name = groupId + '_' + featured.name;
+
+ const group = fieldData.groups?.find(g => g.id === groupId);
+ if (group) {
+ if (!group.uploads) group.uploads = [];
+ group.uploads.push(uploadId);
+ upload.groupId = groupId;
+ }
+ }
+
+ // Update location
+ uploadEl.location = target;
+ target.append(uploadEl.element);
+
+ // Update stores
+ this.uploadStore.save(upload);
+ if (persist) {
+ this.saveFieldData(fieldData);
+ }
+
+ // Update sortable state
+ this.updateSortableState(target);
+ if (uploadEl.location && uploadEl.location !== target) {
+ this.updateSortableState(uploadEl.location);
+ }
+ }
+
+ removeFromGroup(uploadId) {
+ const upload = this.uploadStore.get(uploadId);
+ const uploadEl = this.uploadElements.get(uploadId);
+ if (!upload || !uploadEl) return;
+
+ const fieldData = this.getFieldData(upload.fieldId);
+ const fieldEl = this.fieldElements.get(upload.fieldId);
+ if (!fieldData || !fieldEl) return;
+
+ // Remove from current group
+ if (upload.groupId) {
+ const group = fieldData.groups?.find(g => g.id === upload.groupId);
+ if (group) {
+ group.uploads = group.uploads.filter(id => id !== uploadId);
+ if (group.uploads.length === 0) {
+ this.deleteGroup(upload.groupId, false);
+ }
+ }
+ upload.groupId = null;
+ }
+
+ // Move back to preview
+ if (fieldEl.ui?.preview) {
+ fieldEl.ui.preview.appendChild(uploadEl.element);
+ uploadEl.location = fieldEl.ui.preview;
+ }
+
+ // Hide featured radio
+ const featured = uploadEl.element.querySelector('[name="featured"]');
+ if (featured) {
+ featured.hidden = true;
+ featured.checked = false;
+ }
+
+ this.uploadStore.save(upload);
+ this.updateSortableState(fieldEl.ui.preview);
+ }
+
+ removeUpload(fieldId, uploadId) {
+ const fieldData = this.getFieldData(fieldId);
+ const upload = this.uploadStore.get(uploadId);
+ const uploadEl = this.uploadElements.get(uploadId);
+
+ if (!fieldData || !upload) return;
+
+ // Remove from field
+ fieldData.uploads?.delete(uploadId);
+
+ // Remove from group if grouped
+ if (upload.groupId) {
+ const group = fieldData.groups?.find(g => g.id === upload.groupId);
+ if (group) {
+ group.uploads = group.uploads.filter(id => id !== uploadId);
+ if (group.uploads.length === 0) {
+ this.deleteGroup(upload.groupId);
+ }
+ }
+ }
+
+ // Clean up element
+ uploadEl?.element?.remove();
+
+ // Clean up memory
+ this.clearUpload(uploadId);
+
+ // Update field state
+ this.saveFieldData(fieldData);
+ this.updateFieldState(fieldId);
+ this.maybeLockUploads(fieldId);
+
+ const handler = this.selectionHandlers.get(fieldId);
+ if (handler) {
+ handler.deselect(uploadId);
+ }
+
+ this.a11y.announce('Upload removed');
+ }
+
+ handleGroupMetaChange(input) {
+ const groupEl = this.getGroupFromElement(input);
+ if (!groupEl) return;
+
+ const fieldData = this.getFieldData(groupEl.fieldId);
+ const group = fieldData?.groups?.find(g => g.id === groupEl.element.dataset.groupId);
+ if (!group) return;
+
+ if (!group.changes) group.changes = {};
+
+ let name = input.name;
+ if (name.includes('group')) {
+ name = name.replace(`${group.id}_`, '').replace(`${group.id}[`, '').replace(']', '');
+ }
+
+ group.changes[name] = input.value;
+ this.saveFieldData(fieldData);
+ this.schedulePersistance(groupEl.fieldId);
+ }
+
+ /*******************************************************************************
+ * ACTION HANDLERS
+ *******************************************************************************/
+
+ handleAction(button) {
+ const action = button.dataset.action;
+ const fieldId = this.getFieldIdFromElement(button);
+
+ switch(action) {
+ case 'add-to-group':
+ this.handleAddToGroup(button);
+ break;
+ case 'delete-group':
+ this.handleDeleteGroup(button);
+ break;
+ case 'delete-upload':
+ case 'remove-from-group':
+ this.handleRemoveItem(button);
+ break;
+ case 'upload':
+ const fieldEl = this.fieldElements.get(fieldId);
+ if (fieldEl) {
+ fieldEl.element.closest('details').open = false;
+ document.body.classList.add('uploading');
+ this.submitUploads(fieldId);
+ }
+ break;
+ case 'restore':
+ this.handleRestoreUploads().then(() => {});
+ break;
+ case 'clear-cache':
+ if (!confirm('Save these uploads for later?')) {
+ this.cleanupStoredUploads();
+ }
+ this.cleanupRestore();
+ break;
+ }
+ }
+
+ handleAddToGroup(button) {
+ const fieldElement = button.closest(this.selectors.field.field);
+ const fieldId = fieldElement?.dataset.uploader;
+ if (!fieldId) return;
+
+ const selected = this.selected.get(fieldId);
+
+ if (!selected || selected.size === 0) {
+ this.createGroup(fieldId);
+ } else {
+ const group = this.createGroup(fieldId);
+ if (!group) return;
+
+ selected.forEach(uploadId => {
+ this.addToGroup(uploadId, group.grid);
+ });
+
+ const handler = this.selectionHandlers.get(fieldId);
+ handler?.clearSelection();
+
+ this.a11y.announce(`Created group with ${selected.size} items`);
+ }
+
+ this.schedulePersistance(fieldId);
+ }
+
+ handleDeleteGroup(button) {
+ const group = button.closest(this.selectors.groups.container);
+ if (!group) return;
+
+ const groupId = group.dataset.groupId;
+ const fieldId = this.getFieldIdFromElement(group);
+
+ if (!confirm('Delete this group? Items will be moved back to the upload area.')) {
+ return;
+ }
+
+ const items = group.querySelectorAll(this.selectors.items.item);
+ items.forEach(item => {
+ const uploadId = item.dataset.uploadId;
+ this.removeFromGroup(uploadId);
+ });
+
+ this.deleteGroup(groupId);
+ this.a11y.announce('Group deleted, items returned to upload area');
+ this.schedulePersistance(fieldId);
+ }
+
+ handleRemoveItem(button) {
+ const item = button.closest(this.selectors.items.item);
+ if (!item) return;
+
+ const uploadId = item.dataset.uploadId;
+ const fieldId = this.getFieldIdFromElement(item);
+
+ if (!confirm('Remove this item?')) return;
+
+ this.removeUpload(fieldId, uploadId);
+ this.a11y.announce('Item removed');
+ this.schedulePersistance(fieldId);
+ }
+
+ /*******************************************************************************
+ * SELECTION MANAGEMENT
+ *******************************************************************************/
+
+ addFieldSelectionHandler(fieldId) {
+ if (this.selectionHandlers.has(fieldId)) {
+ return this.selectionHandlers.get(fieldId);
+ }
+
+ const fieldEl = this.fieldElements.get(fieldId);
+ if (!fieldEl?.element) return;
+
+ const handler = new window.jvbHandleSelection({
+ container: fieldEl.element,
+ ui: {
+ selectAll: fieldEl.element.querySelector('[name="select-all-uploads"]'),
+ bulkControls: fieldEl.element.querySelector('.selection-actions'),
+ count: fieldEl.element.querySelector('.selection-count')
+ },
+ itemSelector: '[data-upload-id]',
+ checkboxSelector: '[name*="select-item"]'
+ });
+
+ handler.subscribe((event, data) => {
+ switch(event) {
+ case 'item-selected':
+ 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;
+ }
+
+ addGroupSelectionHandler(fieldId, groupId) {
+ const handlerKey = `${fieldId}_${groupId}`;
+ if (this.selectionHandlers.has(handlerKey)) {
+ return this.selectionHandlers.get(handlerKey);
+ }
+
+ const groupEl = this.groupElements.get(groupId);
+ if (!groupEl?.element) return;
+
+ const handler = new window.jvbHandleSelection({
+ container: groupEl.element,
+ ui: {
+ selectAll: groupEl.element.querySelector(this.selectors.groups.selectAll),
+ bulkControls: groupEl.element.querySelector(this.selectors.groups.actions),
+ count: groupEl.element.querySelector(this.selectors.groups.count)
+ },
+ itemSelector: '[data-upload-id]',
+ checkboxSelector: '[name*="select-item"]'
+ });
+
+ handler.subscribe((event, data) => {
+ switch(event) {
+ case 'item-selected':
+ case 'item-deselected':
+ case 'range-selected':
+ this.selected.set(fieldId, data.selectedItems);
+ break;
+ case 'select-all':
+ this.handleSelectAll(data.container, data.selected);
+ break;
+ }
+ });
+
+ this.selectionHandlers.set(handlerKey, handler);
+ return handler;
+ }
+
+ handleSelectAll(container, selected) {
+ // Can add custom logic here if needed
+ }
+
+ 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;
+ }
+
+ /*******************************************************************************
+ * HELPER METHODS
+ *******************************************************************************/
+
+ /**
+ * Get field data from store and normalize it
+ * Always use this instead of directly accessing fieldStore.get()
+ */
+ getFieldData(fieldId) {
+ const fieldData = this.fieldStore.get(fieldId);
+ if (!fieldData) return null;
+
+ // Only convert uploads back to Set (DataStore returns Arrays)
+ if (Array.isArray(fieldData.uploads)) {
+ fieldData.uploads = new Set(fieldData.uploads);
+ } else if (!fieldData.uploads) {
+ fieldData.uploads = new Set();
+ }
+
+ // Ensure groups is an array
+ if (!Array.isArray(fieldData.groups)) {
+ fieldData.groups = [];
+ }
+
+ return fieldData;
+ }
+
+ /**
+ * Save field data to store, converting Sets to Arrays
+ */
+ async saveFieldData(fieldData) {
+ await this.fieldStore.save({
+ ...fieldData,
+ timestamp: Date.now()
+ });
+ }
+
+ determineFieldId(fieldElement) {
+ const content = fieldElement.dataset.content ||
+ fieldElement.closest('dialog')?.dataset.content ||
+ fieldElement.closest('form')?.dataset.save || '';
+ const itemID = fieldElement.dataset.itemId ||
+ fieldElement.closest('dialog')?.dataset.itemId || '';
+ const field = fieldElement.dataset.field || '';
+
+ return `${content}_${itemID}_${field}`;
+ }
+
+ getFromElement(element, type) {
+ const map = {
+ 'field': {
+ selector: this.selectors.field.field,
+ key: 'uploader',
+ getRuntimeData: (id) => this.fieldElements.get(id),
+ getStoreData: (id) => this.getFieldData(id)
+ },
+ 'upload': {
+ selector: this.selectors.items.item,
+ key: 'uploadId',
+ getRuntimeData: (id) => this.uploadElements.get(id),
+ getStoreData: (id) => this.uploadStore.get(id)
+ },
+ 'group': {
+ selector: this.selectors.groups.container,
+ key: 'groupId',
+ getRuntimeData: (id) => this.groupElements.get(id),
+ getStoreData: (id) => {
+ // Groups are stored in field.groups array
+ const groupEl = this.groupElements.get(id);
+ if (!groupEl) return null;
+ const fieldData = this.getFieldData(groupEl.fieldId);
+ return fieldData?.groups?.find(g => g.id === id);
+ }
+ }
+ };
+
+ const config = map[type];
+ if (!config) return null;
+
+ const el = element.closest(config.selector);
+ if (!el) return null;
+
+ const id = el.dataset[config.key];
+
+ // Return combined runtime + store data for convenience
+ const runtime = config.getRuntimeData(id);
+ const store = config.getStoreData(id);
+
+ return { ...runtime, ...store };
+ }
+
+ getFieldFromElement(el) { return this.getFromElement(el, 'field'); }
+ getUploadFromElement(el) { return this.getFromElement(el, 'upload'); }
+ getGroupFromElement(el) { return this.getFromElement(el, 'group'); }
+
+ getFieldIdFromElement(el) {
+ const field = this.getFromElement(el, 'field');
+ return field?.id ?? null;
+ }
+ getUploadIdFromElement(el) {
+ const upload = this.getFromElement(el, 'upload');
+ return upload?.id ?? null;
+ }
+ getGroupIdFromElement(el) {
+ const group = this.getFromElement(el, 'group');
+ return group?.id ?? null;
+ }
+
+ getSubtypeFromMime(mimeType) {
+ if (mimeType.startsWith('image/')) return 'image';
+ if (mimeType.startsWith('video/')) return 'video';
+ return 'document';
+ }
+
+ getStatusText(status) {
+ return this.statusMapping[status] || status;
+ }
+
+ getStatusIcon(status) {
+ return window.getIcon(this.queue.icons[status]);
+ }
+
+ getStatusProgress(status) {
+ const progress = {
+ 'local_processing': 28,
+ 'queued': 50,
+ 'uploading': 66,
+ 'pending': 75,
+ 'processing': 89,
+ 'completed': 100
+ };
+ return progress[status] || 0;
+ }
+
+ getModalType(fieldEl) {
+ if (!fieldEl?.element) return null;
+ if (fieldEl._cachedModalType !== undefined) {
+ return fieldEl._cachedModalType;
+ }
+
+ const dialog = fieldEl.element.closest('dialog');
+ if (!dialog) {
+ fieldEl._cachedModalType = null;
+ return null;
+ }
+
+ 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;
+
+ fieldEl._cachedModalType = modalType;
+ return modalType;
+ }
+
+ createUploadElement(upload, draggable = false) {
+ let image = window.getTemplate('uploadItem');
+ if (!image) return;
+
+ image.dataset.uploadId = upload.id;
+ image.dataset.subtype = upload.subtype || 'image';
+
+ let [featured, img, video, preview, details] = [
+ image.querySelector('[name="featured"]'),
+ image.querySelector('img'),
+ image.querySelector('video'),
+ image.querySelector('label > span'),
+ image.querySelector('details')
+ ];
+
+ if (featured) featured.value = upload.id;
+
+ switch (upload.subtype) {
+ case 'image':
+ if (img) {
+ img.src = upload.preview;
+ img.alt = upload.meta?.originalName || '';
+ }
+ video?.remove();
+ preview?.remove();
+ break;
+ case 'video':
+ if (video) video.src = upload.preview;
+ img?.remove();
+ preview?.remove();
+ break;
+ case 'document':
+ const fileName = upload.meta?.originalName || '';
+ const extension = fileName.split('.').pop()?.toLowerCase() || '';
+ const iconMap = {
+ 'pdf': 'file-pdf', 'csv': 'file-csv',
+ 'doc': 'file-doc', 'docx': 'file-doc',
+ 'txt': 'file-txt', 'xls': 'file-xls', 'xlsx': 'file-xls'
+ };
+ const icon = window.getIcon(iconMap[extension] || 'file');
+ if (preview) {
+ preview.innerText = fileName;
+ preview.prepend(icon);
+ }
+ img?.remove();
+ video?.remove();
+ break;
+ }
+
+ if (details) {
+ let template = window.getTemplate('uploadMeta');
+ if (template) details.append(template);
+ }
+
+ image.draggable = draggable;
+
+ // Update input IDs
+ image.querySelectorAll('input').forEach(input => {
+ let id = input.id;
+ if (id) {
+ let newId = id + upload.id;
+ let label = input.parentNode.querySelector(`label[for="${id}"]`);
+ input.id = newId;
+ if (label) label.htmlFor = newId;
+ }
+ });
+
+ return image;
+ }
+
+ /*******************************************************************************
+ * PERSISTENCE
+ *******************************************************************************/
+
+ /**
+ * Normalize field data loaded from IndexedDB
+ * Converts Arrays back to Sets, handles missing properties
+ */
+ normalizeFieldData(fieldData) {
+ if (!fieldData) return null;
+
+ // Convert uploads array back to Set
+ if (Array.isArray(fieldData.uploads)) {
+ fieldData.uploads = new Set(fieldData.uploads);
+ } else if (!fieldData.uploads) {
+ fieldData.uploads = new Set();
+ }
+
+ // Convert groups array, ensure proper structure
+ if (!Array.isArray(fieldData.groups)) {
+ fieldData.groups = [];
+ }
+
+ // Ensure each group has uploads array
+ fieldData.groups = fieldData.groups.map(group => ({
+ ...group,
+ uploads: Array.isArray(group.uploads) ? group.uploads : []
+ }));
+
+ return fieldData;
+ }
+
+ schedulePersistance(fieldId) {
+ const key = `persist_${fieldId}`;
+ window.debouncer.schedule(
+ key,
+ () => this.persistFieldState(fieldId),
+ 1000
+ );
+ }
+
+ async persistFieldState(fieldId) {
+ const fieldData = this.getFieldData(fieldId);
+ if (!fieldData) return;
+
+ // Save with updated timestamp
+ await this.saveFieldData(fieldData);
+ }
+
+ // In UploadManager, add blob conversion helpers
+ async saveBlobData(uploadId, file) {
+ const arrayBuffer = await file.arrayBuffer();
+
+ const uploadData = this.uploadStore.get(uploadId) || { id: uploadId };
+
+ // Store blob data as ArrayBuffer with metadata
+ uploadData.blobData = {
+ buffer: arrayBuffer,
+ name: file.name,
+ type: file.type,
+ size: file.size,
+ lastModified: file.lastModified || Date.now()
+ };
+
+ await this.uploadStore.save(uploadData);
+ }
+
+ async getBlobData(uploadId) {
+ const upload = this.uploadStore.get(uploadId);
+ if (!upload?.blobData) return null;
+
+ // Reconstruct File from ArrayBuffer
+ const blob = new Blob([upload.blobData.buffer], { type: upload.blobData.type });
+ return new File([blob], upload.blobData.name, {
+ type: upload.blobData.type,
+ lastModified: upload.blobData.lastModified
+ });
+ }
+
+ /*******************************************************************************
+ * RECOVERY & RESTORATION
+ *******************************************************************************/
+
+ handleFieldStoreEvent(event, data) {
+ switch(event) {
+ case 'data-loaded':
+ // Check for pending uploads to restore
+ this.checkForStoredUploads();
+ break;
+ }
+ }
+
+ handleUploadStoreEvent(event, data) {
+ switch(event) {
+ case 'data-loaded':
+ break;
+ case 'item-saved':
+ this.showSaveIndicator(data.key);
+ break;
+ }
+ }
+
+ async checkForStoredUploads() {
+ const allFieldStates = this.fieldStore.getAll();
+
+ console.log('Checking for stored uploads...', {
+ fieldStates: allFieldStates.length,
+ uploadStoreSize: this.uploadStore.data.size
+ });
+ const pendingFields = allFieldStates.filter(field => {
+ if (!field.uploads) return false;
+
+ // Handle both Set and Array (from IndexedDB)
+ const uploadsArray = field.uploads instanceof Set
+ ? Array.from(field.uploads)
+ : Array.isArray(field.uploads)
+ ? field.uploads
+ : [];
+
+ return uploadsArray.some(uploadId => {
+ const upload = this.uploadStore.get(uploadId);
+ return upload && !upload.operationId &&
+ ['completed', 'processed', 'local_processing', 'processed-original'].includes(upload.status);
+ });
+ });
+ console.log('Found pending fields:', pendingFields.length);
+ if (pendingFields.length === 0) return;
+
+ this.showRecoveryNotification(pendingFields);
+ }
+
async showRecoveryNotification(pendingFields) {
const totalUploads = pendingFields.reduce((sum, field) => sum + field.uploads.length, 0);
const totalGroups = pendingFields.reduce((sum, field) =>
@@ -2588,14 +2730,12 @@
// const imgEl = uploadItem.querySelector('img');
// const placeholderEl = uploadItem.querySelector('.image-placeholder');
//
- const blobData = await this.uploadStore.getBlob(upload.id);
+ const file = await this.getBlobData(upload.id);
+ if (file) {
-
- if (blobData) {
try {
// Create new blob URL from stored data
- const blob = new Blob([blobData.data], { type: blobData.type });
- const previewUrl = this.createPreviewUrl(blob);
+ const previewUrl = this.createPreviewUrl(file);
let [
featured,
@@ -2616,7 +2756,7 @@
uploadItem.dataset.fieldId = field.id;
- let subtype = this.getSubtypeFromMime(blobData.type);
+ let subtype = this.getSubtypeFromMime(file.type);
uploadItem.dataset.subtype = subtype;
switch (subtype) {
case 'image':
@@ -2625,7 +2765,7 @@
img.alt
] = [
previewUrl,
- upload.originalFile?.name ?? upload.meta?.originalName?? ''
+ file.name ?? upload.meta?.originalName ?? ''
];
video.remove();
preview.remove();
@@ -2720,336 +2860,23 @@
}
- async restoreSelectedUploads(selectedUploads) {
- // Group by field
- const byField = new Map();
- selectedUploads.forEach(item => {
- if (!byField.has(item.fieldId)) {
- byField.set(item.fieldId, []);
- }
- byField.get(item.fieldId).push(item.uploadId);
- });
-
- // Get full field states from IndexedDB
- if (!this.db) {
- // this.notifications.add('Cannot restore: Database not available', 'error');
+ async handleRestoreUploads() {
+ let notification = document.querySelector('dialog.restore-uploads');
+ if (!notification) {
return;
}
- const tx = this.db.transaction(['fieldStates'], 'readonly');
- const store = tx.objectStore('fieldStates');
-
- for (const [fieldId, uploadIds] of byField.entries()) {
- const request = store.get(fieldId);
- const fieldState = await new Promise(resolve => {
- request.onsuccess = () => resolve(request.result);
- request.onerror = () => resolve(null);
- });
-
- if (fieldState) {
- // Filter to only selected uploads
- fieldState.uploads = fieldState.uploads.filter(u => uploadIds.includes(u.id));
- await this.restoreField(fieldState);
- }
- }
-
- // this.notifications.add(`Restored ${selectedUploads.length} upload(s)`, 'success');
- }
-
- async restoreField(fieldState) {
- const { config, context, uploads, groups, id } = fieldState; // ← Use 'id'
-
- // If in a modal, open it first
- if (context.modalType) {
- await this.openModalForRestore(context);
- }
-
- // Find field element
- let fieldElement = document.querySelector(`.field.upload[data-field="${config.name}"]`);
-
- if (!fieldElement) {
- const uploaderKey = `${config.content}_${config.itemID}_${config.name}`;
- fieldElement = document.querySelector(`.field.upload[data-uploader="${uploaderKey}"]`);
- }
-
- if (!fieldElement) {
- console.warn(`Field ${config.name} not found for restoration`, config);
+ const selectedUploads = this.getSelectedRestorationUploads(notification);
+ if (selectedUploads.length === 0) {
return;
}
+ await this.restoreSelectedUploads(selectedUploads);
- // 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);
- }
-
- // Update UI
- this.updateFieldState(fieldKey);
- this.maybeLockUploads(fieldKey);
-
- // Queue for upload if needed
- if (config.mode === 'direct' && config.destination !== 'post_group') {
- await this.queueUpload(fieldKey);
- }
+ this.cleanupRestore();
}
- async restoreUpload(field, uploadData) {
- // Try to get blob data from IndexedDB
- const blobData = await this.uploadStore.getBlob(uploadData.id);
-
- if (blobData) {
- const file = blobData.data instanceof File
- ? blobData.data
- : new File(
- [blobData.data],
- blobData.name,
- { type: blobData.type, lastModified: blobData.lastModified }
- );
-
- uploadData.originalFile = file;
- uploadData.processedFile = file;
- uploadData.preview = 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
- if (!field.uploads) field.uploads = new Set();
- field.uploads.add(uploadData.id);
-
- // Recreate DOM element
- const subtype = this.getSubtypeFromMime(uploadData.originalFile.type);
- uploadData.element = this.createUploadElement({
- ...uploadData,
- subtype: subtype
- }, field.config.destination === 'post_group');
-
- // Restore to correct location
- let location;
- if (uploadData.groupId && field.ui.groups.groups.has(uploadData.groupId)) {
- location = field.ui.groups.groups.get(uploadData.groupId).querySelector('.item-grid');
- } else {
- location = field.ui.preview;
- }
-
- if (location) {
- location.appendChild(uploadData.element);
- uploadData.location = location;
- }
-
- // Store in memory
- this.uploads.set(uploadData.id, uploadData);
- if (uploadData.groupId) {
- const group = this.groups.get(uploadData.groupId);
- if (group && group.uploads) {
- group.uploads.add(uploadData.id);
- }
- }
- }
-
- 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);
-
- if (group) {
- // Update the group metadata from saved state
- if (groupData.meta) {
- group.meta = { ...groupData.meta };
- }
- if (groupData.changes) {
- group.changes = { ...groupData.changes };
- }
-
-
- // 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) {
- const { modalType, formId } = context;
-
- // Find and click the appropriate button to open the modal
- let trigger = null;
-
- switch(modalType) {
- case 'create':
- trigger = document.querySelector('[data-action="create"]');
- break;
- case 'edit':
- // Need to find the specific edit button
- trigger = document.querySelector(`[data-action="edit"][data-id="${context.itemId}"]`);
- break;
- case 'bulkEdit':
- trigger = document.querySelector('[data-action="bulk-edit"]');
- break;
- }
-
- if (trigger) {
- trigger.click();
-
- // Wait for modal to open
- await new Promise(resolve => setTimeout(resolve, 300));
- }
- }
-
- /*******************************************************************************
- INDEXEDDB CACHE FUNCTIONALITY
- *******************************************************************************/
- handleFieldStoreEvent(event, data) {
- switch(event) {
- case 'data-loaded':
-
- break;
- case 'item-saved':
- console.log(`Field state saved: ${data.key}`);
- break;
- }
- }
-
- 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 {
- 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));
- }
- this.fields.set(field.fieldId, field);
- });
- }
-
- async loadUploads() {
- const uploads = await this.uploadStore.getAll();
- uploads.forEach(upload => {
- this.uploads.set(upload.id, upload);
- });
- }
-
- /**************************************************************************
- SUBSCRIBERS
- **************************************************************************/
- /**
- * Event system
- */
- subscribe(callback) {
- this.subscribers.add(callback);
- return () => this.subscribers.delete(callback);
- }
-
- notify(event, data) {
- this.subscribers.forEach(cb => cb(event, data));
- }
- /*******************************************************************************
- * CLEANUP
- *******************************************************************************/
-
- 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);
-
- // Destroy drag controller
- if (this.dragController) {
- this.dragController.destroy();
- }
-
- // Destroy selection handlers
- this.selectionHandlers.forEach(handler => handler.destroy());
- this.selectionHandlers.clear();
-
- this.cleanupAllPreviewUrls();
- this.sortableInstances.forEach(instance => {
- if (instance?.destroy) {
- instance.destroy();
- }
- });
- this.sortableInstances.clear();
-
- // Clear data
- this.fields.clear();
- this.uploads.clear();
- this.groups.clear();
- this.selected.clear();
- this.subscribers.clear();
- }
-
- destroySortable(fieldName) {
- // Destroy all sortable instances for this field
- const instances = Array.from(this.sortableInstances.keys())
- .filter(key => key.startsWith(fieldName));
-
- instances.forEach(key => {
- const instance = this.sortableInstances.get(key);
- if (instance?.destroy) {
- instance.destroy();
- }
- this.sortableInstances.delete(key);
- });
+ showSaveIndicator(key) {
+ // Optional: show user that state is being saved
}
cleanupRestore() {
@@ -3062,65 +2889,60 @@
}
async cleanupStoredUploads() {
- this.fieldStore.clear();
- this.uploadStore.clear();
+ await this.fieldStore.clear();
+ await this.uploadStore.clear();
}
- /**
- * Clear all uploads for a field and cleanup resources
- */
- async clearField(fieldId) {
- // Clear from stores
- await this.fieldStore.delete(fieldId);
+ /*******************************************************************************
+ * EVENT SYSTEM
+ *******************************************************************************/
- // Clear related uploads
- const field = this.fields.get(fieldId);
- if (field?.uploads) {
- for (const uploadId of field.uploads) {
- await this.uploadStore.delete(uploadId);
+ subscribe(callback) {
+ this.subscribers.add(callback);
+ return () => this.subscribers.delete(callback);
+ }
+
+ notify(event, data = {}) {
+ this.subscribers.forEach(cb => {
+ try {
+ cb(event, data);
+ } catch (error) {
+ console.error('Subscriber error:', error);
}
- }
-
- // Clear from memory
- this.fields.delete(fieldId);
+ });
}
- async clearUpload(uploadId, persist = true) {
- const upload = this.uploads.get(uploadId);
- if (!upload) return;
+ /*******************************************************************************
+ * DESTROY & CLEANUP
+ *******************************************************************************/
- // Clean up preview URL using helper
- this.revokePreviewUrl(upload.preview);
+ destroy() {
+ document.removeEventListener('click', this.clickHandler);
+ document.removeEventListener('change', this.changeHandler);
+ document.removeEventListener('dragenter', this.dragEnterHandler);
+ document.removeEventListener('dragleave', this.dragLeaveHandler);
+ document.removeEventListener('dragover', this.dragOverHandler);
+ document.removeEventListener('drop', this.dropHandler);
- // Clean up element preview URL
- if (upload.element) {
- const previewUrl = upload.element.dataset.previewUrl;
- this.revokePreviewUrl(previewUrl);
- delete upload.element.dataset.previewUrl;
+ if (this.dragController) {
+ this.dragController.destroy();
}
- if (persist) {
- await this.schedulePersistance(upload.fieldId);
- }
+ this.selectionHandlers.forEach(handler => handler.destroy());
+ this.selectionHandlers.clear();
- // Remove from memory
- this.uploads.delete(uploadId);
+ this.cleanupAllPreviewUrls();
- // 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();
- }
+ this.sortableInstances.forEach(instance => {
+ if (instance?.destroy) instance.destroy();
+ });
+ this.sortableInstances.clear();
+
+ this.uploadElements.clear();
+ this.fieldElements.clear();
+ this.groupElements.clear();
+ this.selected.clear();
+ this.subscribers.clear();
}
}
--
Gitblit v1.10.0