class UploadManager {
|
constructor() {
|
//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,
|
|
indexes: [
|
{ name: 'fieldId', keyPath: 'fieldId' },
|
{ name: 'timestamp', keyPath: 'timestamp' },
|
{ name: 'content', keyPath: 'content' },
|
{ name: 'itemId', keyPath: 'itemId' },
|
{ name: 'status', keyPath: 'status' }
|
],
|
|
stripDOMReferences: true,
|
TTL: 86400000*7 // 24 hours -> 1 week
|
});
|
|
this.uploadStore = new window.jvbStore({
|
name: 'uploads',
|
storeName: 'uploads',
|
keyPath: 'id',
|
storeBlobs: true,
|
|
indexes: [
|
{ name: 'fieldId', keyPath: 'fieldId' },
|
{ name: 'status', keyPath: 'status' },
|
{ name: 'groupId', keyPath: 'groupId' },
|
{ name: 'attachmentId', keyPath: 'attachmentId' }
|
],
|
});
|
|
window.jvbUploadBlobs = this.uploadStore;
|
|
// Subscribe to store events
|
this.fieldStore.subscribe(this.handleFieldStoreEvent.bind(this));
|
this.uploadStore.subscribe(this.handleUploadStoreEvent.bind(this));
|
|
//Load Worker
|
this.initWorker();
|
|
// Core data structures
|
this.fields = new Map();
|
this.uploads = new Map();
|
this.groups = new Map();
|
this.selected = new Map();
|
this.selectionHandlers = new Map();
|
this.previewUrls = new Set();
|
//Notification and Subscribers
|
this.subscribers = new Set();
|
|
// Controllers (will be initialized based on features)
|
this.dragController = null;
|
|
// Selectors
|
this.selectors = {
|
field: {
|
field: '[data-upload-field]',
|
input: 'input[type="file"]',
|
hiddenValue: 'input[type="hidden"]',
|
dropZone: '.file-upload-container',
|
preview: '.item-grid.preview',
|
progress: '.image-progress'
|
},
|
groups: {
|
container: '.upload-group',
|
grid: '.item-grid.group',
|
header: '.group-header',
|
selectAll: '[name="select-all-group"]',
|
actions: '.group-actions',
|
count: '.selection-controls .info'
|
},
|
items: {
|
item: '[data-upload-id]',
|
checkbox: '[name*="select-item"]',
|
featured: '[name="featured"]',
|
details: 'details'
|
}
|
};
|
|
|
this.statusMapping = {
|
'received': 'Image Received',
|
'local_processing': 'Processing Image...',
|
'queued': 'Waiting to upload...',
|
'uploading': 'Uploading to Server',
|
'pending': 'Successfully sent to server. In line for further processing.',
|
'processing': 'Processing on server...',
|
'completed': 'Upload complete!',
|
'failed': 'Upload failed (will retry)',
|
'failed_permanent': 'Upload failed permanently'
|
};
|
|
this.init();
|
}
|
|
async init() {
|
// Load existing data
|
await this.loadFields();
|
await this.loadUploads();
|
// Initialize fields
|
this.initializeFields();
|
|
// Set up core listeners
|
this.initListeners();
|
|
this.queue.subscribe((event, operation) => {
|
if (operation.endpoint !== 'uploads' && operation.endpoint !== 'uploads/meta') {
|
return;
|
}
|
const fieldId = operation.data instanceof FormData
|
? operation.data.get('fieldId')
|
: operation.data.fieldId;
|
switch(event) {
|
case 'cancel-operation':
|
if (fieldId) {
|
this.clearField(fieldId);
|
}
|
break;
|
case '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);
|
}
|
break;
|
}
|
|
});
|
|
window.addEventListener('beforeunload', () => {
|
this.cleanupAllPreviewUrls();
|
});
|
}
|
|
initWorker() {
|
this.worker = {
|
worker: null,
|
timeout: null,
|
tasks: new Map(),
|
restart: {
|
count: 0,
|
max: 3,
|
},
|
settings: {
|
timeout: 10000, //10 seconds per image
|
batchSize: 1,
|
maxConcurrent: 3,
|
restartAfterTimeout: true
|
}
|
};
|
}
|
|
/**
|
* Initialize all upload fields on the page
|
*/
|
initializeFields() {
|
const fields = document.querySelectorAll(this.selectors.field.field);
|
fields.forEach(uploader => {
|
this.registerUploader(uploader);
|
});
|
}
|
|
scanFields(container) {
|
const fields = container.querySelectorAll(this.selectors.field.field);
|
fields.forEach(uploader => {
|
this.registerUploader(uploader);
|
});
|
}
|
|
registerUploader(uploader) {
|
const fieldId = this.determineFieldId(uploader);
|
const config = this.extractFieldConfig(uploader);
|
|
// Create field data structure
|
const field = {
|
id: fieldId,
|
config: config,
|
element: uploader,
|
ui: this.buildFieldUI(uploader),
|
uploads: new Set(),
|
groups: new Set(),
|
state: 'ready',
|
};
|
|
this.fields.set(fieldId, field);
|
uploader.dataset.uploader = fieldId;
|
this.addFieldSelectionHandler(fieldId);
|
|
if (config.destination === 'post_group' && !this.dragController) {
|
this.initGroupFeatures();
|
}
|
|
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
|
maxFiles: parseInt(fieldElement.dataset.maxFiles) || 999,
|
subtype: fieldElement.dataset.subtype || 'image'
|
};
|
}
|
|
/**
|
* Build UI element references for a field
|
*/
|
buildFieldUI(fieldElement) {
|
let UI = {
|
field: fieldElement,
|
input: fieldElement.querySelector(this.selectors.field.input),
|
dropZone: fieldElement.querySelector(this.selectors.field.dropZone),
|
preview: fieldElement.querySelector(this.selectors.field.preview),
|
progress: {
|
progress: fieldElement.querySelector(this.selectors.field.progress),
|
bar: fieldElement.querySelector('.bar'),
|
fill: fieldElement.querySelector('.fill'),
|
details: fieldElement.querySelector('.details'),
|
text: fieldElement.querySelector('.details .text'),
|
count: fieldElement.querySelector('.details .count')
|
}
|
};
|
|
let display = fieldElement.querySelector('.group-display');
|
if (display) {
|
UI.groups = {
|
display: display,
|
container: fieldElement.querySelector('.item-grid.groups'),
|
empty: fieldElement.querySelector('.empty-group'),
|
groups: new Map()
|
};
|
}
|
|
return UI;
|
}
|
|
/**
|
* Set up core event listeners
|
*/
|
initListeners() {
|
this.clickHandler = this.handleClick.bind(this);
|
this.changeHandler = this.handleChange.bind(this);
|
|
document.addEventListener('click', this.clickHandler);
|
document.addEventListener('change', this.changeHandler);
|
|
// External file drops
|
this.dragEnterHandler = this.handleExternalDragEnter.bind(this);
|
this.dragLeaveHandler = this.handleExternalDragLeave.bind(this);
|
this.dragOverHandler = this.handleExternalDragOver.bind(this);
|
this.dropHandler = this.handleExternalDrop.bind(this);
|
|
document.addEventListener('dragenter', this.dragEnterHandler);
|
document.addEventListener('dragleave', this.dragLeaveHandler);
|
document.addEventListener('dragover', this.dragOverHandler);
|
document.addEventListener('drop', this.dropHandler);
|
}
|
|
/**
|
* Initialize group-specific features (drag & drop for rearranging)
|
*/
|
initGroupFeatures() {
|
// Initialize drag controller for rearranging items
|
this.dragController = new window.jvbDragHandler({
|
// What can be dragged
|
draggableSelector: this.selectors.items.item,
|
|
// Where items can be dropped
|
dropTargetSelector: `${this.selectors.field.preview}, ${this.selectors.groups.grid}, .empty-group`,
|
|
// Don't start drag on interactive elements
|
ignoreSelector: 'input:not(.upload-select), button, select, textarea, details, summary, a',
|
previewElement: 'img, video, .icon',
|
|
// Extract upload ID from element
|
getItemId: (element) => {
|
return element.dataset.uploadId;
|
},
|
|
// Get selected items for multi-drag
|
getSelectedItems: (element) => {
|
const fieldId = this.getFieldIdFromElement(element);
|
const uploadId = element.dataset.uploadId;
|
const selected = this.getCurrentSelection(fieldId);
|
|
if (selected && selected.includes(uploadId)) {
|
return selected;
|
}
|
|
return [uploadId];
|
},
|
|
// Validate drop location
|
validateDrop: (itemIds, targetElement) => {
|
const targetFieldId = this.getFieldIdFromElement(targetElement);
|
const itemElement = document.querySelector(`[data-upload-id="${itemIds[0]}"]`);
|
const itemFieldId = this.getFieldIdFromElement(itemElement);
|
|
return targetFieldId === itemFieldId;
|
},
|
|
// Handle successful drop
|
onDrop: (itemIds, targetElement) => {
|
this.handleItemDrop(itemIds, targetElement);
|
targetElement.scrollIntoView({behavior:'smooth', block:'center'});
|
},
|
|
// Optional callbacks
|
onDragStart: (itemIds) => {
|
},
|
|
onDragEnd: (itemIds, success) => {
|
if (success) {
|
// Clear selection after successful move
|
const itemElement = document.querySelector(`[data-upload-id="${itemIds[0]}"]`);
|
const fieldId = this.getFieldIdFromElement(itemElement);
|
const handler = this.selectionHandlers.get(fieldId);
|
handler?.clearSelection();
|
}
|
},
|
|
// Preview options
|
previewOptions: {
|
multiOffset: { x: -60, y: -80 },
|
singleOffset: { x: -50, y: -60 },
|
showCount: true
|
}
|
});
|
}
|
|
/*******************************************************************************
|
* EXTERNAL FILE DROP HANDLERS (for new uploads from desktop)
|
*******************************************************************************/
|
|
handleExternalDragLeave(e) {
|
const dropZone = e.target.closest(this.selectors.field.dropZone);
|
if (dropZone && !dropZone.contains(e.relatedTarget)) {
|
dropZone.classList.remove('dragover');
|
}
|
}
|
handleExternalDragEnter(e) {
|
if (!e.dataTransfer.types.includes('Files')) {
|
return;
|
}
|
|
const dropZone = e.target.closest(this.selectors.field.dropZone);
|
|
if (dropZone) {
|
e.preventDefault();
|
dropZone.classList.add('dragover');
|
}
|
}
|
|
handleExternalDragOver(e) {
|
if (!e.dataTransfer.types.includes('Files')) return;
|
|
const dropZone = e.target.closest(this.selectors.field.dropZone);
|
if (dropZone) {
|
e.preventDefault();
|
e.dataTransfer.dropEffect = 'copy';
|
}
|
}
|
|
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)
|
*******************************************************************************/
|
|
/**
|
* Handle items being dropped (called by DragController)
|
*/
|
handleItemDrop(itemIds, targetElement) {
|
const isPreviewDrop = targetElement.classList.contains('preview');
|
let actualTarget = targetElement;
|
|
// Handle drop on empty group placeholder
|
if (targetElement.classList.contains('empty-group')) {
|
const fieldId = this.getFieldIdFromElement(targetElement);
|
const group = this.createGroup(fieldId);
|
|
if (!group) {
|
console.error('Failed to create group');
|
return;
|
}
|
|
actualTarget = group.grid;
|
}
|
|
// Move each item to target
|
itemIds.forEach(uploadId => {
|
if (isPreviewDrop) {
|
// Moving back to preview (ungrouping)
|
this.removeFromGroup(uploadId);
|
} else {
|
// Moving to a group
|
this.addToGroup(uploadId, actualTarget);
|
}
|
});
|
|
// Persist state
|
const fieldId = this.getFieldIdFromElement(targetElement);
|
this.schedulePersistance(fieldId);
|
|
// Announce for accessibility
|
const message = itemIds.length > 1
|
? `Moved ${itemIds.length} items`
|
: 'Moved item';
|
this.a11y.announce(message);
|
}
|
|
/*******************************************************************************
|
* CLICK HANDLERS
|
*******************************************************************************/
|
|
handleClick(e) {
|
// File input triggers
|
if (e.target.matches(this.selectors.field.dropZone) ||
|
e.target.closest(this.selectors.field.dropZone)) {
|
const dropZone = e.target.closest(this.selectors.field.dropZone);
|
if (dropZone && !e.target.matches('input, button, a')) {
|
const input = dropZone.querySelector(this.selectors.field.input);
|
input?.click();
|
}
|
}
|
|
// Group actions
|
const actionButton = e.target.closest('[data-action]');
|
if (actionButton) {
|
this.handleAction(actionButton);
|
}
|
}
|
|
handleChange(e) {
|
const fieldId = this.getFieldIdFromElement(e.target);
|
// File input change
|
if (e.target.matches(this.selectors.field.input)) {
|
const fieldId = this.getFieldIdFromElement(e.target);
|
const files = Array.from(e.target.files);
|
|
if (files.length > 0 && fieldId) {
|
this.processFiles(fieldId, files);
|
}
|
}
|
|
// Meta field changes
|
if (fieldId) {
|
if (this.fields.get(fieldId).config.destination === 'post_group') {
|
this.handleGroupMetaChange(e.target);
|
} else {
|
this.queueUploadMeta(e);
|
}
|
}
|
}
|
|
/********************************************************************************
|
UTILITY
|
********************************************************************************/
|
getCurrentSelection(fieldId) {
|
let selected = [];
|
for (let [key, handler] of this.selectionHandlers) {
|
if ((fieldId === key || key.includes(fieldId)) && handler.selectedItems.size > 0) {
|
selected = selected.concat([... handler.selectedItems]);
|
}
|
}
|
return selected;
|
}
|
|
getSubtypeFromMime(mimeType) {
|
if (mimeType.startsWith('image/')) return 'image';
|
if (mimeType.startsWith('video/')) return 'video';
|
return 'document';
|
}
|
|
getStatusText(status) {
|
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;
|
}
|
if (field.ui.groups.display) {
|
field.ui.groups.display.hidden = false;
|
}
|
|
const totalFiles = files.length;
|
let processedCount = 0;
|
|
// Show initial progress
|
this.updateUploadProgress(fieldId, 0, totalFiles, 'Processing files...');
|
|
// Initialize field uploads set if needed
|
if (!field.uploads) {
|
field.uploads = new Set();
|
}
|
|
// Process files
|
const processPromises = Array.from(files).map(async (file, index) => {
|
try {
|
|
// Create upload ID
|
const uploadId = `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
// Create upload data
|
const uploadData = {
|
id: uploadId,
|
attachment_id: null,
|
fieldId: fieldId,
|
originalFile: file,
|
processedFile: null,
|
preview: null,
|
status: 'local_processing',
|
element: null,
|
location: null,
|
meta: {
|
originalName: file.name,
|
size: file.size,
|
type: file.type
|
}
|
};
|
|
// Create preview URL
|
uploadData.preview = this.createPreviewUrl(file);
|
|
// Process the file (resize if image)
|
if (file.type.startsWith('image/')) {
|
uploadData.processedFile = await this.processImage(file, field.subtype);
|
} else {
|
uploadData.processedFile = file;
|
}
|
|
// Store blob data separately in IndexedDB
|
await this.uploadStore.saveBlob(uploadId, uploadData.processedFile || file);
|
|
// Create DOM element
|
const subtype = this.getSubtypeFromMime(file.type);
|
uploadData.element = this.createUploadElement({
|
...uploadData,
|
subtype: subtype
|
}, field.config.destination === 'post_group');
|
|
// Show progress on the item
|
this.showUploadProgress(uploadId, true);
|
this.updateUploadItemProgress(uploadId, 50, 'local_processing');
|
|
// Add to preview grid
|
if (field.ui.preview) {
|
field.ui.preview.appendChild(uploadData.element);
|
uploadData.location = field.ui.preview;
|
}
|
|
// Store upload
|
this.uploads.set(uploadId, uploadData);
|
field.uploads.add(uploadId);
|
|
// Update progress
|
processedCount++;
|
this.updateUploadProgress(fieldId, processedCount, totalFiles, 'Processing files...');
|
this.updateUploadItemProgress(uploadId, 100, 'processed');
|
uploadData.status = 'processed';
|
|
// Fade out item progress after a moment
|
setTimeout(() => {
|
this.showUploadProgress(uploadId, false);
|
}, 1000);
|
|
return uploadId;
|
|
} catch (error) {
|
console.error('Error processing file:', file.name, error);
|
processedCount++;
|
this.updateUploadProgress(fieldId, processedCount, totalFiles, 'Processing files...');
|
return null;
|
}
|
});
|
|
// Wait for all files to process
|
await Promise.all(processPromises);
|
|
this.updateFieldState(fieldId);
|
// Cache the state (now without DOM references)
|
await this.schedulePersistance(fieldId);
|
|
// Queue for upload if in direct mode
|
if (field.config.destination !== 'post_group') {
|
await this.queueUpload(fieldId);
|
// 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;
|
}
|
|
async processImage(file, uploadId) {
|
const timeout = this.worker.settings.timeout;
|
|
return new Promise((resolve, reject) => {
|
let timeoutId;
|
let taskCompleted = false;
|
|
// Set timeout
|
timeoutId = setTimeout(() => {
|
if (!taskCompleted) {
|
taskCompleted = true;
|
|
// Remove from active tasks
|
this.worker.tasks.delete(uploadId);
|
|
// Maybe restart worker if configured
|
if (this.worker.settings.restartAfterTimeout) {
|
this.restartCompressionWorker();
|
}
|
|
reject(new Error(`Processing timeout for ${file.name}`));
|
}
|
}, timeout);
|
|
// Track this task
|
this.worker.tasks.set(uploadId, { file, timeoutId });
|
|
// Process image
|
this.handleProcess(file, uploadId)
|
.then(result => {
|
if (!taskCompleted) {
|
taskCompleted = true;
|
clearTimeout(timeoutId);
|
this.worker.tasks.delete(uploadId);
|
resolve(result);
|
}
|
})
|
.catch(error => {
|
if (!taskCompleted) {
|
taskCompleted = true;
|
clearTimeout(timeoutId);
|
this.worker.tasks.delete(uploadId);
|
reject(error);
|
}
|
});
|
});
|
}
|
|
async handleProcess(file, uploadId) {
|
// Skip non-images
|
if (!file.type.startsWith('image/')) {
|
return file;
|
}
|
|
const maxDimension = this.getMaxDimension();
|
const quality = 0.85;
|
|
// Try worker first if available
|
if (this.shouldUseWorker(file)) {
|
try {
|
// Ensure worker is initialized
|
if (!this.worker.worker) {
|
this.initCompressionWorker();
|
}
|
|
if (this.worker.worker) {
|
return await this.processWithWorker(file, uploadId, maxDimension, quality);
|
}
|
} catch (error) {
|
console.warn('Worker processing failed, falling back to main thread:', error);
|
}
|
}
|
|
// Fallback to main thread
|
return await this.processOnMainThread(file, maxDimension, quality);
|
}
|
|
/**
|
* Process image on main thread with better error handling
|
*/
|
async processOnMainThread(file, maxDimension, quality) {
|
return new Promise((resolve, reject) => {
|
const img = new Image();
|
const canvas = document.createElement('canvas');
|
const ctx = canvas.getContext('2d');
|
let objectUrl = null;
|
|
const cleanup = () => {
|
img.onload = null;
|
img.onerror = null;
|
if (objectUrl) {
|
URL.revokeObjectURL(objectUrl);
|
objectUrl = null;
|
}
|
// Explicitly clean up canvas
|
canvas.width = 1;
|
canvas.height = 1;
|
ctx.clearRect(0, 0, 1, 1);
|
};
|
|
img.onload = () => {
|
try {
|
const { width, height } = this.calculateOptimalDimensions(img, maxDimension);
|
canvas.width = width;
|
canvas.height = height;
|
|
// Enhanced image smoothing
|
ctx.imageSmoothingEnabled = true;
|
ctx.imageSmoothingQuality = 'high';
|
ctx.drawImage(img, 0, 0, width, height);
|
|
const outputFormat = this.getOptimalFormat(file);
|
const outputQuality = this.getOptimalQuality(file, quality);
|
|
canvas.toBlob(
|
(blob) => {
|
cleanup();
|
if (blob) {
|
const processedFile = new File(
|
[blob],
|
this.getProcessedFileName(file, outputFormat),
|
{ type: outputFormat, lastModified: Date.now() }
|
);
|
resolve(processedFile);
|
} else {
|
reject(new Error('Canvas toBlob failed'));
|
}
|
},
|
outputFormat,
|
outputQuality
|
);
|
|
} catch (error) {
|
cleanup();
|
reject(new Error(`Canvas processing failed: ${error.message}`));
|
}
|
};
|
|
img.onerror = () => {
|
cleanup();
|
reject(new Error(`Failed to load image: ${file.name}`));
|
};
|
|
try {
|
objectUrl = this.createPreviewUrl(file);
|
img.src = objectUrl;
|
} catch (error) {
|
cleanup();
|
reject(new Error(`Failed to create object URL: ${error.message}`));
|
}
|
});
|
}
|
|
/**
|
* Get optimal output format
|
*/
|
getOptimalFormat(file) {
|
// Keep original format for certain types
|
if (file.type === 'image/gif' || file.type === 'image/svg+xml') {
|
return file.type;
|
}
|
|
// Use WebP if supported, otherwise JPEG
|
return this.supportsWebP() ? 'image/webp' : 'image/jpeg';
|
}
|
|
/**
|
* Get optimal quality setting
|
*/
|
getOptimalQuality(file, requestedQuality) {
|
// Higher quality for smaller files
|
if (file.size < 500 * 1024) return Math.max(requestedQuality, 0.9);
|
if (file.size < 2 * 1024 * 1024) return requestedQuality;
|
|
// Lower quality for very large files
|
return Math.min(requestedQuality, 0.8);
|
}
|
|
/**
|
* Generate processed file name
|
*/
|
getProcessedFileName(originalFile, outputFormat) {
|
const baseName = originalFile.name.replace(/\.[^/.]+$/, '');
|
|
const extensions = {
|
'image/webp': '.webp',
|
'image/jpeg': '.jpg',
|
'image/png': '.png',
|
'image/gif': '.gif'
|
};
|
|
return baseName + (extensions[outputFormat] || '.jpg');
|
}
|
|
/**
|
* Get maximum dimension based on device capabilities
|
*/
|
getMaxDimension() {
|
const screenWidth = window.screen.width;
|
const devicePixelRatio = window.devicePixelRatio || 1;
|
|
// Scale based on device capabilities
|
if (screenWidth * devicePixelRatio > 2560) return 2400;
|
if (screenWidth * devicePixelRatio > 1920) return 1920;
|
return 1200;
|
}
|
|
/**
|
* Determine if we should use Web Worker
|
*/
|
shouldUseWorker(file) {
|
// Use worker for large files or when available
|
return this.worker.worker &&
|
file.size > 1024 * 1024 && // > 1MB
|
typeof OffscreenCanvas !== 'undefined';
|
}
|
|
async processWithWorker(file, uploadId, maxDimension, quality) {
|
return new Promise((resolve, reject) => {
|
if (!this.worker.worker) {
|
reject(new Error('Worker not available'));
|
return;
|
}
|
|
// Create unique message ID for this task
|
const messageId = `${uploadId}_${Date.now()}`;
|
|
// Handler for this specific message
|
const messageHandler = (e) => {
|
if (e.data.messageId !== messageId) return;
|
|
// Remove handler
|
this.worker.worker.removeEventListener('message', messageHandler);
|
this.worker.worker.removeEventListener('error', errorHandler);
|
|
if (e.data.success) {
|
const processedFile = new File(
|
[e.data.blob],
|
this.getProcessedFileName(file, e.data.format || 'image/webp'),
|
{ type: e.data.format || 'image/webp', lastModified: Date.now() }
|
);
|
resolve(processedFile);
|
} else {
|
reject(new Error(e.data.error || 'Worker processing failed'));
|
}
|
};
|
|
const errorHandler = (error) => {
|
this.worker.worker.removeEventListener('message', messageHandler);
|
this.worker.worker.removeEventListener('error', errorHandler);
|
reject(new Error(`Worker error: ${error.message}`));
|
};
|
|
// Add handlers
|
this.worker.worker.addEventListener('message', messageHandler);
|
this.worker.worker.addEventListener('error', errorHandler);
|
|
// Send message to worker
|
this.worker.worker.postMessage({
|
messageId,
|
file,
|
maxDimension,
|
quality,
|
outputFormat: this.getOptimalFormat(file)
|
});
|
});
|
}
|
|
/**
|
* Restart compression worker
|
*/
|
restartCompressionWorker() {
|
// Terminate existing worker
|
if (this.worker.worker) {
|
this.worker.worker.terminate();
|
this.worker.worker = null;
|
}
|
|
// Clear active tasks
|
this.worker.tasks.clear();
|
|
// Check restart limit
|
if (this.worker.restart.count >= this.worker.restart.max) {
|
console.error('Max worker restarts reached, disabling worker');
|
return;
|
}
|
|
this.worker.restart.count++;
|
|
// Reinitialize
|
this.initCompressionWorker();
|
}
|
|
/**
|
* Initialize Web Worker for image compression
|
*/
|
initCompressionWorker() {
|
if (this.worker.worker || typeof Worker === 'undefined') return;
|
|
try {
|
const workerScript = `
|
self.onmessage = async function(e) {
|
const { messageId, file, maxDimension, quality, outputFormat } = e.data;
|
|
try {
|
// Create ImageBitmap from file
|
const bitmap = await createImageBitmap(file);
|
|
// Calculate dimensions
|
const scale = Math.min(maxDimension / bitmap.width, maxDimension / bitmap.height, 1);
|
const width = Math.round(bitmap.width * scale);
|
const height = Math.round(bitmap.height * scale);
|
|
// Create OffscreenCanvas
|
const canvas = new OffscreenCanvas(width, height);
|
const ctx = canvas.getContext('2d');
|
|
// Draw and resize
|
ctx.imageSmoothingEnabled = true;
|
ctx.imageSmoothingQuality = 'high';
|
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
// Clean up bitmap
|
bitmap.close();
|
|
// Convert to blob
|
const blob = await canvas.convertToBlob({
|
type: outputFormat,
|
quality: quality
|
});
|
|
self.postMessage({
|
messageId,
|
success: true,
|
blob: blob,
|
format: outputFormat
|
});
|
|
} catch (error) {
|
self.postMessage({
|
messageId,
|
success: false,
|
error: error.message
|
});
|
}
|
};
|
`;
|
|
const blob = new Blob([workerScript], { type: 'application/javascript' });
|
this.worker.worker = new Worker(this.createPreviewUrl(blob));
|
|
} catch (error) {
|
console.warn('Failed to initialize compression worker:', error);
|
this.worker.worker = null;
|
}
|
}
|
|
/**
|
* Calculate optimal dimensions with aspect ratio preservation
|
*/
|
calculateOptimalDimensions(img, maxDimension) {
|
let { width, height } = img;
|
|
// Don't upscale
|
if (width <= maxDimension && height <= maxDimension) {
|
return { width, height };
|
}
|
|
// Calculate scale factor
|
const scale = Math.min(maxDimension / width, maxDimension / height);
|
|
return {
|
width: Math.round(width * scale),
|
height: Math.round(height * scale)
|
};
|
}
|
|
|
/**
|
* Check WebP support
|
*/
|
supportsWebP() {
|
const canvas = document.createElement('canvas');
|
return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0;
|
}
|
|
createPreviewUrl(file) {
|
const url = URL.createObjectURL(file);
|
// Track for cleanup
|
if (!this.previewUrls) this.previewUrls = new Set();
|
this.previewUrls.add(url);
|
return url;
|
}
|
|
revokePreviewUrl(url) {
|
if (url?.startsWith('blob:')) {
|
URL.revokeObjectURL(url);
|
this.previewUrls?.delete(url);
|
}
|
}
|
|
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) {
|
return;
|
}
|
|
let uploads = Array.from(field.uploads);
|
if (uploads.length === 0) {
|
this.error.log('No uploads to upload', {
|
component: 'UploadManager',
|
action: 'submitGroupedUploads',
|
fieldId: fieldId
|
});
|
return;
|
}
|
|
const fieldGroups = this.getFieldGroups(fieldId);
|
|
if (fieldGroups.length === 0) {
|
this.error.log('No groups created for post_group upload', {
|
component: 'UploadManager',
|
action: 'submitGroupedUploads',
|
fieldId: fieldId
|
});
|
return;
|
}
|
|
// Build posts array from groups
|
const posts = [];
|
const formData = new FormData();
|
let uploadMap = [];
|
|
uploads = uploads.map((upload) => {
|
return this.uploads.get(upload);
|
});
|
|
fieldGroups.forEach((group, groupIndex) => {
|
const post = {
|
images: [],
|
fields: {}
|
};
|
for (let [name, value] of Object.entries(group.changes)) {
|
post.fields[name] = value;
|
}
|
|
let groupUploads = uploads.filter((upload) => {
|
return upload['groupId'] === group.id;
|
});
|
|
groupUploads.forEach((upload) => {
|
if (upload) {
|
const fileToUpload = upload.processedFile || upload.originalFile;
|
if (fileToUpload) {
|
formData.append('files[]', fileToUpload);
|
|
const imageData = {
|
upload_id: upload.id,
|
index: uploadMap.length
|
};
|
post.images.push(imageData);
|
uploadMap.push(upload.id);
|
}
|
}
|
});
|
// Add images for this group
|
// group.uploads.forEach(uploadId => {
|
// const upload = this.uploads.get(uploadId);
|
// if (upload) {
|
// const fileToUpload = upload.processedFile || upload.originalFile;
|
// if (fileToUpload) {
|
// formData.append('files[]', fileToUpload);
|
//
|
// const imageData = {
|
// upload_id: upload.id,
|
// index: uploadMap.length
|
// };
|
//
|
// // Check if this is the featured image
|
// const radioInput = upload.element?.querySelector('[name="featured"]');
|
// if (radioInput?.checked) {
|
// post.fields.featured = upload.id;
|
// }
|
//
|
// post.images.push(imageData);
|
// uploadMap.push(upload.id);
|
// }
|
// }
|
// });
|
|
posts.push(post);
|
});
|
|
//Each remaining upload (without a groupId) becomes its own post
|
let remainingUploads = uploads.filter((upload) => {
|
return !Object.hasOwn(upload, 'groupId');
|
});
|
|
remainingUploads.forEach((upload) => {
|
if (upload) {
|
|
const post = {
|
images: [],
|
fields: {}
|
};
|
const fileToUpload = upload.processedFile || upload.originalFile;
|
if (fileToUpload) {
|
formData.append('files[]', fileToUpload);
|
|
const imageData = {
|
upload_id: upload.id,
|
index: uploadMap.length
|
};
|
post.images.push(imageData);
|
uploadMap.push(upload.id);
|
}
|
posts.push(post);
|
}
|
});
|
|
|
// Add metadata to FormData
|
formData.append('content', field.config.content);
|
formData.append('user', field.config.itemID); // Assuming itemID is user ID
|
formData.append('posts', JSON.stringify(posts));
|
formData.append('upload_ids', JSON.stringify(uploadMap));
|
|
for (const [key, value] of formData.entries()) {
|
console.log(key, value);
|
}
|
const operation = {
|
endpoint: 'uploads/groups',
|
method: 'POST',
|
data: formData,
|
title: `Creating ${posts.length} ${field.config.content}${posts.length > 1 ? 's' : ''} from uploads...`,
|
popup: `Creating ${posts.length} post${posts.length > 1 ? 's' : ''}...`,
|
canMerge: false,
|
headers: {
|
'action_nonce': jvbSettings.dash
|
},
|
append: '_upload',
|
};
|
|
try {
|
const operationId = await this.queue.addToQueue(operation);
|
|
uploads.forEach(uploadId => {
|
let upload = this.uploads.get(uploadId);
|
if (upload) {
|
upload.operationId = operationId;
|
this.updateUploadStatus(uploadId, 'queued');
|
}
|
});
|
|
field.operationId = operationId;
|
this.a11y.announce(`Creating ${posts.length} post${posts.length > 1 ? 's' : ''} from your uploads`);
|
|
return operationId;
|
} catch (error) {
|
this.error.log(error, {
|
component: 'UploadManager',
|
action: 'submitGroupedUploads',
|
fieldId: fieldId
|
});
|
throw error;
|
} finally {
|
this.schedulePersistance(field.id);
|
}
|
}
|
|
async queueUpload(fieldId) {
|
const field = this.fields.get(fieldId);
|
if (!field?.uploads) return;
|
|
const uploads = Array.from(field.uploads);
|
if (uploads.length === 0) {
|
return;
|
}
|
|
const data = this.prepareUploadData(field, uploads);
|
this.a11y.announce('Queuing for upload');
|
let img = (uploads.length === 1) ? 'file' : 'files';
|
const operation = {
|
endpoint: 'uploads',
|
method: 'POST',
|
data: data,
|
title: `Uploading ${uploads.length} ${img} to server...`,
|
popup: `Uploading ${uploads.length} ${img}...`,
|
canMerge: false,
|
headers: {
|
'action_nonce': jvbSettings.dash
|
},
|
append: '_upload'
|
}
|
try {
|
const operationId = await this.queue.addToQueue(operation);
|
|
uploads.forEach(uploadId => {
|
let upload = this.uploads.get(uploadId);
|
if (!upload) {
|
return;
|
}
|
upload.operationId = operationId;
|
this.updateUploadStatus(uploadId, 'queued');
|
});
|
field.operationId = operationId;
|
|
return operationId;
|
} catch (error) {
|
throw error;
|
} finally {
|
this.schedulePersistance(field.id);
|
}
|
}
|
|
prepareUploadData(field, uploads) {
|
|
const formData = new FormData();
|
formData.append('content', field.config.content);
|
formData.append('mode', field.config.mode);
|
formData.append('field_name', field.config.name);
|
formData.append('fieldId', field.id);
|
formData.append('field_type', field.config.type);
|
formData.append('subtype', field.config.subtype);
|
formData.append('item_id', field.config.itemID); //post, term, or user id
|
formData.append('destination', field.config.destination || 'meta'); //meta, post, post_group
|
let uploadMap = [];
|
|
const fieldGroups = this.getFieldGroups(field.id);
|
if (field.config.destination === 'post_group' && fieldGroups.length > 0) {
|
// User has created groups
|
let groups = [];
|
let titles = [];
|
let featuredImages = [];
|
|
fieldGroups.forEach(group => {
|
let groupUploadIndices = [];
|
let featuredIndex = null;
|
|
group.uploads.forEach(uploadId => {
|
let upload = this.uploads.get(uploadId);
|
if (upload) {
|
const fileToUpload = upload.processedFile || upload.originalFile;
|
if (fileToUpload) {
|
formData.append('files[]', fileToUpload);
|
const fileIndex = uploadMap.length;
|
uploadMap.push(upload.id);
|
groupUploadIndices.push(upload.id);
|
|
// Check if this is the featured image
|
const radioInput = upload.element?.querySelector('[name="featured"]');
|
if (radioInput?.checked) {
|
featuredIndex = upload.id;
|
}
|
}
|
}
|
});
|
|
groups.push(groupUploadIndices);
|
titles.push(group.title || '');
|
featuredImages.push(featuredIndex);
|
});
|
|
formData.append('groups', JSON.stringify(groups));
|
formData.append('group_titles', JSON.stringify(titles));
|
formData.append('featured_images', JSON.stringify(featuredImages));
|
} else {
|
// No groups - just append all files
|
uploads.forEach(uploadId => {
|
let upload = this.uploads.get(uploadId);
|
if (upload) {
|
const fileToUpload = upload.processedFile || upload.originalFile;
|
if (fileToUpload) {
|
formData.append('files[]', fileToUpload);
|
uploadMap.push(upload.id);
|
}
|
}
|
});
|
}
|
formData.append('upload_ids', JSON.stringify(uploadMap));
|
|
// console.log('Final FormData:');
|
// for (let pair of formData.entries()) {
|
// console.log(pair[0], pair[1]);
|
// }
|
|
return formData;
|
}
|
|
getFieldGroups(fieldId) {
|
const groups = [];
|
|
this.groups.forEach((groupData, groupId) => {
|
if (groupData.fieldId === fieldId) {
|
const field = this.fields.get(fieldId);
|
const groupElement = field?.ui?.groups?.groups?.get(groupId);
|
|
groups.push({
|
id: groupId,
|
uploads: Array.from(groupData.uploads || new Set()),
|
changes: groupData.changes || {},
|
element: groupElement || null
|
});
|
}
|
});
|
|
return groups;
|
}
|
|
async queueUploadMeta(e) {
|
const upload = this.getUploadFromElement(e.target);
|
if (!upload) return;
|
|
const field = this.fields.get(upload.fieldId);
|
if (!field) return;
|
|
const container = e.target.closest('.upload-meta');
|
if (!container) return;
|
|
let data = {};
|
data[e.target.name] = e.target.value;
|
|
upload.meta = {
|
...upload.meta,
|
... data
|
};
|
|
let queueData = {};
|
//If there is an attachment ID, use that: else, use our generated upload id
|
queueData[upload.attachmentId??upload.id] = upload.meta;
|
|
const operation = {
|
endpoint: 'uploads/meta',
|
method: 'POST',
|
data: queueData,
|
title: `Updating meta`,
|
canMerge: true,
|
headers: {
|
'action_nonce': jvbSettings.dash
|
}
|
};
|
|
try {
|
await this.queue.addToQueue(operation);
|
} catch (error) {
|
this.error.log(error, {
|
component: 'UploadManager',
|
action: 'sendMetaUpdate',
|
uploadId: upload.id
|
});
|
}
|
}
|
/*******************************************************************************
|
* GROUP MANAGEMENT
|
*******************************************************************************/
|
|
createGroup(fieldKey, groupId = null) {
|
const field = this.fields.get(fieldKey);
|
if (!field) {
|
console.error('Field not found:', fieldKey);
|
return null;
|
}
|
|
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]`;
|
}
|
if (excerptInput) {
|
excerptInput.id = `${groupId}_excerpt`;
|
excerptInput.name = `${groupId}[post_excerpt]`;
|
}
|
let field = this.fields.get(fieldId);
|
if (field.config.content !== '') {
|
let summary = groupElement.querySelector('summary');
|
summary.textContent = field.config.content + ' Fields';
|
}
|
} else {
|
groupElement.querySelector('details').remove();
|
}
|
|
const gridContainer = groupElement.querySelector('.item-grid.group');
|
if (gridContainer) {
|
gridContainer.dataset.groupId = groupId;
|
}
|
|
return groupElement;
|
}
|
|
deleteGroup(groupId, confirm = true) {
|
let group = this.groups.get(groupId);
|
if (!group) {
|
return;
|
}
|
|
let keepUploads = true;
|
if (confirm && group.uploads && group.uploads.size > 0) {
|
keepUploads = !window.confirm('Delete uploads in group?');
|
}
|
|
if (confirm && keepUploads) {
|
// Move any remaining uploads back to preview
|
if (group.uploads && group.uploads.size > 0) {
|
Array.from(group.uploads).forEach(uploadId => {
|
this.addImageToGroup(uploadId, null, false);
|
});
|
}
|
}
|
|
// Remove from groups Map
|
this.groups.delete(groupId);
|
|
// Remove DOM element
|
let groupElement = group.element;
|
if (groupElement) {
|
groupElement.remove();
|
this.a11y.announce('Group removed');
|
}
|
|
this.schedulePersistance(group.fieldId);
|
}
|
|
addToGroup(uploadId, target = null, persist = true) {
|
let upload = this.uploads.get(uploadId);
|
if(!upload) {
|
return;
|
}
|
let field = this.fields.get(upload.fieldId);
|
if (!field) {
|
return;
|
}
|
|
//Already in the Preview Grid, or already in the group we're moving to
|
if ((!target && upload.location === field.ui.preview) || target === upload.location) {
|
return;
|
}
|
|
// Remove from previous location
|
if (upload.location) {
|
let groupId = upload.location.dataset.groupId;
|
if (groupId) {
|
let group = this.groups.get(groupId);
|
if (group && group.uploads) {
|
group.uploads.delete(uploadId);
|
|
if (group.uploads.size === 0) {
|
this.deleteGroup(groupId);
|
}
|
}
|
}
|
}
|
|
const checkbox = upload.element.querySelector('[name*="select-item"]');
|
if (checkbox) {
|
checkbox.checked = false;
|
}
|
|
let featured = upload.element.querySelector('[name="featured"]');
|
featured.hidden = !target;
|
|
|
//If no target, it's going to the preview grid
|
if (!target) {
|
target = field.ui.preview;
|
upload.groupId = null;
|
} else if (!target.classList.contains('item-grid') || !target.classList.contains('preview')) {
|
// It's a group target
|
let groupId = target.dataset.groupId;
|
featured.name = groupId+'_'+featured.name;
|
let group = this.groups.get(groupId);
|
if (!group) {
|
group = this.createGroup(upload.fieldId);
|
target = group.grid;
|
groupId = group.id;
|
}
|
if (group) {
|
group.uploads.add(uploadId);
|
upload.groupId = groupId;
|
}
|
|
}
|
|
upload.location = target;
|
target.append(upload.element);
|
|
if (persist) {
|
this.schedulePersistance(field.id);
|
}
|
}
|
|
removeFromGroup(uploadId) {
|
const upload = this.uploads.get(uploadId);
|
if (!upload) return;
|
|
const field = this.fields.get(upload.fieldId);
|
if (!field) return;
|
|
// Remove from current group if in one
|
if (upload.groupId) {
|
const group = this.groups.get(upload.groupId);
|
if (group?.uploads) {
|
group.uploads.delete(uploadId);
|
|
// Delete empty group
|
if (group.uploads.size === 0) {
|
this.deleteGroup(upload.groupId, false);
|
}
|
}
|
upload.groupId = null;
|
}
|
|
// Move back to preview
|
if (field.ui?.preview) {
|
field.ui.preview.appendChild(upload.element);
|
upload.location = field.ui.preview;
|
}
|
|
// Hide featured radio
|
const featured = upload.element.querySelector('[name="featured"]');
|
if (featured) {
|
featured.hidden = true;
|
featured.checked = false;
|
}
|
}
|
|
removeUpload(fieldId, uploadId) {
|
const field = this.fields.get(fieldId);
|
const upload = this.uploads.get(uploadId);
|
|
if (!field || !upload) return;
|
|
// Remove from field
|
field.uploads?.delete(uploadId);
|
|
// Remove from group if grouped
|
if (upload.groupId) {
|
const group = this.groups.get(upload.groupId);
|
if (group && group.uploads) {
|
group.uploads.delete(uploadId);
|
|
if (group.uploads.size === 0) {
|
this.removeGroup(upload.groupId);
|
}
|
}
|
}
|
|
// Clean up element
|
upload.element?.remove();
|
|
// Clean up memory
|
this.clearUpload(uploadId);
|
|
// Update field state after removal
|
this.updateFieldState(fieldId);
|
|
// Update UI
|
this.maybeLockUploads(fieldId);
|
const handler = this.selectionHandlers.get(field.id);
|
if (handler) {
|
handler.deselect(uploadId);
|
}
|
|
this.a11y.announce('Upload removed');
|
}
|
|
/*******************************************************************************
|
* STATE MANAGEMENT
|
*******************************************************************************/
|
schedulePersistance(fieldId) {
|
const key = `persist_${fieldId}`;
|
window.debouncer.schedule(
|
key,
|
() => this.persistFieldState(fieldId),
|
1000
|
);
|
}
|
|
async persistFieldState(fieldId) {
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
// Convert Sets to Arrays for storage
|
const fieldData = {
|
...field,
|
id: fieldId, // Use as primary key
|
fieldId: fieldId,
|
uploads: Array.from(field.uploads || []).map(uploadId => {
|
return this.uploads.get(uploadId);;
|
}),
|
groups: Array.from(this.groups.entries())
|
.filter(([id, data]) => data.fieldId === fieldId && data.uploads && data.uploads.size > 0)
|
.map(([id, data]) => ({
|
id: data.id,
|
uploads: Array.from(data.uploads),
|
changes: data.changes || {}
|
})),
|
|
// Context for restoration
|
context: {
|
url: this.normalizeUrl(window.location.href),
|
fullUrl: window.location.href,
|
modalType: this.getModalType(field),
|
formId: field.formId,
|
fieldSelector: `.field.upload[data-field="${field.config.name}"]`
|
},
|
timestamp: Date.now()
|
};
|
|
// Save to store
|
await this.fieldStore.save(fieldData);
|
}
|
normalizeUrl(url) {
|
try {
|
const urlObj = new URL(url);
|
// Return just the origin + pathname (no query string or hash)
|
return urlObj.origin + urlObj.pathname;
|
} catch (e) {
|
return url;
|
}
|
}
|
|
/**
|
* Get uploads for a field, optionally cleaned for storage
|
* @param {string} fieldId
|
* @param {boolean} clean - Remove DOM references for IndexedDB storage
|
* @returns {Array}
|
*/
|
getFieldUploads(fieldId, clean = false) {
|
const field = this.fields.get(fieldId);
|
if (!field || !field.uploads) return [];
|
|
return Array.from(field.uploads)
|
.map(uploadId => {
|
const upload = this.uploads.get(uploadId);
|
if (!upload) return null;
|
|
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;
|
}
|
|
const selectedUploads = this.getSelectedRestorationUploads(notification);
|
if (selectedUploads.length === 0) {
|
return;
|
}
|
await this.restoreSelectedUploads(selectedUploads);
|
|
this.cleanupRestore();
|
}
|
|
getSelectedRestorationUploads(notificationEl) {
|
let selected = [];
|
const checkboxes = notificationEl.querySelectorAll('[type=checkbox]:checked');
|
|
checkboxes.forEach(checkbox => {
|
const item = checkbox.closest('.item');
|
if (item) {
|
selected.push({
|
uploadId: item.dataset.uploadId,
|
fieldId: item.dataset.fieldId
|
});
|
}
|
});
|
|
return selected;
|
}
|
|
handleGroupMetaChange(input) {
|
let group = this.getGroupFromElement(input);
|
if (!group) {
|
return;
|
}
|
if (!Object.hasOwn(group, 'changes')) {
|
group.changes = {};
|
}
|
let name = input.name;
|
if (name.includes('group')) {
|
let replace = group.id+'_';
|
let replace2 = group.id+'[';
|
name = name.replace(replace, '').replace(replace2,'').replace(']', '');
|
}
|
group.changes[`${name}`] = input.value;
|
this.groups.set(group.id, group);
|
this.schedulePersistance(group.fieldId);
|
}
|
|
|
/*******************************************************************************
|
* RESTORING UPLOADS
|
*******************************************************************************/
|
async showRecoveryNotification(pendingFields) {
|
const totalUploads = pendingFields.reduce((sum, field) => sum + field.uploads.length, 0);
|
const totalGroups = pendingFields.reduce((sum, field) =>
|
sum + (field.groups?.length || 0), 0);
|
|
let notification = window.getTemplate('restoreNotification');
|
if (!notification) {
|
console.error('Restore notification template not found');
|
return;
|
}
|
|
// Build appropriate message
|
let message;
|
if (totalGroups > 0) {
|
let group = totalGroups > 1 ? 'groups' : 'group';
|
let upload = totalUploads > 1 ? 'uploads' : 'upload';
|
message = `${totalGroups} ${group} with ${totalUploads} ${upload} can be restored.`;
|
} else {
|
message = `${totalUploads} upload(s) from ${pendingFields.length} field(s) can be recovered.`;
|
}
|
|
const detailsEl = notification.querySelector('.restore-details');
|
if (detailsEl) {
|
detailsEl.textContent = message;
|
}
|
|
// Build the restoration preview
|
for (const field of pendingFields) {
|
let fieldTemplate = window.getTemplate('restoreField');
|
if (!fieldTemplate) continue;
|
|
// Set field name/title
|
const titleEl = fieldTemplate.querySelector('h3');
|
if (titleEl) {
|
titleEl.textContent = field.config.name || 'Unnamed Field';
|
}
|
|
const itemGrid = fieldTemplate.querySelector('.item-grid.restore');
|
|
// Process each upload
|
for (const upload of field.uploads) {
|
|
let uploadItem = window.getTemplate('uploadItem');
|
if (!uploadItem) continue;
|
//
|
// const imgEl = uploadItem.querySelector('img');
|
// const placeholderEl = uploadItem.querySelector('.image-placeholder');
|
//
|
const blobData = await this.uploadStore.getBlob(upload.id);
|
|
|
if (blobData) {
|
try {
|
// Create new blob URL from stored data
|
const blob = new Blob([blobData.data], { type: blobData.type });
|
const previewUrl = this.createPreviewUrl(blob);
|
|
let [
|
featured,
|
img,
|
video,
|
preview,
|
details
|
] = [
|
uploadItem.querySelector('[name="featured"]'),
|
uploadItem.querySelector('img'),
|
uploadItem.querySelector('video'),
|
uploadItem.querySelector('label > span'),
|
uploadItem.querySelector('details')
|
];
|
|
uploadItem.dataset.uploadId = upload.id;
|
|
|
uploadItem.dataset.fieldId = field.id;
|
|
let subtype = this.getSubtypeFromMime(blobData.type);
|
uploadItem.dataset.subtype = subtype;
|
switch (subtype) {
|
case 'image':
|
[
|
img.src,
|
img.alt
|
] = [
|
previewUrl,
|
upload.originalFile?.name ?? upload.meta?.originalName?? ''
|
];
|
video.remove();
|
preview.remove();
|
break;
|
case 'video':
|
video.src = previewUrl;
|
img.remove();
|
preview.remove();
|
break;
|
case 'document':
|
let extension = '';
|
let icon;
|
switch (extension) {
|
case 'pdf':
|
icon = window.getIcon('file-pdf');
|
break;
|
case 'csv':
|
icon = window.getIcon('file-csv');
|
break;
|
case 'doc':
|
icon = window.getIcon('file-doc');
|
break;
|
case 'txt':
|
icon = window.getIcon('file-txt');
|
break;
|
case 'xls':
|
icon = window.getIcon('file-xls');
|
break;
|
default:
|
icon = window.getIcon('file');
|
break;
|
}
|
|
preview.innerText = upload.originalFile.name;
|
preview.prepend(icon);
|
img.remove();
|
video.remove();
|
break;
|
}
|
|
// Store URL for cleanup later
|
uploadItem.dataset.previewUrl = previewUrl;
|
} catch (error) {
|
console.warn('Failed to create preview for upload:', upload.id, error);
|
}
|
}
|
|
// Set upload metadata
|
const nameEl = uploadItem.querySelector('summary span');
|
if (nameEl) {
|
nameEl.textContent = upload.meta?.originalName || 'Unknown file';
|
}
|
|
const metaEl = uploadItem.querySelector('details');
|
if (metaEl && upload.meta) {
|
metaEl.textContent = `${this.formatBytes(upload.meta.size)} • ${upload.meta.type}`;
|
}
|
|
// Update input IDs safely
|
uploadItem.querySelectorAll('input').forEach(input => {
|
let id = input.id;
|
if (id) {
|
let newId = id + upload.id;
|
let label = input.parentNode.querySelector(`label[for="${id}"]`);
|
input.id = newId;
|
if (label) {
|
label.htmlFor = newId;
|
}
|
}
|
});
|
|
if (itemGrid) {
|
itemGrid.appendChild(uploadItem);
|
}
|
}
|
|
notification.querySelector('.wrap').appendChild(itemGrid);
|
}
|
|
document.querySelector('.field.upload').appendChild(notification);
|
notification = document.querySelector('dialog.restore-uploads');
|
this.restoreModal = new window.jvbModal(notification);
|
this.restoreSelection = new window.jvbHandleSelection({
|
container: notification,
|
ui: {
|
selectAll: notification.querySelector('#select-all-restore'),
|
count: notification.querySelector('.selection-count'),
|
},
|
});
|
|
this.restoreModal.handleOpen();
|
|
}
|
|
async restoreSelectedUploads(selectedUploads) {
|
// Group by field
|
const byField = new Map();
|
selectedUploads.forEach(item => {
|
if (!byField.has(item.fieldId)) {
|
byField.set(item.fieldId, []);
|
}
|
byField.get(item.fieldId).push(item.uploadId);
|
});
|
|
// Get full field states from IndexedDB
|
if (!this.db) {
|
// this.notifications.add('Cannot restore: Database not available', 'error');
|
return;
|
}
|
|
const tx = this.db.transaction(['fieldStates'], 'readonly');
|
const store = tx.objectStore('fieldStates');
|
|
for (const [fieldId, uploadIds] of byField.entries()) {
|
const request = store.get(fieldId);
|
const fieldState = await new Promise(resolve => {
|
request.onsuccess = () => resolve(request.result);
|
request.onerror = () => resolve(null);
|
});
|
|
if (fieldState) {
|
// Filter to only selected uploads
|
fieldState.uploads = fieldState.uploads.filter(u => uploadIds.includes(u.id));
|
await this.restoreField(fieldState);
|
}
|
}
|
|
// this.notifications.add(`Restored ${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);
|
return;
|
}
|
|
// Register the field if not already registered
|
let fieldKey = fieldElement.dataset.uploader;
|
if (!fieldKey || !this.fields.has(fieldKey)) {
|
fieldKey = this.registerUploader(fieldElement, config);
|
}
|
|
const field = this.fields.get(fieldKey);
|
if (!field) {
|
console.error('Failed to register field for restoration');
|
return;
|
}
|
|
// 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);
|
}
|
}
|
|
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();
|
|
// Clear data
|
this.fields.clear();
|
this.uploads.clear();
|
this.groups.clear();
|
this.selected.clear();
|
this.subscribers.clear();
|
}
|
|
cleanupRestore() {
|
this.restoreModal.handleClose();
|
this.restoreSelection.destroy();
|
this.restoreSelection = null;
|
this.restoreModal.destroy();
|
this.restoreModal.modal.remove();
|
this.restoreModal = null;
|
}
|
|
async cleanupStoredUploads() {
|
this.fieldStore.clear();
|
this.uploadStore.clear();
|
}
|
|
/**
|
* Clear all uploads for a field and cleanup resources
|
*/
|
async clearField(fieldId) {
|
// Clear from stores
|
await this.fieldStore.delete(fieldId);
|
|
// Clear related uploads
|
const field = this.fields.get(fieldId);
|
if (field?.uploads) {
|
for (const uploadId of field.uploads) {
|
await this.uploadStore.delete(uploadId);
|
}
|
}
|
|
// Clear from memory
|
this.fields.delete(fieldId);
|
}
|
|
async clearUpload(uploadId, persist = true) {
|
const upload = this.uploads.get(uploadId);
|
if (!upload) return;
|
|
// Clean up preview URL using helper
|
this.revokePreviewUrl(upload.preview);
|
|
// Clean up element preview URL
|
if (upload.element) {
|
const previewUrl = upload.element.dataset.previewUrl;
|
this.revokePreviewUrl(previewUrl);
|
delete upload.element.dataset.previewUrl;
|
}
|
|
if (persist) {
|
await this.schedulePersistance(upload.fieldId);
|
}
|
|
// Remove from memory
|
this.uploads.delete(uploadId);
|
|
// Remove from IndexedDB
|
this.uploadStore.delete(uploadId);
|
this.uploadStore.delete(uploadId, 'blobs');
|
}
|
cleanupAllPreviewUrls() {
|
if (this.previewUrls) {
|
this.previewUrls.forEach(url => {
|
try {
|
URL.revokeObjectURL(url);
|
} catch (e) {
|
// Ignore errors during cleanup
|
}
|
});
|
this.previewUrls.clear();
|
}
|
}
|
}
|
|
// Initialize when DOM is ready
|
document.addEventListener('DOMContentLoaded', () => {
|
window.jvbUploads = new UploadManager();
|
});
|