class UploadManager {
|
constructor() {
|
this.a11y = window.jvbA11y;
|
this.queue = window.jvbQueue;
|
this.error = window.jvbError;
|
|
this.subscribers = new Set();
|
|
this.initStores();
|
this.initWorker();
|
|
//Maps for DOM references
|
this.fields = new Map();
|
this.uploads = new Map();
|
this.groups = new Map();
|
|
this.selected = new Map();
|
this.selectionHandlers = new Map();
|
this.sortables = new Map();
|
|
this.previewUrls = new Set();
|
this.initElements();
|
this.initListeners();
|
}
|
|
initStores() {
|
const {uploads, groups} = window.jvbStore.register(
|
'uploads',
|
[
|
{
|
storeName: 'uploads',
|
keyPath: 'id',
|
indexes: [
|
{ name: 'field', keyPath: 'field' },
|
{ name: 'status', keyPath: 'status' },
|
{ name: 'group', keyPath: 'group' },
|
{ name: 'src', keyPath: 'src' }
|
],
|
},
|
{
|
storeName: 'groups',
|
keyPath: 'id',
|
indexes: [
|
{ name: 'field', keyPath: 'field' },
|
{ name: 'src', keyPath: 'src' }
|
]
|
}
|
]
|
);
|
|
this.stores = {
|
uploads: uploads,
|
groups: groups,
|
ready: []
|
};
|
|
this.stores.uploads.subscribe(this.handleStores.bind(this, 'uploads'));
|
this.stores.groups.subscribe(this.handleStores.bind(this, 'groups'));
|
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;
|
if (!fieldId) {
|
return;
|
}
|
switch (event) {
|
case 'cancel-operation':
|
this.handleOperationCancelled(fieldId).then(()=>{});
|
break;
|
case 'operation-status':
|
this.handleFieldStatus(fieldId, operation).then(()=>{});
|
break;
|
case 'operation-completed':
|
this.handleOperationComplete(operation, fieldId).then(()=>{});
|
break;
|
case 'operation-failed':
|
case 'operation-failed-permanent':
|
this.handleOperationFailed(operation, fieldId).then(()=>{});
|
break;
|
}
|
});
|
}
|
|
storesReady() {
|
return this.stores.ready.length === 2;
|
}
|
|
handleStores(storeName, event) {
|
if (event === 'data-ready') {
|
this.stores.ready.push(storeName);
|
if (this.storesReady()) {
|
this.checkRecovery();
|
}
|
}
|
}
|
|
initWorker() {
|
this.worker = null;
|
this.workerState = {
|
worker: null,
|
tasks: new Map(),
|
restart: { count: 0, max: 3 },
|
settings: {
|
timeout: 3000,
|
maxConcurrent: 3,
|
restartAfterTimeout: true
|
}
|
};
|
}
|
|
initElements() {
|
this.selectors = {
|
fields: {
|
field: '[data-upload-field]',
|
input: 'input[type="file"]',
|
dropZone: '.file-upload-container',
|
preview: '.preview-wrap',
|
grid: '.item-grid.preview',
|
progress: {
|
progress: '.file-upload-container .progress',
|
fill: '.file-upload-container .progress .fill',
|
details: '.file-upload-container .progress .details',
|
icon: '.file-upload-container .progress .icon'
|
},
|
selectAll: '[name="select-all-uploads"]',
|
actions: '.selection-actions',
|
count: '.selection-count',
|
hidden: 'input[type="hidden"]'
|
},
|
// groups = selectors that affect groups as a whole
|
groups: {
|
container: '.group-display',
|
grid: '.item-grid.groups',
|
empty: '.empty-group',
|
header: '.sidebar .header',
|
},
|
// group = selectors that affect individual groups
|
group: {
|
item: '.upload-group',
|
actions: '.selection-actions',
|
selectAll: '[name="select-all-group"]',
|
count: '.group-header .info',
|
fields: 'details .fields',
|
grid: '.item-grid.group',
|
total: '.group-content .group-count'
|
},
|
items: {
|
item: '[data-upload-id]',
|
checkbox: '[name*="select-item"]',
|
featured: '[name="featured"]',
|
image: 'img',
|
details: 'details',
|
progress: {
|
progress: '.progress',
|
fill: '.fill',
|
details: '.details',
|
icon: '.icon'
|
}
|
}
|
};
|
}
|
|
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.handleDrop.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);
|
|
window.addEventListener('beforeunload', () => {
|
this.cleanupAllPreviewUrls();
|
});
|
}
|
|
async setUpload(uploadId, data) {
|
const defaults = {
|
id: uploadId,
|
attachment: null,
|
group: null,
|
field: null,
|
src: window.location.href,
|
blob: null,
|
status: 'local_processing',
|
operationId: null,
|
fields: {}
|
};
|
|
const upload = { ...defaults, ...data };
|
Object.preventExtensions(upload);
|
await this.stores.uploads.save(upload);
|
return upload;
|
}
|
|
/*********************************************************************
|
UTILITY
|
*********************************************************************/
|
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);
|
}
|
}
|
|
formatFile(upload) {
|
if (!upload.blob) return null;
|
return new File([upload.blob], upload.fields.originalName || 'file', {
|
type: upload.fields.type || upload.blob.type,
|
lastModified: upload.fields.lastModified || Date.now()
|
});
|
}
|
/*********************************************************************
|
LISTENERS
|
*********************************************************************/
|
handleClick(e) {
|
//Open the file input if it's a dropzone
|
let dropZone = window.targetCheck(e, this.selectors.fields.dropZone);
|
if (dropZone && !e.target.matches('input, button, a')){
|
dropZone.querySelector(this.selectors.fields.input)?.click();
|
}
|
|
//Handle action buttons
|
const button = window.targetCheck(e, '[data-action]');
|
if (button) this.handleAction(button);
|
}
|
handleAction(button) {
|
const action = button.dataset.action;
|
const fieldId = this.getFieldIdFromElement(button);
|
|
switch (action) {
|
case 'add-to-group':
|
this.handleAddToGroup(fieldId).then(()=>{});
|
break;
|
case 'delete-group':
|
this.handleDeleteGroup(button);
|
break;
|
case 'delete-upload':
|
case 'remove-from-group':
|
this.handleRemoveItem(button).then(()=>{});
|
break;
|
case 'upload':
|
this.queueUploads('uploads/groups',fieldId).then(()=>{});
|
break;
|
case 'restore':
|
this.handleRestoreSelected().then(()=>{});
|
break;
|
case 'restore-all':
|
this.handleRestoreAll().then(()=>{});
|
break;
|
case 'clear-cache':
|
this.handleClearCache().then(()=>{});
|
break;
|
}
|
}
|
handleChange(e) {
|
let fieldId = this.getFieldIdFromElement(e.target);
|
if (!fieldId) return;
|
|
if (e.target.matches(this.selectors.fields.input)) {
|
const files = Array.from(e.target.files);
|
if (files.length > 0) this.processFiles(fieldId, files).then(()=>{});
|
return;
|
}
|
|
// Skip selection-related inputs
|
if (e.target.matches(this.selectors.items.checkbox) ||
|
e.target.matches(this.selectors.items.featured) ||
|
e.target.matches('[name*="select-"]')) {
|
return;
|
}
|
|
let field = this.fields.get(fieldId);
|
if (!field || !field.config.autoUpload) return;
|
|
if (field.config.destination === 'post_group') {
|
this.handleGroupMetaChange(e.target);
|
} else {
|
this.queueUploadMeta(e).then(()=>{});
|
}
|
}
|
handleGroupMetaChange(input) {
|
const element = input.closest(this.selectors.group.fields);
|
if (!element) return;
|
|
const groupId = element.dataset.groupId;
|
const group = this.stores.groups.get(groupId); // Changed from this.groups
|
if (!group) return;
|
|
window.debouncer.schedule(`group-meta-${groupId}`, async (input, groupId) => {
|
let name = input.name
|
.replace(`${groupId}_`, '')
|
.replace(`${groupId}[`, '')
|
.replace(']', '');
|
group.fields[name] = input.value;
|
await this.setGroup(groupId, group);
|
}, 300);
|
}
|
handleDragEnter(e) {
|
if (!e.dataTransfer.types.includes('Files')) return;
|
const dropZone = e.target.closest(this.selectors.fields.dropZone);
|
if (dropZone) {
|
e.preventDefault();
|
dropZone.classList.add('dragover');
|
}
|
}
|
handleDragLeave(e) {
|
const dropZone = e.target.closest(this.selectors.fields.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.fields.dropZone);
|
if (dropZone) {
|
e.preventDefault();
|
e.dataTransfer.dropEffect = 'copy';
|
}
|
}
|
handleDrop(e) {
|
const dropZone = e.target.closest(this.selectors.fields.dropZone);
|
if (!dropZone) return;
|
|
e.preventDefault();
|
dropZone.classList.remove('dragover');
|
dropZone.classList.add('uploading');
|
|
|
const files = Array.from(e.dataTransfer.files);
|
if (files.length === 0) return;
|
|
const fieldId = this.getFieldIdFromElement(dropZone);
|
if (fieldId) {
|
this.processFiles(fieldId, files).then(()=>{});
|
this.a11y.announce(`${files.length} file(s) dropped for upload`);
|
}
|
}
|
|
async queueUploads(endpoint, fieldId) {
|
let data = new FormData();
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
let uploads = this.stores.uploads.filterByIndex({field: fieldId});
|
if (uploads.length === 0) return;
|
|
const [ isUpload, isGroups] =
|
[ endpoint === 'uploads', endpoint === 'uploads/groups'];
|
|
data.append('fieldId', field.id);
|
data.append('content', field.config.content);
|
|
if (isUpload) {
|
data.append('mode', field.config.mode);
|
data.append('field_name', field.config.name);
|
data.append('fieldId', field.id);
|
data.append('field_type', field.config.type);
|
data.append('subtype', field.config.subtype);
|
data.append('item_id', field.config.itemID);
|
data.append('destination', field.config.destination);
|
}
|
|
let posts, uploadMap, files;
|
if (isGroups) {
|
({posts, uploadMap, files} = this.collectGroups(fieldId));
|
} else if (isUpload) {
|
({uploadMap, files} = this.collectUploads(fieldId));
|
}
|
|
if (isGroups) {
|
data.append('posts', JSON.stringify(posts));
|
}
|
files.forEach(file => {
|
data.append('files[]', file);
|
});
|
data.append('upload_ids', JSON.stringify(uploadMap));
|
|
let title, popup;
|
if (isUpload) {
|
title = `Uploading ${uploads.length} file${uploads.length>1?'s':''} to server...`;
|
popup = `Uploading ${uploads.length} file${uploads.length>1?'s':''}...`;
|
} else if (isGroups) {
|
title = `Creating ${posts.length} ${field.config.content}${posts.length > 1 ? 's' : ''} from uploads...`;
|
popup = `Creating ${posts.length} post${posts.length>1?'s':''}...`;
|
}
|
await this.setBulkUpload(uploads, 'status', 'queued');
|
let operationId = this.sendToQueue(endpoint, data, title, popup);
|
|
if (endpoint === 'uploads/groups') {
|
let details = field.element.closest('details');
|
if (details) {
|
details.open = false;
|
}
|
}
|
if (operationId) {
|
field.operationId = operationId;
|
await this.setBulkUpload(uploads, 'operationId', operationId);
|
await this.setBulkUpload(uploads, 'status', 'uploading');
|
await this.setBulkGroup(fieldId, 'operationId', operationId);
|
this.fields.set(field.id, field);
|
} else {
|
await this.setBulkUpload(uploads, 'status', 'failed');
|
}
|
this.notify('sent-to-queue', fieldId);
|
return operationId;
|
}
|
|
async sendToQueue(endpoint, data, title = '', popup = '', mergable = false) {
|
if (popup === '') {
|
popup = title;
|
}
|
const operation = {
|
endpoint: endpoint,
|
method: 'POST',
|
data: data,
|
title: title,
|
popup: popup,
|
canMerge: mergable,
|
sendNow: endpoint === 'uploads/groups',
|
headers: {
|
'action_nonce': window.auth.getNonce('dash')
|
},
|
append: '_upload'
|
}
|
try {
|
return await this.queue.addToQueue(operation);
|
} catch (error) {
|
this.error.log(error, {
|
component: 'UploadManager',
|
action: 'sentToQueue'
|
});
|
return false;
|
}
|
}
|
|
collectGroups(fieldId) {
|
let uploads = this.stores.uploads.filterByIndex({field: fieldId});
|
let groups = this.stores.groups.filterByIndex({field: fieldId});
|
|
let posts = [];
|
let uploadMap = [];
|
let files = [];
|
|
for (const group of groups) {
|
const post = {
|
images: [],
|
fields: group.fields??{}
|
};
|
|
const groupUploads = uploads.filter(u => u.group === group.id);
|
for (const upload of groupUploads) {
|
const file = this.formatFile(upload);
|
if (file) {
|
files.push(file);
|
const imageData = {
|
upload_id: upload.id,
|
index: uploadMap.length
|
};
|
let uploadEl = this.uploads.get(upload.id);
|
if (uploadEl.ui?.featured?.checked) {
|
post.fields.featured = upload.id;
|
}
|
post.images.push(imageData);
|
uploadMap.push(upload.id);
|
}
|
}
|
posts.push(post);
|
}
|
|
const remaining = uploads.filter(u => !u.group);
|
|
for (const upload of remaining) {
|
const post = {
|
images: [],
|
fields: {}
|
};
|
|
const file = this.formatFile(upload);
|
if (file) {
|
files.push(file);
|
|
const imageData = {
|
upload_id: upload.id,
|
index: uploadMap.length
|
};
|
post.images.push(imageData);
|
uploadMap.push(upload.id);
|
}
|
posts.push(post);
|
}
|
return {posts, uploadMap, files};
|
}
|
|
collectUploads(fieldId) {
|
let uploads = this.stores.uploads.filterByIndex({field: fieldId});
|
if (uploads.length === 0) return;
|
|
let uploadMap = [];
|
let files = [];
|
|
for (const upload of uploads) {
|
const file = this.formatFile(upload);
|
if (file) {
|
files.push(file);
|
uploadMap.push(upload.id);
|
}
|
}
|
return { uploadMap, files };
|
}
|
|
async queueUploadMeta(e) {
|
const uploadId = e.target.closest(this.selectors.items.item)?.dataset.uploadId;
|
const upload = this.stores.uploads.get(uploadId);
|
if (!uploadId || !upload) return;
|
|
const field = this.fields.get(upload.field);
|
if (!field) return;
|
|
let data = {};
|
data[e.target.name] = e.target.value;
|
|
upload.fields = { ...upload.fields, ...data };
|
await this.setUpload(upload.id, upload);
|
|
let queueData = {};
|
queueData[upload.attachmentId ?? upload.id] = upload.fields;
|
return await this.sendToQueue('uploads/meta', queueData, 'Uploading Meta', '', true);
|
}
|
|
async handleOperationComplete(operation, fieldId) {
|
const response = operation.response;
|
|
// Handle direct upload results (from uploads endpoint)
|
if (response?.data) {
|
const results = Array.isArray(response.data) ? response.data : Object.values(response.data);
|
for (const result of results) {
|
if (result.upload_id && result.attachment_id) {
|
const upload = this.stores.uploads.get(result.upload_id);
|
if (upload) {
|
upload.attachmentId = result.attachment_id;
|
upload.status = 'completed';
|
await this.stores.uploads.save(upload);
|
}
|
}
|
}
|
}
|
|
// Clear completed uploads and groups
|
const uploads = this.stores.uploads.filterByIndex({field: fieldId});
|
const groups = this.stores.groups.filterByIndex({field: fieldId});
|
|
await Promise.all([
|
...uploads
|
.filter(upload => upload.status === 'completed')
|
.map(upload => this.clearUpload(upload.id)),
|
...groups.map(group => this.stores.groups.delete(group.id))
|
]);
|
|
this.notify('uploads-complete', { fieldId, response });
|
}
|
/*********************************************************************
|
FIELD LOGIC
|
*********************************************************************/
|
scanFields(container, autoUpload = true) {
|
const fields = container.querySelectorAll(this.selectors.fields.field);
|
fields.forEach(uploader => this.registerField(uploader, autoUpload));
|
}
|
|
registerField(element, autoUpload = true, id = null) {
|
const data = {
|
element: element,
|
id: (id) ? id : this.determineFieldId(element),
|
config: this.extractFieldConfig(element, autoUpload),
|
uploads: new Set(),
|
operationId: null,
|
groups: [],
|
ui: window.uiFromSelectors(this.selectors.fields, element),
|
groupUI: window.uiFromSelectors(this.selectors.groups, element)
|
};
|
|
|
this.fields.set(data.id, data);
|
|
element.dataset.uploader = data.id;
|
this.getSelectionHandler(data.id);
|
if (data.config.type !== 'single') {
|
this.initSortable(data.id);
|
}
|
|
return data.id;
|
}
|
|
extractFieldConfig(fieldElement, autoUpload) {
|
return {
|
autoUpload: autoUpload,
|
destination: fieldElement.dataset.destination || 'meta', //TODO: why do we need this?
|
content: this.extractFieldContent(fieldElement),
|
mode: fieldElement.dataset.mode || 'direct',
|
type: fieldElement.dataset.type || 'single',
|
name: fieldElement.dataset.field,
|
itemID: this.extractFieldItemId(fieldElement)??0,
|
maxFiles: parseInt(fieldElement.dataset.maxFiles)??25,
|
subType: fieldElement.dataset.subtype?? 'image'
|
};
|
}
|
|
extractFieldContent(fieldElement) {
|
return fieldElement.dataset.content ||
|
fieldElement.closest('dialog')?.dataset.content ||
|
fieldElement.closest('form')?.dataset.save || null;
|
}
|
extractFieldItemId(fieldElement) {
|
return fieldElement.dataset.itemId ||
|
fieldElement.closest('dialog')?.dataset.itemId || null;
|
}
|
|
determineFieldId(fieldElement) {
|
let content = this.extractFieldContent(fieldElement);
|
content = (content === null) ? '' : content+'_';
|
|
let itemID = this.extractFieldItemId(fieldElement);
|
itemID = (itemID === null) ? '' : itemID+'_';
|
|
const field = fieldElement.dataset.field || '';
|
|
return `${content}${itemID}${field}`;
|
}
|
|
getFieldIdFromElement(el) {
|
const field = el.closest(this.selectors.fields.field);
|
return field?.dataset.uploader || null;
|
}
|
|
updateFieldProgress(fieldId, current, total, message) {
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
window.showProgress(field.ui.progress,current, total, message);
|
}
|
/*********************************************************************
|
IMAGE PROCESSING FILE PROCESSING
|
*********************************************************************/
|
getWorker() {
|
if (!this.workerState.worker && typeof OffscreenCanvas !== 'undefined') {
|
this.workerState.worker = new Worker('worker.js');
|
this.workerState.worker.onmessage = (e) => this.handleWorkerMessage(e);
|
this.workerState.worker.onerror = (e) => this.handleWorkerError(e);
|
}
|
return this.workerState.worker;
|
}
|
|
handleWorkerMessage(e) {
|
const { id, blob } = e.data;
|
const task = this.workerState.tasks.get(id);
|
if (task) {
|
clearTimeout(task.timeoutId);
|
task.resolve(blob);
|
this.workerState.tasks.delete(id);
|
}
|
}
|
|
handleWorkerError(e) {
|
// Reject all pending tasks
|
this.workerState.tasks.forEach(task => {
|
clearTimeout(task.timeoutId);
|
task.reject(e);
|
});
|
this.workerState.tasks.clear();
|
this.restartWorker();
|
}
|
|
restartWorker() {
|
if (this.workerState.worker) {
|
this.workerState.worker.terminate();
|
this.workerState.worker = null;
|
}
|
this.workerState.restart.count++;
|
}
|
async processImages(files, maxWidth = 2200, maxHeight = 2200){
|
const results = [];
|
const queue = [...files];
|
const concurrency = this.workerState.settings.maxConcurrent;
|
|
const processNext = async () => {
|
while (queue.length > 0) {
|
const file = queue.shift();
|
results.push(await this.processImage(file, maxWidth, maxHeight));
|
}
|
};
|
|
await Promise.all(
|
Array.from({length: Math.min(concurrency, files.length)}, () => processNext())
|
);
|
|
return results;
|
}
|
async processImage(file, maxWidth = 2200, maxHeight = 2200, timeout = 3000){
|
if (typeof OffscreenCanvas=== 'undefined') {
|
return this.resizeImage(file,maxWidth,maxHeight);
|
}
|
try {
|
return await this.withTimeout(
|
this.workerImage(file, maxWidth, maxHeight),
|
timeout
|
);
|
} catch (e) {
|
return this.resizeImage(file, maxWidth, maxHeight);
|
}
|
}
|
withTimeout(promise, ms) {
|
return Promise.race([
|
promise,
|
new Promise((_, reject) =>
|
setTimeout(() => reject(new Error('Timeout')), ms)
|
)
|
]);
|
}
|
|
|
async workerImage(file, maxWidth = 2200, maxHeight = 2200) {
|
const { settings, restart } = this.workerState;
|
|
if (restart.count >= restart.max) {
|
throw new Error('Worker max restarts exceeded');
|
}
|
|
const bitmap = await createImageBitmap(file);
|
|
let { width, height } = bitmap;
|
if (width > maxWidth || height > maxHeight) {
|
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
width = Math.round(width * ratio);
|
height = Math.round(height * ratio);
|
}
|
|
const worker = this.getWorker();
|
const id = crypto.randomUUID();
|
|
return new Promise((resolve, reject) => {
|
const timeoutId = setTimeout(() => {
|
this.workerState.tasks.delete(id);
|
if (settings.restartAfterTimeout) {
|
this.restartWorker();
|
}
|
reject(new Error('Timeout'));
|
}, settings.timeout);
|
|
this.workerState.tasks.set(id, { resolve, reject, timeoutId });
|
|
worker.postMessage(
|
{ id, imageBitmap: bitmap, width, height, type: file.type, quality: 0.9 },
|
[bitmap]
|
);
|
});
|
}
|
resizeImage(file, maxWidth, maxHeight) {
|
return new Promise((resolve) => {
|
const img = new Image();
|
img.onload = () => {
|
URL.revokeObjectURL(img.src);
|
// Calculate new dimensions keeping aspect ratio
|
let { width, height } = img;
|
|
if (width > maxWidth || height > maxHeight) {
|
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
width = Math.round(width * ratio);
|
height = Math.round(height * ratio);
|
}
|
|
// Draw to canvas at new size
|
const canvas = document.createElement('canvas');
|
canvas.width = width;
|
canvas.height = height;
|
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
|
|
// Export as blob for upload
|
canvas.toBlob(resolve, file.type, 0.9);
|
};
|
img.src = URL.createObjectURL(file);
|
});
|
}
|
|
async processFiles(fieldId, files) {
|
let field = this.fields.get(fieldId);
|
if (!field) return;
|
|
if (field.groupUI.container) {
|
field.groupUI.container.hidden = false;
|
}
|
|
const totalFiles = files.length;
|
let processed = 0;
|
|
this.updateFieldProgress(fieldId, 0, totalFiles, 'Processing files...');
|
|
// Create upload records for all files first
|
const uploadEntries = await Promise.all(
|
files.map(async (file) => {
|
const uploadId = window.generateID('upload');
|
const upload = await this.setUpload(uploadId, {
|
id: uploadId,
|
field: fieldId,
|
status: 'local_processing',
|
blob: null,
|
fields: {
|
originalName: file.name,
|
originalSize: file.size,
|
type: file.type,
|
lastModified: file.lastModified
|
}
|
});
|
|
const element = await this.createUpload(uploadId, file, fieldId);
|
this.uploads.set(uploadId, {
|
element: element,
|
ui: window.uiFromSelectors(this.selectors.items, element)
|
});
|
|
await this.addToGroup(uploadId, null);
|
|
return { uploadId, upload, file };
|
})
|
);
|
|
// Batch process images with concurrency control
|
const imageEntries = uploadEntries.filter(e => e.file.type.startsWith('image/'));
|
const otherEntries = uploadEntries.filter(e => !e.file.type.startsWith('image/'));
|
|
// Process images in batches
|
const processedBlobs = await this.processImages(
|
imageEntries.map(e => e.file)
|
);
|
|
// Update image uploads with processed blobs
|
for (let i = 0; i < imageEntries.length; i++) {
|
const { uploadId, upload } = imageEntries[i];
|
upload.blob = processedBlobs[i];
|
upload.fields.size = processedBlobs[i].size;
|
upload.status = 'queued';
|
await this.setUpload(uploadId, upload);
|
processed++;
|
this.updateFieldProgress(fieldId, processed, totalFiles, 'Processing files...');
|
}
|
|
// Handle non-image files (no processing needed)
|
for (const { uploadId, upload, file } of otherEntries) {
|
upload.blob = file;
|
upload.status = 'queued';
|
await this.setUpload(uploadId, upload);
|
processed++;
|
this.updateFieldProgress(fieldId, processed, totalFiles, 'Processing files...');
|
}
|
|
this.maybeLockUploads(fieldId);
|
if (field.config.autoUpload && field.config.destination !== 'post_group') {
|
await this.queueUploads('uploads', fieldId);
|
}
|
}
|
/*************************************************************
|
RECOVERY
|
*************************************************************/
|
async checkRecovery() {
|
const pendingUploads = this.stores.uploads.filterByIndex({status: ['local_processing', 'queued', 'uploading']});
|
if (pendingUploads.length === 0) return;
|
|
let notification = window.getTemplate('restoreNotification');
|
if (!notification) {
|
this.error.log(
|
'No restore notification',
|
{
|
component: 'UploadManager',
|
src: window.location.href
|
}
|
);
|
return;
|
}
|
// Group by source page
|
const bySource = new Map();
|
pendingUploads.forEach(upload => {
|
const src = upload.src || 'unknown';
|
if (!bySource.has(src)) bySource.set(src, []);
|
bySource.get(src).push(upload);
|
});
|
|
const currentSrc = window.location.href;
|
|
|
let source = bySource.size > 1 ? ` across ${bySource.size} pages` : '';
|
let upload = pendingUploads.length > 1 ? 'uploads' : 'upload';
|
let message = `${pendingUploads.length} ${upload} can be recovered${source}`;
|
|
let details = notification.querySelector('.details');
|
if (details) {
|
details.textContent = message;
|
}
|
|
let i = 1;
|
for (const [src, uploads] of bySource) {
|
let template = window.getTemplate('restoreField');
|
if (!template) continue;
|
let fieldId = this.registerField(template,false, 'recovery_'+i);
|
let field = this.fields.get(fieldId);
|
i++;
|
let isCurrent = src === currentSrc;
|
let [
|
h3,
|
a,
|
grid
|
] = [
|
template.querySelector('h3'),
|
template.querySelector('h3 a'),
|
template.querySelector('.item-grid')
|
];
|
|
template.open = isCurrent;
|
if (!isCurrent) {
|
[a.href, a.title,a.textContent] =
|
[src, 'Navigate to Page and Restore', src];
|
} else {
|
a.remove();
|
h3.textContent = 'From this page:';
|
}
|
|
let filteredGroupIds = [...new Set(uploads.map(upload => upload.group??'preview'))];
|
|
for (let groupId of filteredGroupIds) {
|
let group = (groupId === 'preview') ? true : this.stores.groups.get(groupId);
|
if (!group) continue;
|
|
let groupElement = await this.createGroupElement(groupId,field.id);
|
let groupGrid = groupElement.querySelector('.item-grid');
|
let theseUploads = uploads.filter(upload => upload.group === (groupId === 'preview') ? null : groupId);
|
for (const [key, value] of Object.entries(group.fields ?? {})) {
|
let field = groupElement.querySelector(`input[name*="${key}"]`);
|
if (field) field.value = value;
|
}
|
for (let upload of theseUploads) {
|
let item = await this.createUpload(upload.id, this.formatFile(upload), field.id);
|
groupGrid.append(item);
|
}
|
|
grid.append(groupElement);
|
}
|
notification.querySelector('.wrap').append(template);
|
}
|
document.body.append(notification);
|
notification = document.querySelector('dialog.restore-uploads');
|
this.restoreModal = new window.jvbModal(notification);
|
this.restoreSelection = new window.jvbHandleSelection({
|
container: notification,
|
wrapper: '.restore-uploads .wrap',
|
bulkControls: '.selection-actions',
|
selectAll: '#select-all-restore',
|
count: '.selection-count'
|
});
|
this.restoreModal.handleOpen();
|
}
|
|
async handleRestoreSelected() {
|
if (!this.restoreSelection) return;
|
|
let selected = Array.from(this.restoreSelection.selectedItems);
|
if (selected.length === 0) {
|
return;
|
}
|
|
await this.restoreSelectedUploads(selected);
|
}
|
async handleRestoreAll() {
|
if (!this.restoreModal) return;
|
const allUploads = Array.from(this.restoreModal.modal.querySelectorAll('.item.upload')).map(item => item.dataset.uploadId);
|
|
await this.restoreSelectedUploads(allUploads);
|
}
|
|
async restoreSelectedUploads(selectedUploads) {
|
let currentPage = window.location.href;
|
|
let uploads = Array.from(this.stores.uploads.data.values()).filter(
|
upload => selectedUploads.includes(upload.id) && upload.src === currentPage
|
);
|
|
let groups = [... new Set(uploads.map(upload => upload.group))].filter(Boolean);
|
|
let fieldId = uploads[0].field;
|
let field = document.querySelector(`[data-uploader="${fieldId}"]`);
|
if (!field) {
|
console.log('No field found for '+fieldId);
|
return;
|
}
|
let fieldData = this.fields.get(fieldId);
|
if (fieldData.groupUI.container) {
|
fieldData.groupUI.container.hidden = false;
|
}
|
|
let usedIds = [];
|
for (let gr of groups) {
|
|
let group = this.stores.groups.get(gr);
|
await this.createGroup(fieldId,gr);
|
let element = this.groups.get(gr);
|
|
let theseUploads = uploads.filter(upload => upload.group === gr);
|
if (group && this.groups.has(gr)) {
|
let fields = group.fields;
|
|
for (const [key, value] of Object.entries(fields)) {
|
let fi = element.element.querySelector(`input[name*="${key}"]`);
|
if (fi) {
|
fi.value = value;
|
}
|
}
|
}else {
|
//Couldn't restore the group for some reason, just add it to the main preview grid instead
|
gr = null;
|
}
|
|
for (let upload of theseUploads) {
|
let item = await this.createUpload(upload.id, this.formatFile(upload), fieldId);
|
this.uploads.set(upload.id, {
|
element: item,
|
ui: window.uiFromSelectors(this.selectors.items, item)
|
});
|
await this.addToGroup(upload.id, gr);
|
usedIds.push(upload.id);
|
}
|
|
}
|
|
let remaining = uploads.filter(upload => !usedIds.includes(upload.id));
|
for (let upload of remaining) {
|
let item = await this.createUpload(upload.id, this.formatFile(upload), fieldId);
|
this.uploads.set(upload.id, {
|
element: item,
|
ui: window.uiFromSelectors(this.selectors.items, item)
|
});
|
await this.addToGroup(upload.id, null);
|
}
|
|
this.cleanupRestore();
|
}
|
|
cleanupRestore() {
|
this.restoreModal.handleClose();
|
this.restoreSelection.destroy();
|
this.restoreSelection = null;
|
this.restoreModal.destroy();
|
this.restoreModal.modal.remove();
|
this.restoreModal = null;
|
}
|
/*******************************************************************************
|
STATUS MANAGEMENT
|
*******************************************************************************/
|
getStatusText(status) {
|
let map = {
|
'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'
|
};
|
|
return map[status]||status;
|
}
|
getStatusProgress(status) {
|
let progress = {
|
'local_processing': 28,
|
'queued': 50,
|
'uploading': 66,
|
'pending': 75,
|
'processing': 89,
|
'completed': 100
|
};
|
return progress[status]??0;
|
}
|
/*******************************************************************************
|
UPLOAD METHODS
|
*******************************************************************************/
|
async createUpload(uploadId, file, fieldId) {
|
let image = window.getTemplate('uploadItem');
|
if (!image) return null;
|
|
let field = this.fields.get(fieldId);
|
if (!field) return null;
|
|
image.dataset.uploadId = uploadId;
|
let mimeType = this.getSubtypeFromMime(file.type)||'image';
|
image.dataset.subtype = mimeType;
|
|
let [featured, img, video, preview, details] = [
|
image.querySelector('[name="featured"]'),
|
image.querySelector('img'),
|
image.querySelector('video'),
|
image.querySelector('label > span'),
|
image.querySelector('details')
|
];
|
|
if (featured) featured.value = uploadId;
|
switch (mimeType) {
|
case 'image':
|
if (img) {
|
const previewUrl = this.createPreviewUrl(file);
|
img.src = previewUrl;
|
img.alt = file.name || '';
|
img.dataset.previewUrl = previewUrl;
|
}
|
video?.remove();
|
preview?.remove();
|
break;
|
case 'video':
|
if (video){
|
const previewUrl = this.createPreviewUrl(file);
|
video.src = previewUrl;
|
video.dataset.previewUrl = previewUrl;
|
}
|
img?.remove();
|
preview?.remove();
|
break;
|
case 'document':
|
let ext = file.name.split('.').pop()?.toLowerCase()??'';
|
let map = {
|
'pdf': 'file-pdf', 'csv': 'file-csv',
|
'doc': 'file-doc', 'docx': 'file-doc',
|
'txt': 'file-txt', 'xls': 'file-xls', 'xlsx': 'file-xls'
|
};
|
let icon = window.getIcon(map[ext]??'file');
|
if (preview) {
|
preview.innerText = file.name;
|
preview.prepend(icon);
|
}
|
img?.remove();
|
video?.remove();
|
break;
|
}
|
|
if (details) {
|
let template = window.getTemplate('uploadMeta');
|
if (template) details.append(template);
|
}
|
|
image.draggable = field.config.type !== 'single'??false;
|
|
image.querySelectorAll('input').forEach(input => {
|
let id = input.id;
|
if (id) {
|
let newId = id + uploadId;
|
let label = input.parentNode.querySelector(`label[for="${id}"]`);
|
input.id = newId;
|
if (label) label.htmlFor = newId;
|
}
|
});
|
|
return image;
|
}
|
|
getSubtypeFromMime(mimeType) {
|
if (mimeType.startsWith('image/')) return 'image';
|
if (mimeType.startsWith('video/')) return 'video';
|
return 'document';
|
}
|
/**
|
* Called by handleAction
|
* @param button
|
*/
|
async handleRemoveItem(button) {
|
const item = button.closest(this.selectors.items.item);
|
if (!item) return;
|
|
const uploadId = item.dataset.uploadId;
|
if (!confirm('Remove this item?')) return;
|
await this.removeUpload(uploadId);
|
this.a11y.announce('Item removed');
|
}
|
|
async setBulkUpload(uploads, key, value) {
|
const promises = Array.from(uploads).map(async (upload) => {
|
if (key === 'status') {
|
await this.setUploadStatus(upload, value);
|
}
|
upload[key] = value;
|
return this.stores.uploads.save(upload);
|
});
|
await Promise.all(promises);
|
}
|
|
async setUploadStatus(upload, status) {
|
if (upload.progress) {
|
window.showProgress(upload.progress, this.getStatusProgress(status), 100, this.getStatusText(status), this.queue.icons[status]??'');
|
}
|
}
|
|
async removeUpload(uploadId) {
|
let upload = this.stores.uploads.get(uploadId);
|
if (!upload) return;
|
if (upload.group) {
|
let group = this.stores.groups.get(upload.group);
|
group.uploads = group.uploads.filter(id => id !== uploadId);
|
if (group.uploads.length === 0) {
|
await this.removeGroup(group.id, false);
|
}
|
}
|
|
await this.clearUpload(uploadId);
|
this.maybeLockUploads(upload.field);
|
|
let handler = this.selectionHandlers.get(upload.field);
|
if (handler){
|
handler.deselect(uploadId);
|
}
|
|
this.a11y.announce('Upload removed');
|
}
|
|
async clearUpload(uploadId) {
|
const element = this.uploads.get(uploadId);
|
if (element) {
|
this.revokePreviewUrl(element.preview);
|
if (element.element) {
|
const previewUrl = element.element.dataset.previewUrl;
|
this.revokePreviewUrl(previewUrl);
|
element.element.remove();
|
}
|
}
|
this.uploads.delete(uploadId);
|
await this.stores.uploads.delete(uploadId);
|
}
|
|
/*******************************************************************************
|
GROUP METHODS
|
*******************************************************************************/
|
async handleAddToGroup(fieldId) {
|
const selected = this.selected.get(fieldId);
|
if (!selected || selected.size === 0) return;
|
|
let groupId = await this.createGroup(fieldId);
|
if (!groupId) return;
|
|
await Promise.all(
|
Array.from(selected).map(uploadId => this.addToGroup(uploadId, groupId))
|
);
|
|
this.selectionHandlers.get(fieldId)?.clearSelection();
|
this.a11y.announce(`Created group with ${selected.size} items`);
|
}
|
async createGroup(fieldId, groupId = null) {
|
let field = this.fields.get(fieldId);
|
if (!field) return;
|
|
if (!groupId) {
|
groupId = window.generateID('group');
|
}
|
|
const element = this.createGroupElement(groupId, fieldId);
|
if (!element) return null;
|
|
field.groupUI.grid.append(element);
|
|
// Create Sortable for this group's grid
|
const grid = element.querySelector('.item-grid');
|
if (grid) {
|
grid.dataset.groupId = groupId;
|
this.createSortableForGrid(fieldId, grid, groupId);
|
}
|
|
let storedData = this.stores.groups.data.has(groupId)
|
? this.stores.groups.data.get(groupId)
|
: {};
|
|
await this.setGroup(groupId, { ...storedData, id: groupId, field: fieldId });
|
|
return groupId;
|
}
|
|
createGroupElement(groupId, fieldId = null) {
|
let element = window.getTemplate('imageGroup');
|
if (!element) return;
|
|
element.dataset.groupId = groupId;
|
if (fieldId) {
|
element.dataset.fieldId = fieldId;
|
}
|
|
const selectAll = element.querySelector('[data-select-all]');
|
if (selectAll) {
|
const newId = `select-all-${groupId}`;
|
const label = element.querySelector(`label[for="${selectAll.id}"]`);
|
selectAll.id = newId;
|
selectAll.name = newId;
|
if (label) label.htmlFor = newId;
|
}
|
|
let fields = window.getTemplate('groupMetadata');
|
let container = element.querySelector('.fields');
|
if (fields && container) {
|
container.append(fields);
|
|
let title = container.querySelector('[name="post_title"]');
|
let excerpt = container.querySelector('[name="post_excerpt"]');
|
|
if (title) {
|
title.id = `${groupId}_title`;
|
title.name = `${groupId}[post_title]`;
|
}
|
if (excerpt) {
|
excerpt.id = `${groupId}_excerpt`;
|
excerpt.name = `${groupId}[post_excerpt]`;
|
}
|
} else {
|
element.querySelector('details')?.remove();
|
}
|
|
const grid = element.querySelector('.item-grid');
|
if (grid) {
|
grid.dataset.groupId = groupId;
|
}
|
|
this.groups.set(groupId, {
|
element: element,
|
ui: window.uiFromSelectors(this.selectors.group, element)
|
});
|
return element;
|
}
|
|
|
async setGroup(groupId, data) {
|
const defaults = {
|
id: groupId,
|
src: window.location.href,
|
uploads: [],
|
operationId: null,
|
field: null,
|
fields: {}
|
};
|
const group = {...defaults, ...data};
|
Object.preventExtensions(group);
|
|
await this.stores.groups.save(group);
|
}
|
|
async setBulkGroup(fieldId, key, value) {
|
let groups = this.stores.groups.filterByIndex({field:fieldId});
|
if (groups.length === 0) {
|
return;
|
}
|
let Promises = groups.map(group => {
|
group[key] = value;
|
this.stores.groups.save(group);
|
});
|
await Promise.all(Promises);
|
}
|
|
async addToGroup(uploadId, groupId = null){
|
const upload = this.stores.uploads.get(uploadId);
|
const element = this.uploads.get(uploadId);
|
if (!upload || !element) return;
|
const field = this.fields.get(upload.field);
|
if (!field) return;
|
|
//Check if it's already in this destination, it's probably a reorder
|
const isInDOM = element.element?.parentElement !== null;
|
if (isInDOM && ((!groupId && upload.group === null) || groupId === upload.group)) {
|
this.handleReorder(upload.field, groupId);
|
return;
|
}
|
|
if (upload.group) {
|
const group = this.stores.groups.get(upload.group);
|
if (group) {
|
group.uploads = group.uploads.filter(id => id !== uploadId);
|
if (group.uploads.length === 0) {
|
await this.removeGroup(group.id, false);
|
}
|
}
|
}
|
|
//clear any selection
|
if (element.ui.checkbox) element.ui.checkbox.checked = false;
|
if (this.selected.get(upload.field)?.has(uploadId)) {
|
this.selected.get(upload.field).delete(uploadId);
|
}
|
if (element.ui.featured) element.ui.featured.hidden = !groupId;
|
|
if (!groupId) {
|
upload.group = null;
|
} else {
|
if (element.ui.featured) element.ui.featured.name = `${groupId}_featured`;
|
let group = this.stores.groups.get(groupId);
|
if (group) {
|
group.uploads.push(uploadId);
|
upload.group = groupId;
|
this.stores.groups.save(group);
|
}
|
}
|
|
let target = (groupId) ? this.groups.get(groupId)?.ui.grid : field.ui.grid;
|
if (target) {
|
target.append(element.element)
|
}
|
this.stores.uploads.save(upload);
|
}
|
|
handleDeleteGroup(button) {
|
const group = button.closest(this.selectors.group.item);
|
if (!group) return;
|
|
let groupId = group.dataset.groupId;
|
if (!confirm('Delete this group? Items will be moved back to the upload area.')) {
|
return;
|
}
|
|
let uploads = this.stores.uploads.filterByIndex({group: groupId});
|
|
Promise.all(
|
uploads.map(upload => this.addToGroup(upload.id, null))
|
).then(() => {
|
this.removeGroup(groupId, false).then(()=>{});
|
this.a11y.announce('Group deleted. Items returned to upload area');
|
});
|
}
|
|
async removeGroup(groupId, confirm = true) {
|
let element = this.groups.get(groupId);
|
let group = this.stores.groups.get(groupId);
|
if (!group) return;
|
|
let keepUploads = true;
|
|
if (confirm && group.uploads.length > 0) {
|
keepUploads = window.confirm('Keep uploads in this group?');
|
}
|
|
await Promise.all(
|
group.uploads.map(uploadId =>
|
keepUploads ? this.addToGroup(uploadId, null) : this.removeUpload(uploadId)
|
)
|
);
|
|
// Destroy the Sortable for this group
|
const sortableKey = this.getGroupKey(group.field, groupId);
|
const sortable = this.sortables.get(sortableKey);
|
if (sortable?.destroy) {
|
sortable.destroy();
|
}
|
this.sortables.delete(sortableKey);
|
|
if (element?.element) {
|
element.element.remove();
|
}
|
this.groups.delete(groupId);
|
|
await this.stores.groups.delete(groupId);
|
|
this.a11y.announce('Group removed');
|
}
|
|
maybeLockUploads(fieldId) {
|
let field = this.fields.get(fieldId);
|
if (!field || !field.ui.dropZone) return;
|
|
let uploads = this.stores.uploads.filterByIndex({field: fieldId});
|
let count = uploads.length;
|
let max = field.config.maxFiles??25;
|
|
field.ui.dropZone.hidden = count >= max;
|
}
|
/*******************************************************************************
|
OPERATION METHODS
|
*******************************************************************************/
|
async handleOperationCancelled(fieldId) {
|
const uploads = this.stores.uploads.filterByIndex({field: fieldId});
|
const groups = this.stores.groups.filterByIndex({field: fieldId});
|
|
await Promise.all([
|
...uploads.map(upload => this.removeUpload(upload.id)),
|
...groups.map(group => this.removeGroup(group.id, false))
|
]);
|
this.a11y.announce('Upload Cancelled');
|
}
|
|
async handleOperationFailed(operation, fieldId) {
|
// Mark uploads as failed, maybe show retry UI
|
await this.setBulkUpload(
|
this.stores.uploads.filterByIndex({field: fieldId}),
|
'status',
|
'failed'
|
);
|
}
|
|
async handleFieldStatus(fieldId, operation) {
|
let status = operation.status;
|
let uploads = this.stores.uploads.filterByIndex({field: fieldId});
|
await this.setBulkUpload(uploads, 'status', status);
|
}
|
/*******************************************************************************
|
SELECTION HANDLERS
|
*******************************************************************************/
|
getGroupKey(fieldId, groupId = null) {
|
return (groupId) ? `${fieldId}_${groupId}` : `${fieldId}`;
|
}
|
|
getSelectionHandler(fieldId) {
|
let key = this.getGroupKey(fieldId);
|
|
if (!this.selectionHandlers.has(key)) {
|
let field = this.fields.get(fieldId);
|
if (!field) return;
|
let handler = new window.jvbHandleSelection({
|
container: field.element,
|
item: this.selectors.items.item,
|
count: this.selectors.fields.count,
|
bulkControls: this.selectors.fields.actions,
|
checkbox: this.selectors.items.checkbox,
|
selectAll: this.selectors.fields.selectAll,
|
wrapper: `${this.selectors.fields.preview}, ${this.selectors.group.item}`,
|
});
|
|
handler.subscribe((event, data) => {
|
this.selected.set(fieldId, data.selectedItems);
|
console.log(Array.from(this.selected));
|
this.syncSortableSelection(fieldId, data.selectedItems);
|
});
|
|
this.selectionHandlers.set(key, handler);
|
}
|
|
return this.selectionHandlers.get(key);
|
}
|
/*******************************************************************************
|
SORTABLE
|
*******************************************************************************/
|
initSortable(fieldId) {
|
if (!window.Sortable) return;
|
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
if (!Sortable._multiDragMounted && Sortable.MultiDrag) {
|
Sortable.mount(new Sortable.MultiDrag());
|
Sortable._multiDragMounted = true;
|
}
|
|
// Create sortable for the main preview grid
|
this.createSortable(fieldId, field.ui.grid, null);
|
|
// Set up empty-group as native drop zone
|
this.initEmptyGroupDropZone(fieldId);
|
}
|
|
createSortable(fieldId, gridElement, groupId) {
|
if (!gridElement) return null;
|
|
const key = this.getGroupKey(fieldId, groupId);
|
|
// Already exists
|
if (this.sortables.has(key)) {
|
return this.sortables.get(key);
|
}
|
|
const sortable = new Sortable(gridElement, {
|
animation: 150,
|
draggable: '.item',
|
multiDrag: true,
|
selectedClass: 'selected',
|
avoidImplicitDeselect: true,
|
group: { name: fieldId, pull: true, put: true },
|
ghostClass: 'ghost',
|
chosenClass: 'chosen',
|
dragClass: 'dragging',
|
|
onStart: () => this.syncSortableSelection(fieldId),
|
onEnd: (evt) => this.sortableDrop(evt, fieldId),
|
});
|
|
this.sortables.set(key, sortable);
|
return sortable;
|
}
|
|
initEmptyGroupDropZone(fieldId) {
|
const field = this.fields.get(fieldId);
|
const emptyZone = field?.groupUI?.empty;
|
if (!emptyZone) return;
|
|
emptyZone.addEventListener('dragover', (e) => {
|
e.preventDefault();
|
e.dataTransfer.dropEffect = 'move';
|
emptyZone.classList.add('drag-over');
|
});
|
|
emptyZone.addEventListener('dragleave', (e) => {
|
if (!emptyZone.contains(e.relatedTarget)) {
|
emptyZone.classList.remove('drag-over');
|
}
|
});
|
|
emptyZone.addEventListener('drop', async (e) => {
|
e.preventDefault();
|
emptyZone.classList.remove('drag-over');
|
|
// Get selected items from our tracking
|
const selectedItems = this.selected.get(fieldId);
|
if (!selectedItems || selectedItems.size === 0) return;
|
|
const groupId = await this.createGroup(fieldId);
|
if (!groupId) return;
|
|
await Promise.all(
|
Array.from(selectedItems).map(uploadId => this.addToGroup(uploadId, groupId))
|
);
|
|
this.selectionHandlers.get(fieldId)?.clearSelection();
|
});
|
}
|
|
async sortableDrop(evt, fieldId) {
|
const dropTarget = evt.to;
|
|
const items = evt.items?.length > 0 ? Array.from(evt.items) : [evt.item];
|
const uploadIds = items.map(item => item.dataset.uploadId).filter(Boolean);
|
|
if (uploadIds.length === 0) return;
|
|
// Determine target group from the grid's data attribute
|
const targetGroupId = dropTarget.dataset.groupId || null;
|
|
await Promise.all(
|
uploadIds.map(uploadId => this.addToGroup(uploadId, targetGroupId))
|
);
|
|
this.selectionHandlers.get(fieldId)?.clearSelection();
|
}
|
|
syncSortableSelection(fieldId) {
|
const selectedItems = this.selected.get(fieldId) || new Set();
|
|
for (const [uploadId, uploadData] of this.uploads) {
|
const upload = this.stores.uploads.get(uploadId);
|
if (!upload || upload.field !== fieldId) continue;
|
|
const element = uploadData.element;
|
if (!element) continue;
|
|
const shouldBeSelected = selectedItems.has(uploadId);
|
|
if (shouldBeSelected && !element.classList.contains('selected')) {
|
Sortable.utils.select(element);
|
} else if (!shouldBeSelected && element.classList.contains('selected')) {
|
Sortable.utils.deselect(element);
|
}
|
}
|
}
|
|
handleReorder(fieldId, groupId = null) {
|
let target = (groupId) ? this.groups.get(groupId)?.ui.grid : this.fields.get(fieldId)?.ui.grid;
|
if (!target) {
|
console.log ('Couldn\'t Reorder items...');
|
return;
|
}
|
//Get current order from DOM
|
let items = Array.from(target.querySelectorAll(this.selectors.items.item+':not(.ghost)'))
|
.map(upload => upload.dataset.uploadId)
|
.filter(id => id);
|
|
|
if (!groupId) {
|
let hiddenInput = this.fields.get(fieldId)?.ui.hidden;
|
if (hiddenInput) {
|
hiddenInput.value = items.join(',');
|
}
|
} else {
|
let group = this.groups.get(groupId);
|
if (group) {
|
group.uploads = items;
|
}
|
}
|
this.a11y.announce('Items reordered');
|
}
|
/*******************************************************************************
|
* 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); }
|
});
|
}
|
/********************************************************************
|
CLEANUP
|
********************************************************************/
|
destroy() {
|
this.subscribers.clear();
|
this.previewUrls.forEach(url => {
|
this.revokePreviewUrl(url);
|
});
|
this.previewUrls.clear();
|
}
|
|
cleanupAllPreviewUrls() {
|
this.previewUrls.forEach(url => this.revokePreviewUrl(url));
|
this.previewUrls.clear();
|
}
|
|
async handleClearCache() {
|
const currentSrc = window.location.href;
|
|
const uploads = this.stores.uploads.filterByIndex({src: currentSrc});
|
const groups = this.stores.groups.filterByIndex({src:currentSrc});
|
|
await Promise.all([
|
...uploads.map(upload => this.clearUpload(upload.id)),
|
...groups.map(group => {
|
this.groups.get(group.id)?.element?.remove();
|
this.groups.delete(group.id);
|
return this.stores.groups.delete(group.id);
|
})
|
]);
|
|
this.a11y.announce('Cache cleared for this page');
|
}
|
|
/**
|
* Get files from all upload fields in a form
|
* Returns array of {file, fieldName, uploadId, meta}
|
*/
|
async getFilesForForm(formElement) {
|
const uploadFields = formElement.querySelectorAll(this.selectors.fields.field);
|
const allFiles = [];
|
|
for (const fieldElement of uploadFields) {
|
const fieldId = this.determineFieldId(fieldElement);
|
const fieldName = fieldElement.dataset.field;
|
const uploads = this.stores.uploads.filterByIndex({ field: fieldId });
|
|
for (const upload of uploads) {
|
const file = this.formatFile(upload);
|
if (file) {
|
allFiles.push({
|
file: file,
|
fieldName: fieldName,
|
uploadId: upload.id,
|
meta: upload.fields || {}
|
});
|
}
|
}
|
}
|
|
return allFiles;
|
}
|
|
/**
|
* Clear all uploads and groups for a specific field from stores
|
*/
|
async clearFieldFromStores(fieldId) {
|
const uploads = this.stores.uploads.filterByIndex({ field: fieldId });
|
const groups = this.stores.groups.filterByIndex({ field: fieldId });
|
|
// Clear all uploads
|
await Promise.all(
|
uploads.map(upload => this.clearUpload(upload.id))
|
);
|
|
// Clear all groups
|
await Promise.all(
|
groups.map(group => {
|
this.groups.get(group.id)?.element?.remove();
|
this.groups.delete(group.id);
|
return this.stores.groups.delete(group.id);
|
})
|
);
|
}
|
}
|
|
document.addEventListener('DOMContentLoaded', async function () {
|
window.auth.subscribe((event) => {
|
if (event === 'auth-loaded') {
|
window.jvbUploads = new UploadManager();
|
}
|
});
|
});
|