/**
|
* UploadManager - Refactored with simplified store architecture
|
*
|
* Architecture:
|
* - uploadStore: Individual uploads with blob data, keyed by uploadId
|
* - groupStore: Group metadata + upload references, keyed by groupId
|
* - Runtime Maps: DOM references only (fieldElements, uploadElements, groupElements)
|
*
|
* Flow: File → Process → Store → Queue → Server → Clear stores
|
* Recovery: Check stores on load → Show notification → Restore to DOM
|
*/
|
class UploadManager {
|
constructor() {
|
this.queue = window.jvbQueue;
|
this.a11y = window.jvbA11y;
|
this.error = window.jvbError;
|
|
// Store initialization flags
|
this.storesReady = false;
|
this.hasCheckedForRecovery = false;
|
|
// Register stores
|
const { uploads, groups } = window.jvbStore.register(
|
'uploads',
|
[
|
{
|
storeName: 'uploads',
|
keyPath: 'id',
|
storeBlobs: true,
|
indexes: [
|
{ name: 'fieldId', keyPath: 'fieldId' },
|
{ name: 'status', keyPath: 'status' },
|
{ name: 'groupId', keyPath: 'groupId' },
|
{ name: 'pageUrl', keyPath: 'pageUrl' }
|
],
|
TTL: 7 * 24 * 60 * 60 * 1000, // 1 week
|
delayFetch: true
|
},
|
{
|
storeName: 'groups',
|
keyPath: 'id',
|
indexes: [
|
{ name: 'fieldId', keyPath: 'fieldId' },
|
{ name: 'pageUrl', keyPath: 'pageUrl' }
|
],
|
TTL: 7 * 24 * 60 * 60 * 1000,
|
delayFetch: true
|
}
|
]
|
);
|
|
this.uploadStore = uploads;
|
this.groupStore = groups;
|
|
// Subscribe to store events
|
this.uploadStore.subscribe(this.handleStoreEvent.bind(this, 'uploads'));
|
this.groupStore.subscribe(this.handleStoreEvent.bind(this, 'groups'));
|
|
// RUNTIME DATA - DOM references only, not persisted
|
this.fieldElements = new Map(); // fieldId → { element, ui, config }
|
this.uploadElements = new Map(); // uploadId → { element, preview, location }
|
this.groupElements = new Map(); // groupId → { element, grid, fieldId }
|
|
// Selection state
|
this.selected = new Map(); // fieldId -> { }
|
this.selectionHandlers = new Map();
|
this.sortableInstances = new Map();
|
|
// Preview URL tracking for cleanup
|
this.previewUrls = new Set();
|
|
// Worker for image processing
|
this.worker = this.createWorkerConfig();
|
|
// Notification subscribers
|
this.subscribers = new Set();
|
|
// Selectors
|
this.selectors = {
|
field: {
|
field: '[data-upload-field]',
|
input: 'input[type="file"]',
|
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',
|
'processing': 'Processing Image...',
|
'queued': 'Waiting to upload...',
|
'uploading': 'Uploading to Server',
|
'pending': 'Sent to server, awaiting processing.',
|
'server_processing': 'Processing on server...',
|
'completed': 'Upload complete!',
|
'failed': 'Upload failed (will retry)',
|
'failed_permanent': 'Upload failed permanently'
|
};
|
|
this.init();
|
}
|
|
/*******************************************************************************
|
* INITIALIZATION
|
*******************************************************************************/
|
|
async init() {
|
this.initListeners();
|
this.initQueueSubscription();
|
|
window.addEventListener('beforeunload', () => this.cleanupAllPreviewUrls());
|
}
|
|
createWorkerConfig() {
|
return {
|
worker: null,
|
tasks: new Map(),
|
restart: { count: 0, max: 3 },
|
settings: {
|
timeout: 10000,
|
maxConcurrent: 3,
|
restartAfterTimeout: true
|
}
|
};
|
}
|
|
initQueueSubscription() {
|
this.queue.subscribe((event, operation) => {
|
if (!['uploads', 'uploads/meta', 'uploads/groups'].includes(operation.endpoint)) {
|
return;
|
}
|
|
const fieldId = operation.data instanceof FormData
|
? operation.data.get('fieldId')
|
: operation.data?.fieldId;
|
|
switch (event) {
|
case 'cancel-operation':
|
if (fieldId) this.handleOperationCancelled(fieldId);
|
break;
|
case 'operation-status':
|
if (fieldId) this.updateFieldStatus(fieldId, operation.status);
|
break;
|
case 'operation-complete':
|
this.handleOperationComplete(operation, fieldId);
|
break;
|
case 'operation-failed':
|
case 'operation-failed-permanent':
|
this.handleOperationFailed(operation, fieldId);
|
break;
|
}
|
});
|
}
|
|
/*******************************************************************************
|
* STORE EVENT HANDLING & RECOVERY
|
*******************************************************************************/
|
|
handleStoreEvent(storeName, event, data) {
|
if (event === 'data-loaded') {
|
this.checkStoresReady();
|
}
|
}
|
|
checkStoresReady() {
|
// Both stores need to be ready before checking for recovery
|
const uploadsReady = this.uploadStore.getStore()._initialized;
|
const groupsReady = this.groupStore.getStore()._initialized;
|
|
if (uploadsReady && groupsReady && !this.hasCheckedForRecovery) {
|
this.hasCheckedForRecovery = true;
|
this.storesReady = true;
|
this.checkForRecoverableUploads();
|
}
|
}
|
|
async checkForRecoverableUploads() {
|
const allUploads = this.uploadStore.getAll();
|
|
// Find uploads that weren't completed and don't have an active operation
|
const recoverableUploads = allUploads.filter(upload =>
|
!upload.operationId &&
|
['processed', 'processing', 'queued'].includes(upload.status)
|
);
|
|
if (recoverableUploads.length === 0) return;
|
|
// Group by fieldId for display
|
const byField = this.groupUploadsByField(recoverableUploads);
|
await this.showRecoveryNotification(byField);
|
}
|
|
groupUploadsByField(uploads) {
|
const byField = new Map();
|
|
uploads.forEach(upload => {
|
if (!byField.has(upload.fieldId)) {
|
byField.set(upload.fieldId, {
|
fieldId: upload.fieldId,
|
pageUrl: upload.pageUrl,
|
uploads: [],
|
groups: new Map()
|
});
|
}
|
|
const fieldData = byField.get(upload.fieldId);
|
fieldData.uploads.push(upload);
|
|
// Track groups
|
if (upload.groupId) {
|
const group = this.groupStore.get(upload.groupId);
|
if (group) {
|
fieldData.groups.set(upload.groupId, group);
|
}
|
}
|
});
|
|
return byField;
|
}
|
|
/*******************************************************************************
|
* FIELD MANAGEMENT - Runtime only, no persistence
|
*******************************************************************************/
|
|
scanFields(container, autoUpload = false) {
|
const fields = container.querySelectorAll(this.selectors.field.field);
|
fields.forEach(uploader => this.registerUploader(uploader, autoUpload));
|
}
|
|
registerUploader(uploader, autoUpload = false) {
|
const fieldId = this.determineFieldId(uploader);
|
const config = this.extractFieldConfig(uploader, autoUpload);
|
const ui = this.buildFieldUI(uploader);
|
|
// Store DOM references only - not persisted
|
this.fieldElements.set(fieldId, { element: uploader, ui, config });
|
|
uploader.dataset.uploader = fieldId;
|
this.addFieldSelectionHandler(fieldId);
|
|
if (config.type !== 'single') {
|
this.initSortable(fieldId);
|
}
|
|
return fieldId;
|
}
|
|
extractFieldConfig(fieldElement, autoUpload) {
|
return {
|
autoUpload,
|
destination: fieldElement.dataset.destination || 'meta',
|
content: fieldElement.dataset.content || null,
|
mode: fieldElement.dataset.mode || 'direct',
|
type: fieldElement.dataset.type || 'single',
|
name: fieldElement.dataset.field,
|
itemID: fieldElement.dataset.itemId || 0,
|
maxFiles: parseInt(fieldElement.dataset.maxFiles) || 999,
|
subtype: fieldElement.dataset.subtype || 'image'
|
};
|
}
|
|
buildFieldUI(fieldElement) {
|
const 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: {
|
container: 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')
|
}
|
};
|
|
const display = fieldElement.querySelector('.group-display');
|
if (display) {
|
UI.groups = {
|
display,
|
container: fieldElement.querySelector('.item-grid.groups'),
|
empty: fieldElement.querySelector('.empty-group'),
|
groups: new Map()
|
};
|
}
|
|
return UI;
|
}
|
|
/**
|
* Get uploads for a field - derived from store, not cached
|
*/
|
getFieldUploads(fieldId) {
|
return this.uploadStore.getAll().filter(u => u.fieldId === fieldId);
|
}
|
|
/**
|
* Get groups for a field - derived from store
|
*/
|
getFieldGroups(fieldId) {
|
return this.groupStore.getAll().filter(g => g.fieldId === fieldId);
|
}
|
|
/**
|
* Get upload count for a field
|
*/
|
getFieldUploadCount(fieldId) {
|
return this.getFieldUploads(fieldId).length;
|
}
|
|
/*******************************************************************************
|
* FILE PROCESSING
|
*******************************************************************************/
|
|
async processFiles(fieldId, files) {
|
const fieldEl = this.fieldElements.get(fieldId);
|
if (!fieldEl) return;
|
|
const { config, ui } = fieldEl;
|
|
// Show group display, hide upload zone
|
if (ui.dropZone) ui.dropZone.hidden = true;
|
if (ui.groups?.display) ui.groups.display.hidden = false;
|
|
const totalFiles = files.length;
|
let processedCount = 0;
|
|
this.updateFieldProgress(fieldId, 0, totalFiles, 'Processing files...');
|
|
const processPromises = Array.from(files).map(async (file) => {
|
try {
|
const uploadId = this.generateId('upload');
|
|
// Create upload data
|
const uploadData = {
|
id: uploadId,
|
fieldId,
|
pageUrl: window.location.href,
|
status: 'processing',
|
groupId: null,
|
attachmentId: null,
|
meta: {
|
originalName: file.name,
|
size: file.size,
|
type: file.type
|
}
|
};
|
|
// Save initial state
|
await this.uploadStore.save(uploadData);
|
|
// Process file
|
const preview = this.createPreviewUrl(file);
|
const processedFile = file.type.startsWith('image/')
|
? await this.processImage(file, uploadId)
|
: file;
|
|
// Show progress
|
this.showUploadProgress(uploadId, true);
|
this.updateUploadItemProgress(uploadId, 50, 'processing');
|
|
// Store blob data
|
await this.saveBlobData(uploadId, processedFile || file);
|
|
// Create DOM element
|
const subtype = this.getSubtypeFromMime(file.type);
|
const element = this.createUploadElement({
|
id: uploadId,
|
preview,
|
meta: uploadData.meta,
|
subtype
|
}, config.destination === 'post_group');
|
|
// Add to preview grid
|
if (ui.preview) {
|
ui.preview.appendChild(element);
|
this.uploadElements.set(uploadId, { element, preview, location: ui.preview });
|
}
|
|
// Update status
|
await this.updateUploadStatus(uploadId, 'processed');
|
|
// Update progress
|
processedCount++;
|
this.updateFieldProgress(fieldId, processedCount, totalFiles, 'Processing files...');
|
this.updateUploadItemProgress(uploadId, 100, 'processed');
|
|
setTimeout(() => this.showUploadProgress(uploadId, false), 1000);
|
|
return uploadId;
|
|
} catch (error) {
|
console.error('Error processing file:', file.name, error);
|
processedCount++;
|
this.updateFieldProgress(fieldId, processedCount, totalFiles, 'Processing files...');
|
return null;
|
}
|
});
|
|
await Promise.all(processPromises);
|
|
this.updateFieldState(fieldId);
|
this.refreshSortable(fieldId);
|
|
// Queue for upload if auto-upload enabled
|
if (config.autoUpload && config.destination !== 'post_group') {
|
await this.queueUpload(fieldId);
|
this.maybeLockUploads(fieldId);
|
}
|
}
|
|
/*******************************************************************************
|
* IMAGE PROCESSING (unchanged logic, just cleaner structure)
|
*******************************************************************************/
|
|
async processImage(file, uploadId) {
|
const timeout = this.worker.settings.timeout;
|
|
return new Promise((resolve, reject) => {
|
let timeoutId;
|
let completed = false;
|
|
timeoutId = setTimeout(() => {
|
if (!completed) {
|
completed = true;
|
this.worker.tasks.delete(uploadId);
|
if (this.worker.settings.restartAfterTimeout) {
|
this.restartWorker();
|
}
|
reject(new Error(`Processing timeout for ${file.name}`));
|
}
|
}, timeout);
|
|
this.worker.tasks.set(uploadId, { file, timeoutId });
|
|
this.handleImageProcess(file, uploadId)
|
.then(result => {
|
if (!completed) {
|
completed = true;
|
clearTimeout(timeoutId);
|
this.worker.tasks.delete(uploadId);
|
resolve(result);
|
}
|
})
|
.catch(error => {
|
if (!completed) {
|
completed = true;
|
clearTimeout(timeoutId);
|
this.worker.tasks.delete(uploadId);
|
reject(error);
|
}
|
});
|
});
|
}
|
|
async handleImageProcess(file, uploadId) {
|
if (!file.type.startsWith('image/')) return file;
|
|
const maxDimension = this.getMaxDimension();
|
const quality = 0.95;
|
|
if (this.shouldUseWorker(file)) {
|
try {
|
if (!this.worker.worker) this.initWorker();
|
if (this.worker.worker) {
|
return await this.processWithWorker(file, uploadId, maxDimension, quality);
|
}
|
} catch (error) {
|
console.warn('Worker failed, using main thread:', error);
|
}
|
}
|
|
return this.processOnMainThread(file, maxDimension, quality);
|
}
|
|
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 = img.onerror = null;
|
if (objectUrl) {
|
URL.revokeObjectURL(objectUrl);
|
objectUrl = null;
|
}
|
canvas.width = canvas.height = 1;
|
ctx.clearRect(0, 0, 1, 1);
|
};
|
|
img.onload = () => {
|
try {
|
const { width, height } = this.calculateDimensions(img, maxDimension);
|
canvas.width = width;
|
canvas.height = height;
|
|
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) {
|
resolve(new File(
|
[blob],
|
this.getProcessedFileName(file, outputFormat),
|
{ type: outputFormat, lastModified: Date.now() }
|
));
|
} 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}`));
|
}
|
});
|
}
|
|
// Worker methods (simplified)
|
initWorker() {
|
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 {
|
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 });
|
self.postMessage({ messageId, success: true, 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 worker:', error);
|
this.worker.worker = null;
|
}
|
}
|
|
async processWithWorker(file, uploadId, maxDimension, quality) {
|
return new Promise((resolve, reject) => {
|
if (!this.worker.worker) {
|
reject(new Error('Worker not available'));
|
return;
|
}
|
|
const messageId = `${uploadId}_${Date.now()}`;
|
const outputFormat = this.getOptimalFormat(file);
|
|
const handler = (e) => {
|
if (e.data.messageId !== messageId) return;
|
this.worker.worker.removeEventListener('message', handler);
|
this.worker.worker.removeEventListener('error', errorHandler);
|
|
if (e.data.success) {
|
resolve(new File(
|
[e.data.blob],
|
this.getProcessedFileName(file, e.data.format || outputFormat),
|
{ type: e.data.format || outputFormat, lastModified: Date.now() }
|
));
|
} else {
|
reject(new Error(e.data.error || 'Worker processing failed'));
|
}
|
};
|
|
const errorHandler = (error) => {
|
this.worker.worker.removeEventListener('message', handler);
|
this.worker.worker.removeEventListener('error', errorHandler);
|
reject(new Error(`Worker error: ${error.message}`));
|
};
|
|
this.worker.worker.addEventListener('message', handler);
|
this.worker.worker.addEventListener('error', errorHandler);
|
this.worker.worker.postMessage({ messageId, file, maxDimension, quality, outputFormat });
|
});
|
}
|
|
restartWorker() {
|
if (this.worker.worker) {
|
this.worker.worker.terminate();
|
this.worker.worker = null;
|
}
|
this.worker.tasks.clear();
|
|
if (this.worker.restart.count < this.worker.restart.max) {
|
this.worker.restart.count++;
|
this.initWorker();
|
}
|
}
|
|
// Image processing helpers
|
calculateDimensions(img, maxDimension) {
|
let { width, height } = img;
|
if (width <= maxDimension && height <= maxDimension) {
|
return { width, height };
|
}
|
const scale = Math.min(maxDimension / width, maxDimension / height);
|
return { width: Math.round(width * scale), height: Math.round(height * scale) };
|
}
|
|
getMaxDimension() {
|
const screenWidth = window.screen.width;
|
const dpr = window.devicePixelRatio || 1;
|
if (screenWidth * dpr > 2560) return 2400;
|
if (screenWidth * dpr > 1920) return 1920;
|
return 1200;
|
}
|
|
shouldUseWorker(file) {
|
return this.worker.worker && file.size > 1024 * 1024 && typeof OffscreenCanvas !== 'undefined';
|
}
|
|
getOptimalFormat(file) {
|
if (file.type === 'image/gif' || file.type === 'image/svg+xml') return file.type;
|
return this.supportsWebP() ? 'image/webp' : 'image/jpeg';
|
}
|
|
getOptimalQuality(file, requestedQuality) {
|
if (file.size < 500 * 1024) return Math.max(requestedQuality, 0.9);
|
if (file.size < 2 * 1024 * 1024) return requestedQuality;
|
return Math.min(requestedQuality, 0.8);
|
}
|
|
getProcessedFileName(file, outputFormat) {
|
const baseName = file.name.replace(/\.[^/.]+$/, '');
|
const extensions = { 'image/webp': '.webp', 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif' };
|
return baseName + (extensions[outputFormat] || '.jpg');
|
}
|
|
supportsWebP() {
|
const canvas = document.createElement('canvas');
|
return canvas.toDataURL('image/webp').startsWith('data:image/webp');
|
}
|
|
/*******************************************************************************
|
* BLOB DATA MANAGEMENT
|
*******************************************************************************/
|
|
async saveBlobData(uploadId, file) {
|
const arrayBuffer = await file.arrayBuffer();
|
const upload = this.uploadStore.get(uploadId) || { id: uploadId };
|
|
upload.blobData = {
|
buffer: arrayBuffer,
|
name: file.name,
|
type: file.type,
|
size: file.size,
|
lastModified: file.lastModified || Date.now()
|
};
|
|
await this.uploadStore.save(upload);
|
}
|
|
async getBlobData(uploadId) {
|
const upload = this.uploadStore.get(uploadId);
|
if (!upload?.blobData) return null;
|
|
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
|
});
|
}
|
|
/*******************************************************************************
|
* QUEUE INTEGRATION
|
*******************************************************************************/
|
|
async queueUpload(fieldId) {
|
const uploads = this.getFieldUploads(fieldId);
|
if (uploads.length === 0) return;
|
|
const fieldEl = this.fieldElements.get(fieldId);
|
if (!fieldEl) return;
|
|
const formData = await this.prepareUploadFormData(fieldId, uploads, fieldEl.config);
|
|
this.a11y.announce('Queuing for upload');
|
|
const operation = {
|
endpoint: 'uploads',
|
method: 'POST',
|
data: formData,
|
title: `Uploading ${uploads.length} file${uploads.length > 1 ? 's' : ''}...`,
|
popup: `Uploading ${uploads.length} file${uploads.length > 1 ? 's' : ''}...`,
|
canMerge: false,
|
headers: { 'action_nonce': window.auth.getNonce('dash') },
|
append: '_upload'
|
};
|
|
try {
|
const operationId = await this.queue.addToQueue(operation);
|
|
// Update upload statuses
|
for (const upload of uploads) {
|
upload.operationId = operationId;
|
upload.status = 'queued';
|
await this.uploadStore.save(upload);
|
this.updateUploadUI(upload.id);
|
}
|
|
return operationId;
|
} catch (error) {
|
throw error;
|
}
|
}
|
|
async prepareUploadFormData(fieldId, uploads, config) {
|
const formData = new FormData();
|
|
formData.append('content', config.content);
|
formData.append('mode', config.mode);
|
formData.append('field_name', config.name);
|
formData.append('fieldId', fieldId);
|
formData.append('field_type', config.type);
|
formData.append('subtype', config.subtype);
|
formData.append('item_id', config.itemID);
|
formData.append('destination', config.destination || 'meta');
|
|
const uploadMap = [];
|
|
for (const upload of uploads) {
|
const file = await this.getBlobData(upload.id);
|
if (file) {
|
formData.append('files[]', file);
|
uploadMap.push(upload.id);
|
}
|
}
|
|
formData.append('upload_ids', JSON.stringify(uploadMap));
|
return formData;
|
}
|
|
async submitGroupedUploads(fieldId) {
|
const uploads = this.getFieldUploads(fieldId);
|
const groups = this.getFieldGroups(fieldId);
|
const fieldEl = this.fieldElements.get(fieldId);
|
|
if (uploads.length === 0 || !fieldEl) return;
|
|
const formData = new FormData();
|
const posts = [];
|
const uploadMap = [];
|
|
// Process each group
|
for (const group of groups) {
|
const groupUploads = uploads.filter(u => u.groupId === group.id);
|
|
const post = {
|
images: [],
|
fields: { ...group.fields }
|
};
|
|
for (const upload of groupUploads) {
|
const file = await this.getBlobData(upload.id);
|
if (file) {
|
formData.append('files[]', file);
|
post.images.push({ upload_id: upload.id, index: uploadMap.length });
|
uploadMap.push(upload.id);
|
|
// Check featured
|
const uploadEl = this.uploadElements.get(upload.id);
|
const featured = uploadEl?.element?.querySelector('[name="featured"]');
|
if (featured?.checked) {
|
post.fields.featured = upload.id;
|
}
|
}
|
}
|
|
if (post.images.length > 0) {
|
posts.push(post);
|
}
|
}
|
|
// Handle ungrouped uploads - each becomes its own post
|
const ungrouped = uploads.filter(u => !u.groupId);
|
for (const upload of ungrouped) {
|
const file = await this.getBlobData(upload.id);
|
if (file) {
|
formData.append('files[]', file);
|
posts.push({
|
images: [{ upload_id: upload.id, index: uploadMap.length }],
|
fields: {}
|
});
|
uploadMap.push(upload.id);
|
}
|
}
|
|
formData.append('content', fieldEl.config.content);
|
formData.append('user', fieldEl.config.itemID);
|
formData.append('posts', JSON.stringify(posts));
|
formData.append('upload_ids', JSON.stringify(uploadMap));
|
|
const operation = {
|
endpoint: 'uploads/groups',
|
method: 'POST',
|
data: formData,
|
title: `Creating ${posts.length} ${fieldEl.config.content}${posts.length > 1 ? 's' : ''}...`,
|
popup: `Creating ${posts.length} post${posts.length > 1 ? 's' : ''}...`,
|
canMerge: false,
|
headers: { 'action_nonce': window.auth.getNonce('dash') },
|
append: '_upload'
|
};
|
|
try {
|
const operationId = await this.queue.addToQueue(operation);
|
|
for (const upload of uploads) {
|
upload.operationId = operationId;
|
upload.status = 'queued';
|
await this.uploadStore.save(upload);
|
this.updateUploadUI(upload.id);
|
}
|
|
this.a11y.announce(`Creating ${posts.length} post${posts.length > 1 ? 's' : ''}`);
|
return operationId;
|
} catch (error) {
|
this.error.log(error, { component: 'UploadManager', action: 'submitGroupedUploads', fieldId });
|
throw error;
|
}
|
}
|
|
async queueUploadMeta(e) {
|
const uploadId = this.getUploadIdFromElement(e.target);
|
const upload = this.uploadStore.get(uploadId);
|
if (!upload) return;
|
|
const data = { [e.target.name]: e.target.value };
|
upload.meta = { ...upload.meta, ...data };
|
await this.uploadStore.save(upload);
|
|
const queueData = { [upload.attachmentId ?? upload.id]: upload.meta };
|
|
const operation = {
|
endpoint: 'uploads/meta',
|
method: 'POST',
|
data: queueData,
|
title: 'Updating meta',
|
canMerge: true,
|
headers: { 'action_nonce': window.auth.getNonce('dash') }
|
};
|
|
try {
|
await this.queue.addToQueue(operation);
|
} catch (error) {
|
this.error.log(error, { component: 'UploadManager', action: 'queueUploadMeta', uploadId });
|
}
|
}
|
|
/*******************************************************************************
|
* QUEUE EVENT HANDLERS - Cleanup after success
|
*******************************************************************************/
|
|
async handleOperationComplete(operation, fieldId) {
|
const results = operation.result?.data || operation.serverData?.data || [];
|
|
// Update attachment IDs
|
for (const result of results) {
|
const upload = this.uploadStore.get(result.upload_id);
|
if (upload) {
|
upload.attachmentId = result.attachment_id;
|
upload.status = 'completed';
|
await this.uploadStore.save(upload);
|
this.updateUploadUI(result.upload_id);
|
}
|
}
|
|
if (!fieldId) return;
|
|
// Clear completed uploads and their groups
|
await this.clearCompletedUploads(fieldId);
|
|
this.updateFieldState(fieldId);
|
this.a11y.announce('All uploads completed successfully');
|
}
|
|
async clearCompletedUploads(fieldId) {
|
const uploads = this.getFieldUploads(fieldId);
|
const groupIds = new Set();
|
|
for (const upload of uploads) {
|
if (upload.status === 'completed') {
|
if (upload.groupId) groupIds.add(upload.groupId);
|
await this.clearUpload(upload.id);
|
}
|
}
|
|
// Clear associated groups
|
for (const groupId of groupIds) {
|
await this.groupStore.delete(groupId);
|
this.groupElements.delete(groupId);
|
}
|
}
|
|
handleOperationFailed(operation, fieldId) {
|
const uploadIds = operation.data instanceof FormData
|
? JSON.parse(operation.data.get('upload_ids') || '[]')
|
: operation.data?.upload_ids || [];
|
|
const status = operation.status === 'operation-failed-permanent' ? 'failed_permanent' : 'failed';
|
|
uploadIds.forEach(async uploadId => {
|
await this.updateUploadStatus(uploadId, status);
|
});
|
|
if (fieldId) this.updateFieldState(fieldId);
|
}
|
|
async handleOperationCancelled(fieldId) {
|
const uploads = this.getFieldUploads(fieldId);
|
const groups = this.getFieldGroups(fieldId);
|
|
for (const upload of uploads) {
|
await this.clearUpload(upload.id);
|
}
|
|
for (const group of groups) {
|
await this.groupStore.delete(group.id);
|
this.groupElements.delete(group.id);
|
}
|
|
this.updateFieldState(fieldId);
|
this.a11y.announce('Upload cancelled');
|
}
|
|
/*******************************************************************************
|
* GROUP MANAGEMENT
|
*******************************************************************************/
|
|
async createGroup(fieldId, groupId = null) {
|
const fieldEl = this.fieldElements.get(fieldId);
|
if (!fieldEl) return null;
|
|
groupId = groupId || this.generateId('group');
|
|
// Create group data
|
const groupData = {
|
id: groupId,
|
fieldId,
|
pageUrl: window.location.href,
|
uploads: [],
|
fields: {}
|
};
|
|
await this.groupStore.save(groupData);
|
|
// Create DOM element
|
const element = this.createGroupElement(groupId, fieldId);
|
if (!element) return null;
|
|
const grid = element.querySelector('.item-grid.group');
|
|
// Store DOM references
|
this.groupElements.set(groupId, { element, grid, fieldId });
|
|
// Insert into DOM
|
if (fieldEl.ui.groups?.container && fieldEl.ui.groups.empty) {
|
fieldEl.ui.groups.container.insertBefore(element, fieldEl.ui.groups.empty);
|
} else if (fieldEl.ui.groups?.container) {
|
fieldEl.ui.groups.container.appendChild(element);
|
}
|
|
// Initialize sortable and selection
|
this.addGroupSelectionHandler(fieldId, groupId);
|
if (grid) this.createSortableForGrid(grid, fieldId, groupId);
|
|
return { id: groupId, element, grid };
|
}
|
|
createGroupElement(groupId, fieldId) {
|
const element = window.getTemplate('imageGroup');
|
if (!element) return null;
|
|
element.dataset.groupId = groupId;
|
element.dataset.fieldId = fieldId;
|
|
const fields = window.getTemplate('groupMetadata');
|
const fieldsContainer = element.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 fieldEl = this.fieldElements.get(fieldId);
|
if (fieldEl?.config.content) {
|
const summary = element.querySelector('summary');
|
if (summary) summary.textContent = fieldEl.config.content + ' Fields';
|
}
|
} else {
|
element.querySelector('details')?.remove();
|
}
|
|
const gridContainer = element.querySelector('.item-grid.group');
|
if (gridContainer) gridContainer.dataset.groupId = groupId;
|
|
return element;
|
}
|
|
async deleteGroup(groupId, moveUploadsToPreview = true) {
|
const groupEl = this.groupElements.get(groupId);
|
if (!groupEl) return;
|
|
const group = this.groupStore.get(groupId);
|
const fieldEl = this.fieldElements.get(groupEl.fieldId);
|
|
if (moveUploadsToPreview && group?.uploads) {
|
for (const uploadId of group.uploads) {
|
await this.removeFromGroup(uploadId);
|
}
|
}
|
|
// Remove from store
|
await this.groupStore.delete(groupId);
|
|
// Remove DOM element
|
groupEl.element?.remove();
|
|
// Cleanup
|
this.groupElements.delete(groupId);
|
|
const sortableKey = `${groupEl.fieldId}-group-${groupId}`;
|
this.sortableInstances.get(sortableKey)?.destroy();
|
this.sortableInstances.delete(sortableKey);
|
|
this.a11y.announce('Group removed');
|
}
|
|
async addToGroup(uploadId, targetGrid, persist = true) {
|
const upload = this.uploadStore.get(uploadId);
|
const uploadEl = this.uploadElements.get(uploadId);
|
if (!upload || !uploadEl) return;
|
|
const groupId = targetGrid.dataset.groupId;
|
const group = this.groupStore.get(groupId);
|
|
// Remove from previous group if needed
|
if (upload.groupId && upload.groupId !== groupId) {
|
const oldGroup = this.groupStore.get(upload.groupId);
|
if (oldGroup) {
|
oldGroup.uploads = oldGroup.uploads.filter(id => id !== uploadId);
|
if (oldGroup.uploads.length === 0) {
|
await this.deleteGroup(upload.groupId, false);
|
} else {
|
await this.groupStore.save(oldGroup);
|
}
|
}
|
}
|
|
// Add to new group
|
upload.groupId = groupId;
|
if (group && !group.uploads.includes(uploadId)) {
|
group.uploads.push(uploadId);
|
await this.groupStore.save(group);
|
}
|
|
// Update upload
|
await this.uploadStore.save(upload);
|
|
// Move DOM element
|
targetGrid.appendChild(uploadEl.element);
|
uploadEl.location = targetGrid;
|
|
// Show featured radio
|
const featured = uploadEl.element.querySelector('[name="featured"]');
|
if (featured) {
|
featured.hidden = false;
|
featured.name = `${groupId}_featured`;
|
}
|
|
// Clear checkbox
|
const checkbox = uploadEl.element.querySelector('[name*="select-item"]');
|
if (checkbox) checkbox.checked = false;
|
|
this.updateSortableState(targetGrid);
|
}
|
|
async removeFromGroup(uploadId) {
|
const upload = this.uploadStore.get(uploadId);
|
const uploadEl = this.uploadElements.get(uploadId);
|
if (!upload || !uploadEl) return;
|
|
const fieldEl = this.fieldElements.get(upload.fieldId);
|
if (!fieldEl?.ui?.preview) return;
|
|
// Update group
|
if (upload.groupId) {
|
const group = this.groupStore.get(upload.groupId);
|
if (group) {
|
group.uploads = group.uploads.filter(id => id !== uploadId);
|
if (group.uploads.length === 0) {
|
await this.deleteGroup(upload.groupId, false);
|
} else {
|
await this.groupStore.save(group);
|
}
|
}
|
upload.groupId = null;
|
await this.uploadStore.save(upload);
|
}
|
|
// Move to 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.updateSortableState(fieldEl.ui.preview);
|
}
|
|
async removeUpload(fieldId, uploadId) {
|
const upload = this.uploadStore.get(uploadId);
|
const uploadEl = this.uploadElements.get(uploadId);
|
|
if (upload?.groupId) {
|
const group = this.groupStore.get(upload.groupId);
|
if (group) {
|
group.uploads = group.uploads.filter(id => id !== uploadId);
|
if (group.uploads.length === 0) {
|
await this.deleteGroup(upload.groupId, false);
|
} else {
|
await this.groupStore.save(group);
|
}
|
}
|
}
|
|
uploadEl?.element?.remove();
|
await this.clearUpload(uploadId);
|
|
this.updateFieldState(fieldId);
|
this.maybeLockUploads(fieldId);
|
|
this.selectionHandlers.get(fieldId)?.deselect(uploadId);
|
this.a11y.announce('Upload removed');
|
}
|
|
async handleGroupMetaChange(input) {
|
const groupEl = input.closest(this.selectors.groups.container);
|
if (!groupEl) return;
|
|
const groupId = groupEl.dataset.groupId;
|
const group = this.groupStore.get(groupId);
|
if (!group) return;
|
|
let name = input.name;
|
if (name.includes(groupId)) {
|
name = name.replace(`${groupId}_`, '').replace(`${groupId}[`, '').replace(']', '');
|
}
|
|
group.fields[name] = input.value;
|
await this.groupStore.save(group);
|
}
|
|
/*******************************************************************************
|
* SORTABLE & DRAG/DROP
|
*******************************************************************************/
|
|
initSortable(fieldId) {
|
if (!window.Sortable) return;
|
|
if (!Sortable._multiDragMounted && Sortable.MultiDrag) {
|
Sortable.mount(new Sortable.MultiDrag());
|
Sortable._multiDragMounted = true;
|
}
|
|
const fieldEl = this.fieldElements.get(fieldId);
|
if (!fieldEl) return;
|
|
const grids = fieldEl.element.querySelectorAll('.item-grid.preview, .item-grid.group');
|
grids.forEach(grid => {
|
const groupId = grid.classList.contains('group')
|
? grid.closest('.upload-group')?.dataset.groupId
|
: null;
|
this.createSortableForGrid(grid, fieldId, groupId);
|
});
|
|
// Empty group drop zone
|
const emptyGroup = fieldEl.element.querySelector('.empty-group');
|
if (emptyGroup && !emptyGroup.sortableInstance) {
|
emptyGroup.sortableInstance = new Sortable(emptyGroup, {
|
animation: 150,
|
draggable: '.item',
|
multiDrag: true,
|
selectedClass: 'selected-for-drag',
|
avoidImplicitDeselect: true,
|
group: { name: fieldId, pull: false, put: true },
|
ghostClass: 'sortable-ghost',
|
onEnd: (evt) => this.handleDrop(evt, fieldId)
|
});
|
}
|
}
|
|
createSortableForGrid(grid, fieldId, groupId = null) {
|
if (!grid || grid.sortableInstance) return;
|
|
const instance = new Sortable(grid, {
|
animation: 150,
|
draggable: '.item',
|
multiDrag: true,
|
selectedClass: 'selected-for-drag',
|
avoidImplicitDeselect: true,
|
group: { name: fieldId, pull: true, put: true },
|
ghostClass: 'sortable-ghost',
|
chosenClass: 'sortable-chosen',
|
dragClass: 'sortable-drag',
|
onEnd: (evt) => this.handleDrop(evt, fieldId),
|
onSelect: (evt) => this.syncCheckboxToSortable(evt.item, true),
|
onDeselect: (evt) => this.syncCheckboxToSortable(evt.item, false),
|
onAdd: (evt) => this.updateSortableState(evt.to),
|
onRemove: (evt) => this.updateSortableState(evt.from)
|
});
|
|
grid.sortableInstance = instance;
|
|
const gridId = groupId ? `${fieldId}-group-${groupId}` : `${fieldId}-preview`;
|
this.sortableInstances.set(gridId, instance);
|
|
return instance;
|
}
|
|
syncCheckboxToSortable(item, selected) {
|
const checkbox = item.querySelector('[name*="select-item"]');
|
if (checkbox && checkbox.checked !== selected) {
|
checkbox.checked = selected;
|
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
|
}
|
}
|
|
/**
|
* Unified drop handler - consolidated from multiple methods
|
*/
|
async handleDrop(evt, fieldId) {
|
const target = evt.to;
|
const source = evt.from;
|
const items = evt.items?.length > 0 ? evt.items : [evt.item];
|
const uploadIds = items.map(item => item.dataset.uploadId);
|
|
// Same container = reorder only
|
if (target === source) {
|
this.handleReorder(evt);
|
return;
|
}
|
|
const targetType = this.getDropTargetType(target);
|
|
try {
|
switch (targetType) {
|
case 'empty-group':
|
const group = await this.createGroup(fieldId);
|
if (!group) throw new Error('Group creation failed');
|
for (const uploadId of uploadIds) {
|
await this.addToGroup(uploadId, group.grid, false);
|
}
|
break;
|
|
case 'preview':
|
for (const uploadId of uploadIds) {
|
await this.removeFromGroup(uploadId);
|
}
|
break;
|
|
case 'group':
|
for (const uploadId of uploadIds) {
|
await this.addToGroup(uploadId, target, false);
|
}
|
break;
|
|
default:
|
throw new Error('Unknown drop target');
|
}
|
|
this.finalizeDrop(fieldId, items.length, targetType);
|
|
} catch (error) {
|
console.error('Drop error:', error);
|
// Return items to preview as fallback
|
const fieldEl = this.fieldElements.get(fieldId);
|
if (fieldEl?.ui?.preview) {
|
items.forEach(item => fieldEl.ui.preview.appendChild(item));
|
}
|
this.a11y.announce('An error occurred. Items returned to preview.');
|
}
|
|
this.updateSortableState(target);
|
if (source !== target) this.updateSortableState(source);
|
}
|
|
finalizeDrop(fieldId, count, targetType) {
|
const messages = {
|
'empty-group': count > 1 ? `Created group with ${count} items` : 'Created group with item',
|
'preview': count > 1 ? `Moved ${count} items to preview` : 'Moved item to preview',
|
'group': count > 1 ? `Moved ${count} items to group` : 'Moved item to group'
|
};
|
|
this.a11y.announce(messages[targetType] || 'Items moved');
|
this.selectionHandlers.get(fieldId)?.clearSelection();
|
}
|
|
getDropTargetType(target) {
|
if (target.classList.contains('empty-group')) return 'empty-group';
|
if (target.classList.contains('preview')) return 'preview';
|
if (target.classList.contains('group')) return 'group';
|
return 'unknown';
|
}
|
|
handleReorder(evt) {
|
const grid = evt.to;
|
const fieldWrapper = grid.closest('.field, .upload');
|
if (!fieldWrapper) return;
|
|
const items = Array.from(grid.querySelectorAll('.item:not(.sortable-ghost):not(.sortable-clone)'))
|
.map(el => el.dataset.uploadId)
|
.filter(Boolean);
|
|
const hiddenInput = fieldWrapper.querySelector('input[type="hidden"]');
|
if (hiddenInput && items.length > 0) {
|
hiddenInput.value = items.join(',');
|
}
|
|
// Update group order in store
|
const groupId = grid.dataset.groupId;
|
if (groupId) {
|
const group = this.groupStore.get(groupId);
|
if (group) {
|
group.uploads = items;
|
this.groupStore.save(group);
|
}
|
}
|
|
this.a11y.announce('Item reordered');
|
|
fieldWrapper.dispatchEvent(new CustomEvent('jvb-items-reordered', {
|
detail: { from: evt.from, to: evt.to, items },
|
bubbles: true
|
}));
|
}
|
|
updateSortableState(grid) {
|
const sortable = grid?.sortableInstance;
|
if (sortable) sortable.option('disabled', false);
|
}
|
|
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));
|
}
|
|
syncSortableSelection(fieldId, selectedItems) {
|
this.sortableInstances.forEach((instance, key) => {
|
if (key.startsWith(fieldId)) {
|
instance.el.querySelectorAll('.item').forEach(item => {
|
const uploadId = item.dataset.uploadId;
|
if (selectedItems.has(uploadId)) {
|
Sortable.utils.select(item);
|
} else {
|
Sortable.utils.deselect(item);
|
}
|
});
|
}
|
});
|
}
|
|
/*******************************************************************************
|
* EVENT LISTENERS
|
*******************************************************************************/
|
|
initListeners() {
|
this.clickHandler = this.handleClick.bind(this);
|
this.changeHandler = this.handleChange.bind(this);
|
this.dragEnterHandler = this.handleDragEnter.bind(this);
|
this.dragLeaveHandler = this.handleDragLeave.bind(this);
|
this.dragOverHandler = this.handleDragOver.bind(this);
|
this.dropHandler = this.handleExternalDrop.bind(this);
|
|
document.addEventListener('click', this.clickHandler);
|
document.addEventListener('change', this.changeHandler);
|
document.addEventListener('dragenter', this.dragEnterHandler);
|
document.addEventListener('dragleave', this.dragLeaveHandler);
|
document.addEventListener('dragover', this.dragOverHandler);
|
document.addEventListener('drop', this.dropHandler);
|
}
|
|
handleClick(e) {
|
// Trigger file input
|
const dropZone = e.target.closest(this.selectors.field.dropZone);
|
if (dropZone && !e.target.matches('input, button, a')) {
|
dropZone.querySelector(this.selectors.field.input)?.click();
|
}
|
|
// Action buttons
|
const actionButton = e.target.closest('[data-action]');
|
if (actionButton) this.handleAction(actionButton);
|
}
|
|
handleChange(e) {
|
const fieldId = this.getFieldIdFromElement(e.target);
|
if (!fieldId) return;
|
|
// File input
|
if (e.target.matches(this.selectors.field.input)) {
|
const files = Array.from(e.target.files);
|
if (files.length > 0) this.processFiles(fieldId, files);
|
return;
|
}
|
|
// Meta field changes
|
const fieldEl = this.fieldElements.get(fieldId);
|
if (!fieldEl?.config.autoUpload) return;
|
|
if (fieldEl.config.destination === 'post_group') {
|
this.handleGroupMetaChange(e.target);
|
} else {
|
this.queueUploadMeta(e);
|
}
|
}
|
|
handleDragEnter(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');
|
}
|
}
|
|
handleDragLeave(e) {
|
const dropZone = e.target.closest(this.selectors.field.dropZone);
|
if (dropZone && !dropZone.contains(e.relatedTarget)) {
|
dropZone.classList.remove('dragover');
|
}
|
}
|
|
handleDragOver(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`);
|
}
|
}
|
|
/*******************************************************************************
|
* ACTION HANDLERS
|
*******************************************************************************/
|
|
handleAction(button) {
|
const action = button.dataset.action;
|
const fieldId = this.getFieldIdFromElement(button);
|
|
switch (action) {
|
case 'add-to-group':
|
this.handleAddToGroup(fieldId);
|
break;
|
case 'delete-group':
|
this.handleDeleteGroup(button);
|
break;
|
case 'delete-upload':
|
case 'remove-from-group':
|
this.handleRemoveItem(button);
|
break;
|
case 'upload':
|
this.handleSubmitUploads(fieldId);
|
break;
|
case 'restore':
|
this.handleRestoreSelected();
|
break;
|
case 'restore-all':
|
this.handleRestoreAll();
|
break;
|
case 'clear-cache':
|
this.handleClearCache();
|
break;
|
}
|
}
|
|
async handleAddToGroup(fieldId) {
|
const selected = this.selected.get(fieldId);
|
|
if (!selected || selected.size === 0) {
|
await this.createGroup(fieldId);
|
} else {
|
const group = await this.createGroup(fieldId);
|
if (!group) return;
|
|
for (const uploadId of selected) {
|
await this.addToGroup(uploadId, group.grid, false);
|
}
|
|
this.selectionHandlers.get(fieldId)?.clearSelection();
|
this.a11y.announce(`Created group with ${selected.size} items`);
|
}
|
}
|
|
async handleDeleteGroup(button) {
|
const group = button.closest(this.selectors.groups.container);
|
if (!group) return;
|
|
const groupId = group.dataset.groupId;
|
|
if (!confirm('Delete this group? Items will be moved back to the upload area.')) {
|
return;
|
}
|
|
await this.deleteGroup(groupId, true);
|
}
|
|
async handleRemoveItem(button) {
|
const item = button.closest(this.selectors.items.item);
|
if (!item) return;
|
|
if (!confirm('Remove this item?')) return;
|
|
const uploadId = item.dataset.uploadId;
|
const fieldId = this.getFieldIdFromElement(item);
|
|
await this.removeUpload(fieldId, uploadId);
|
}
|
|
handleSubmitUploads(fieldId) {
|
const fieldEl = this.fieldElements.get(fieldId);
|
if (!fieldEl) return;
|
|
fieldEl.element.closest('details')?.removeAttribute('open');
|
document.body.classList.add('uploading');
|
|
if (fieldEl.config.destination === 'post_group') {
|
this.submitGroupedUploads(fieldId);
|
} else {
|
this.queueUpload(fieldId);
|
}
|
}
|
|
/*******************************************************************************
|
* SELECTION MANAGEMENT
|
*******************************************************************************/
|
|
addFieldSelectionHandler(fieldId) {
|
if (this.selectionHandlers.has(fieldId)) return;
|
|
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) => {
|
if (['item-selected', 'item-deselected', 'range-selected'].includes(event)) {
|
this.syncSortableSelection(fieldId, data.selectedItems);
|
this.selected.set(fieldId, data.selectedItems);
|
}
|
});
|
|
this.selectionHandlers.set(fieldId, handler);
|
}
|
|
addGroupSelectionHandler(fieldId, groupId) {
|
const key = `${fieldId}_${groupId}`;
|
if (this.selectionHandlers.has(key)) return;
|
|
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) => {
|
if (['item-selected', 'item-deselected', 'range-selected'].includes(event)) {
|
this.selected.set(fieldId, data.selectedItems);
|
}
|
});
|
|
this.selectionHandlers.set(key, handler);
|
}
|
|
/*******************************************************************************
|
* UI UPDATES
|
*******************************************************************************/
|
|
updateFieldState(fieldId) {
|
const fieldEl = this.fieldElements.get(fieldId);
|
if (!fieldEl) return;
|
|
const uploadCount = this.getFieldUploadCount(fieldId);
|
const groupCount = this.getFieldGroups(fieldId).length;
|
|
fieldEl.element.dataset.hasUploads = uploadCount > 0;
|
fieldEl.element.dataset.uploadCount = uploadCount;
|
fieldEl.element.dataset.hasGroups = groupCount > 0;
|
|
if (fieldEl.ui.preview) {
|
fieldEl.ui.preview.setAttribute('aria-label',
|
`Upload preview area with ${uploadCount} item${uploadCount !== 1 ? 's' : ''}`
|
);
|
}
|
}
|
|
updateFieldProgress(fieldId, current, total, message) {
|
const fieldEl = this.fieldElements.get(fieldId);
|
const progress = fieldEl?.ui?.progress;
|
if (!progress?.container) return;
|
|
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.container.hidden = current === total;
|
}
|
|
updateFieldStatus(fieldId, status) {
|
// Status is now derived from uploads, no field-level status needed
|
}
|
|
async updateUploadStatus(uploadId, status) {
|
const upload = this.uploadStore.get(uploadId);
|
if (!upload) return;
|
|
upload.status = status;
|
await 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;
|
|
// Update class
|
uploadEl.element.className = uploadEl.element.className.replace(/status-[\w-]+/g, '');
|
uploadEl.element.classList.add(`status-${upload.status}`);
|
|
// Update progress
|
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);
|
const progress = uploadEl?.element?.querySelector('.progress');
|
if (!progress) return;
|
|
if (show) {
|
progress.style.removeProperty('animation');
|
progress.hidden = false;
|
} else {
|
progress.style.animation = 'fadeOut var(--transition-base)';
|
setTimeout(() => { progress.hidden = true; }, 300);
|
}
|
}
|
|
updateUploadItemProgress(uploadId, percent, status = null) {
|
const uploadEl = this.uploadElements.get(uploadId);
|
const progress = uploadEl?.element?.querySelector('.progress');
|
if (!progress) return;
|
|
const fill = progress.querySelector('.fill');
|
const details = progress.querySelector('.details');
|
const icon = progress.querySelector('.icon');
|
|
if (fill) fill.style.width = `${percent}%`;
|
if (status && details) details.textContent = this.statusMapping[status] || status;
|
if (status && icon) icon.innerHTML = this.getStatusIcon(status).outerHTML;
|
}
|
|
maybeLockUploads(fieldId) {
|
const fieldEl = this.fieldElements.get(fieldId);
|
if (!fieldEl?.ui?.dropZone) return;
|
|
const uploadCount = this.getFieldUploadCount(fieldId);
|
const maxFiles = fieldEl.config.destination === 'post_group' ? 20 : (fieldEl.config.maxFiles || 999);
|
|
fieldEl.ui.dropZone.hidden = uploadCount >= maxFiles;
|
fieldEl.element.classList.toggle('at-max-uploads', uploadCount >= maxFiles);
|
|
if (fieldEl.config.destination === 'post_group' && uploadCount >= maxFiles) {
|
this.a11y.announce('Maximum of 20 uploads reached.');
|
}
|
}
|
|
/*******************************************************************************
|
* CLEANUP
|
*******************************************************************************/
|
|
async clearUpload(uploadId) {
|
const uploadEl = this.uploadElements.get(uploadId);
|
|
if (uploadEl) {
|
this.revokePreviewUrl(uploadEl.preview);
|
if (uploadEl.element?.dataset.previewUrl) {
|
this.revokePreviewUrl(uploadEl.element.dataset.previewUrl);
|
}
|
}
|
|
this.uploadElements.delete(uploadId);
|
await this.uploadStore.delete(uploadId);
|
}
|
|
createPreviewUrl(file) {
|
const url = URL.createObjectURL(file);
|
this.previewUrls.add(url);
|
return url;
|
}
|
|
revokePreviewUrl(url) {
|
if (url?.startsWith('blob:')) {
|
URL.revokeObjectURL(url);
|
this.previewUrls.delete(url);
|
}
|
}
|
|
cleanupAllPreviewUrls() {
|
this.previewUrls.forEach(url => {
|
try { URL.revokeObjectURL(url); } catch (e) {}
|
});
|
this.previewUrls.clear();
|
}
|
|
/*******************************************************************************
|
* RECOVERY & RESTORATION
|
*******************************************************************************/
|
|
async showRecoveryNotification(byField) {
|
let totalUploads = 0;
|
let totalGroups = 0;
|
|
byField.forEach(field => {
|
totalUploads += field.uploads.length;
|
totalGroups += field.groups.size;
|
});
|
|
const notification = window.getTemplate('restoreNotification');
|
if (!notification) return;
|
|
const message = totalGroups > 0
|
? `${totalGroups} group${totalGroups > 1 ? 's' : ''} with ${totalUploads} upload${totalUploads > 1 ? 's' : ''} can be restored.`
|
: `${totalUploads} upload${totalUploads > 1 ? 's' : ''} can be recovered.`;
|
|
const detailsEl = notification.querySelector('.restore-details');
|
if (detailsEl) detailsEl.textContent = message;
|
|
// Build preview
|
for (const [fieldId, fieldData] of byField) {
|
const fieldTemplate = window.getTemplate('restoreField');
|
if (!fieldTemplate) continue;
|
|
const titleEl = fieldTemplate.querySelector('h3');
|
if (titleEl) titleEl.textContent = fieldId;
|
|
const itemGrid = fieldTemplate.querySelector('.item-grid.restore');
|
|
for (const upload of fieldData.uploads) {
|
const file = await this.getBlobData(upload.id);
|
if (!file) continue;
|
|
const uploadItem = this.createUploadElement({
|
id: upload.id,
|
preview: this.createPreviewUrl(file),
|
meta: upload.meta,
|
subtype: this.getSubtypeFromMime(file.type)
|
}, false);
|
|
uploadItem.dataset.fieldId = fieldId;
|
itemGrid?.appendChild(uploadItem);
|
}
|
|
notification.querySelector('.wrap')?.appendChild(fieldTemplate);
|
}
|
|
document.querySelector('.field.upload')?.appendChild(notification);
|
|
const dialog = document.querySelector('dialog.restore-uploads');
|
if (dialog) {
|
this.restoreModal = new window.jvbModal(dialog);
|
this.restoreSelection = new window.jvbHandleSelection({
|
container: dialog,
|
ui: {
|
selectAll: dialog.querySelector('#select-all-restore'),
|
count: dialog.querySelector('.selection-count')
|
}
|
});
|
this.restoreModal.handleOpen();
|
}
|
}
|
|
async handleRestoreSelected() {
|
const dialog = document.querySelector('dialog.restore-uploads');
|
if (!dialog) return;
|
|
const selected = [];
|
dialog.querySelectorAll('[type=checkbox]:checked').forEach(checkbox => {
|
const item = checkbox.closest('.item');
|
if (item) {
|
selected.push({
|
uploadId: item.dataset.uploadId,
|
fieldId: item.dataset.fieldId
|
});
|
}
|
});
|
|
if (selected.length > 0) {
|
await this.restoreUploads(selected);
|
}
|
|
this.cleanupRestoreModal();
|
}
|
|
async handleRestoreAll() {
|
const dialog = document.querySelector('dialog.restore-uploads');
|
if (!dialog) return;
|
|
const all = [];
|
dialog.querySelectorAll('.item.upload').forEach(item => {
|
all.push({
|
uploadId: item.dataset.uploadId,
|
fieldId: item.dataset.fieldId
|
});
|
});
|
|
await this.restoreUploads(all);
|
this.cleanupRestoreModal();
|
}
|
|
async restoreUploads(items) {
|
const byField = new Map();
|
|
items.forEach(item => {
|
if (!byField.has(item.fieldId)) {
|
byField.set(item.fieldId, []);
|
}
|
byField.get(item.fieldId).push(item.uploadId);
|
});
|
|
for (const [fieldId, uploadIds] of byField) {
|
await this.restoreFieldUploads(fieldId, uploadIds);
|
}
|
}
|
|
async restoreFieldUploads(fieldId, uploadIds) {
|
// Find or register field element
|
let fieldEl = this.fieldElements.get(fieldId);
|
|
if (!fieldEl) {
|
// Try to find by data attribute
|
const element = document.querySelector(`[data-uploader="${fieldId}"]`);
|
if (element) {
|
this.registerUploader(element);
|
fieldEl = this.fieldElements.get(fieldId);
|
}
|
}
|
|
if (!fieldEl) {
|
console.warn(`Field ${fieldId} not found for restoration`);
|
return;
|
}
|
|
// Show upload UI
|
if (fieldEl.ui.dropZone) fieldEl.ui.dropZone.hidden = true;
|
if (fieldEl.ui.groups?.display) fieldEl.ui.groups.display.hidden = false;
|
|
// Restore groups first
|
const groups = this.getFieldGroups(fieldId);
|
for (const group of groups) {
|
await this.restoreGroup(fieldId, group);
|
}
|
|
// Restore uploads
|
for (const uploadId of uploadIds) {
|
const upload = this.uploadStore.get(uploadId);
|
if (upload) {
|
await this.restoreUploadElement(fieldId, upload);
|
}
|
}
|
|
this.updateFieldState(fieldId);
|
this.refreshSortable(fieldId);
|
this.maybeLockUploads(fieldId);
|
|
// Auto-upload if configured
|
if (fieldEl.config.autoUpload && fieldEl.config.destination !== 'post_group') {
|
await this.queueUpload(fieldId);
|
}
|
}
|
|
async restoreGroup(fieldId, groupData) {
|
const group = await this.createGroup(fieldId, groupData.id);
|
if (!group) return;
|
|
// Restore field values
|
if (groupData.fields) {
|
const titleInput = group.element.querySelector('[name*="post_title"]');
|
const excerptInput = group.element.querySelector('[name*="post_excerpt"]');
|
|
if (titleInput && groupData.fields.post_title) {
|
titleInput.value = groupData.fields.post_title;
|
}
|
if (excerptInput && groupData.fields.post_excerpt) {
|
excerptInput.value = groupData.fields.post_excerpt;
|
}
|
}
|
}
|
|
async restoreUploadElement(fieldId, upload) {
|
const fieldEl = this.fieldElements.get(fieldId);
|
if (!fieldEl) return;
|
|
const file = await this.getBlobData(upload.id);
|
if (!file) return;
|
|
const preview = this.createPreviewUrl(file);
|
const element = this.createUploadElement({
|
id: upload.id,
|
preview,
|
meta: upload.meta,
|
subtype: this.getSubtypeFromMime(file.type)
|
}, fieldEl.config.destination === 'post_group');
|
|
// Determine location
|
let location;
|
if (upload.groupId) {
|
const groupEl = this.groupElements.get(upload.groupId);
|
location = groupEl?.grid || fieldEl.ui.preview;
|
} else {
|
location = fieldEl.ui.preview;
|
}
|
|
location.appendChild(element);
|
this.uploadElements.set(upload.id, { element, preview, location });
|
|
// Update upload status
|
upload.status = 'processed';
|
await this.uploadStore.save(upload);
|
}
|
|
handleClearCache() {
|
if (!confirm('Discard these uploads?')) return;
|
this.cleanupStoredData();
|
this.cleanupRestoreModal();
|
}
|
|
async cleanupStoredData() {
|
await this.uploadStore.clear();
|
await this.groupStore.clear();
|
}
|
|
cleanupRestoreModal() {
|
if (this.restoreModal) {
|
this.restoreModal.handleClose();
|
this.restoreSelection?.destroy();
|
this.restoreModal.destroy();
|
this.restoreModal.modal.remove();
|
this.restoreModal = null;
|
this.restoreSelection = null;
|
}
|
}
|
|
/*******************************************************************************
|
* HELPER METHODS
|
*******************************************************************************/
|
|
generateId(prefix) {
|
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
}
|
|
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}`;
|
}
|
|
getFieldIdFromElement(el) {
|
const field = el.closest(this.selectors.field.field);
|
return field?.dataset.uploader || null;
|
}
|
|
getUploadIdFromElement(el) {
|
const item = el.closest(this.selectors.items.item);
|
return item?.dataset.uploadId || null;
|
}
|
|
getSubtypeFromMime(mimeType) {
|
if (mimeType.startsWith('image/')) return 'image';
|
if (mimeType.startsWith('video/')) return 'video';
|
return 'document';
|
}
|
|
getStatusIcon(status) {
|
return window.getIcon(this.queue.icons[status]);
|
}
|
|
getStatusProgress(status) {
|
const progress = {
|
'processing': 28,
|
'queued': 50,
|
'uploading': 66,
|
'pending': 75,
|
'server_processing': 89,
|
'completed': 100
|
};
|
return progress[status] || 0;
|
}
|
|
formatBytes(bytes, decimals = 2) {
|
if (bytes === 0) return '0 Bytes';
|
const k = 1024;
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
|
}
|
|
createUploadElement(upload, draggable = false) {
|
const element = window.getTemplate('uploadItem');
|
if (!element) return null;
|
|
element.dataset.uploadId = upload.id;
|
element.dataset.subtype = upload.subtype || 'image';
|
|
const featured = element.querySelector('[name="featured"]');
|
const img = element.querySelector('img');
|
const video = element.querySelector('video');
|
const preview = element.querySelector('label > span');
|
const details = element.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 ext = 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[ext] || 'file');
|
if (preview) {
|
preview.innerText = fileName;
|
preview.prepend(icon);
|
}
|
img?.remove();
|
video?.remove();
|
break;
|
}
|
|
if (details) {
|
const template = window.getTemplate('uploadMeta');
|
if (template) details.append(template);
|
}
|
|
element.draggable = draggable;
|
|
// Update input IDs
|
element.querySelectorAll('input').forEach(input => {
|
const id = input.id;
|
if (id) {
|
const newId = id + upload.id;
|
const label = input.parentNode.querySelector(`label[for="${id}"]`);
|
input.id = newId;
|
if (label) label.htmlFor = newId;
|
}
|
});
|
|
return element;
|
}
|
|
/**
|
* Get all files for a form
|
*/
|
async getFilesForForm(formElement) {
|
const uploadFields = formElement.querySelectorAll('[data-upload-field]');
|
const allFiles = [];
|
|
for (const field of uploadFields) {
|
const fieldId = this.determineFieldId(field);
|
const files = await this.getFilesForField(fieldId);
|
allFiles.push(...files);
|
}
|
|
return allFiles;
|
}
|
|
/**
|
* Get all files for a field
|
*/
|
async getFilesForField(fieldId) {
|
const uploads = this.getFieldUploads(fieldId);
|
const files = [];
|
|
for (const upload of uploads) {
|
const file = await this.getBlobData(upload.id);
|
if (file) {
|
files.push({
|
file,
|
uploadId: upload.id,
|
fieldName: this.fieldElements.get(fieldId)?.config.name,
|
meta: upload.meta || {}
|
});
|
}
|
}
|
|
return files;
|
}
|
|
/*******************************************************************************
|
* EVENT SYSTEM
|
*******************************************************************************/
|
|
subscribe(callback) {
|
this.subscribers.add(callback);
|
return () => this.subscribers.delete(callback);
|
}
|
|
notify(event, data = {}) {
|
this.subscribers.forEach(cb => {
|
try { cb(event, data); } catch (e) { console.error('Subscriber error:', e); }
|
});
|
}
|
|
/*******************************************************************************
|
* DESTROY
|
*******************************************************************************/
|
|
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);
|
|
this.selectionHandlers.forEach(handler => handler.destroy());
|
this.selectionHandlers.clear();
|
|
this.cleanupAllPreviewUrls();
|
|
this.sortableInstances.forEach(instance => instance?.destroy?.());
|
this.sortableInstances.clear();
|
|
this.uploadElements.clear();
|
this.fieldElements.clear();
|
this.groupElements.clear();
|
this.selected.clear();
|
this.subscribers.clear();
|
}
|
}
|
|
// Initialize
|
document.addEventListener('DOMContentLoaded', async function() {
|
window.auth.subscribe((event) => {
|
if (event === 'auth-loaded') {
|
window.jvbUploads = new UploadManager();
|
}
|
});
|
});
|