class UploadManager {
|
constructor() {
|
this.a11y = window.jvbA11y;
|
this.queue = window.jvbQueue;
|
this.error = window.jvbError;
|
this.templates = window.jvbTemplates;
|
|
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.changes = new Map();
|
|
this.previewUrls = new Set();
|
this.initElements();
|
this.initListeners();
|
this.defineTemplates();
|
}
|
|
defineTemplates() {
|
const T = this.templates;
|
const images = this;
|
|
T.define('uploadItem', {
|
refs: {
|
select: '[name="select-item"]',
|
featured: '[name="featured"]',
|
img: 'img',
|
video: 'video',
|
file: 'label > span',
|
details: 'details',
|
alt: '[name="image-alt-text"]',
|
title: '[name="image-title"]',
|
description: '[name="image-caption"]',
|
},
|
manyRefs: {
|
inputs: 'input, select, textarea',
|
},
|
setup({el, refs, manyRefs, data}) {
|
const isNewUpload = Object.hasOwn(data, 'file');
|
let mimeType;
|
let url;
|
let alt;
|
let previewUrl = false;
|
if (isNewUpload) {
|
el.dataset.uploadId = data.uploadId;
|
mimeType = images.getSubtypeFromMime(data.file.type)||'image';
|
url = (mimeType !== 'document') ? images.createPreviewUrl(data.file) : false;
|
previewUrl = url;
|
alt = data.file.name||'';
|
} else {
|
el.dataset.id = data.id;
|
mimeType = images.getSubtypeFromURL(data.medium??data.src);
|
url = data.medium??data.src;
|
alt = data['image-alt-text']??'';
|
}
|
|
|
el.dataset.subtype = mimeType;
|
|
if (refs.featured) {
|
refs.featured.value = data.uploadId;
|
}
|
switch (mimeType) {
|
case 'image':
|
if (refs.img) {
|
refs.img.src = url;
|
refs.img.alt = alt;
|
|
if (previewUrl) refs.img.dataset.previewUrl = previewUrl;
|
}
|
if (refs.video) refs.video.remove();
|
if (refs.file) refs.file.remove();
|
break;
|
case 'video':
|
if (refs.video) {
|
refs.video.src = url;
|
refs.video.alt = alt;
|
if (previewUrl) refs.video.dataset.previewUrl = previewUrl;
|
}
|
if (refs.img) refs.img.remove();
|
if (refs.file) refs.file.remove();
|
break;
|
case 'document':
|
if (refs.preview) {
|
let ext = data.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');
|
refs.preview.innerText = data.file.name??data.title;
|
refs.preview.prepend(icon);
|
}
|
if (refs.img) refs.img.remove();
|
if (refs.video) refs.video.remove();
|
break;
|
}
|
if (refs.details) {
|
if (Object.hasOwn(data, 'field') && Object.hasOwn(data.field,'config') && Object.hasOwn(data.field.config, 'showMeta') && !data.field.config.showMeta) {
|
refs.details.remove();
|
} else {
|
if(Object.hasOwn(data, 'id')) {
|
refs.details.dataset.attachmentId = data.id;
|
} else if (Object.hasOwn(data, 'uploadId')) {
|
refs.details.dataset.uploadId = data.uploadId;
|
}
|
refs.details.setAttribute('data-ignore', '');
|
|
|
if (mimeType !== 'image' && refs.alt) {
|
refs.alt.closest('.field')?.remove();
|
} else if (Object.hasOwn(data, 'image-alt-text') && refs.alt) {
|
refs.alt.value = data['image-alt-text'];
|
}
|
if ((Object.hasOwn(data, 'title') || Object.hasOwn(data, 'file')) && refs.title) {
|
refs.title.value = data.title||data.file.name;
|
}
|
if (Object.hasOwn(data, 'image-caption') && refs.description) {
|
refs.description.value = data['image-caption'];
|
}
|
}
|
}
|
|
|
el.draggable = el.dataset.mode !== 'single';
|
|
if (manyRefs.inputs) {
|
for (let input of manyRefs.inputs) {
|
let wrapper = input.closest('[data-field]')??el;
|
window.prefixInput(input, `${data.id??data.uploadId}-`, wrapper);
|
}
|
}
|
}
|
});
|
|
T.define('imageGroup', {
|
refs: {
|
selectAll: '[data-select-all]',
|
fields: '.fields',
|
details: 'details',
|
grid: '.item-grid',
|
},
|
setup({el, refs, manyRefs, data}) {
|
el.dataset.groupId = data.groupId;
|
if (refs.selectAll) {
|
let wrapper = refs.selectAll.closest('.field');
|
window.prefixInput(refs.selectAll, `select-all-${data.groupId}`, wrapper,true);
|
}
|
let fields = T.create('groupMetadata', {groupId: data.groupId});
|
if (fields) {
|
refs.fields.append(fields);
|
} else {
|
refs.details.remove();
|
}
|
if (refs.grid) {
|
refs.grid.dataset.groupId = data.groupId;
|
}
|
}
|
});
|
|
T.define('groupMetadata', {
|
manyRefs: {
|
inputs: 'input,textarea,select'
|
},
|
setup({el, refs, manyRefs, data}) {
|
if (refs.inputs) {
|
refs.inputs.forEach(input => {
|
let wrapper = input.closest('[data-field]');
|
window.prefixInput(input, `${data.groupId}-`, wrapper);
|
});
|
}
|
}
|
});
|
|
T.define('restoreNotification', {
|
refs: {
|
details: '.details',
|
wrap: '.wrap',
|
},
|
setup({el, refs, manyRefs, data}) {
|
if (refs.details) {
|
let source = data.bySource.size > 1 ? ` across ${data.bySource.size} pages` : '';
|
let upload = data.pendingUploads.length > 1 ? 'uploads' : 'upload';
|
refs.details.textContent = `${data.pendingUploads.length} ${upload} can be recovered${source}`;
|
}
|
if (!refs.wrap) {
|
console.warn('No wrap element in template');
|
return;
|
}
|
let i = 1;
|
for (const [src, uploads] of data.bySource) {
|
let data = {
|
index: i,
|
isCurrent: src === window.location.href,
|
src: src,
|
uploads: uploads
|
};
|
refs.wrap.append(T.create('restoreField', data));
|
i++;
|
}
|
}
|
});
|
|
T.define('restoreField', {
|
refs: {
|
h3: 'h3',
|
a: 'h3 a',
|
grid: '.item-grid'
|
},
|
async setup({el, refs, manyRefs, data}) {
|
let fieldId = images.registerField(el, false, false, `recovery_${data.index}`);
|
if (data.isCurrent) {
|
el.open = true;
|
|
refs.a?.remove();
|
if (refs.h3) {
|
refs.h3.textContent = 'From this page:';
|
}
|
|
} else {
|
if (refs.a) {
|
refs.a.href = data.src;
|
refs.a.title = 'Navigate to page and restore';
|
refs.a.textContent = data.src;
|
}
|
}
|
|
let filtered = [... new Set(data.uploads.map(upload => upload.group??'preview'))];
|
for (let groupId of filtered) {
|
let group = (groupId === 'preview') ? true : images.stores.groups.get(groupId);
|
if (!group) continue;
|
|
let element = await images.createGroupElement(groupId, fieldId);
|
let groupGrid = element.querySelector('.item-grid');
|
let groupUploads = data.uploads.filter(upload => upload.group === (groupId === 'preview') ? null : groupId);
|
|
for (const [key, value] of Object.entries(group.fields??{})) {
|
let field = element.querySelector(`input[name*="${key}"]`);
|
if (field) field.value = value;
|
}
|
|
for (let upload of groupUploads) {
|
let item = await images.createUpload(upload.id, images.formatFile(upload), fieldId);
|
groupGrid.append(item);
|
}
|
refs.grid.append(element);
|
}
|
}
|
});
|
}
|
|
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 ((event === 'operation-status' || event === 'cancel-operation')
|
&& ['image_upload', 'video_upload', 'document_upload'].includes(operation.type)) {
|
let uploadIds = [];
|
|
if (operation.data) {
|
// Handle FormData
|
if (operation.data instanceof FormData) {
|
const dataObj = this.stores.uploads.formDataToObject(operation.data);
|
uploadIds = dataObj['upload_ids'] || [];
|
}
|
// Handle regular object
|
else {
|
uploadIds = operation.data['upload_ids'] || [];
|
}
|
}
|
|
// If not in data, check result (for completed operations from backend)
|
if (uploadIds.length === 0 && operation.result && operation.result.upload_ids) {
|
uploadIds = operation.result.upload_ids;
|
}
|
|
// Still no upload_ids? Log warning and bail
|
if (!uploadIds || uploadIds.length === 0) {
|
console.warn('[UploadManager] No upload_ids found for operation:', {
|
id: operation.id,
|
type: operation.type,
|
status: operation.status,
|
hasData: !!operation.data,
|
hasResult: !!operation.result
|
});
|
return;
|
}
|
|
// Handle cancellation
|
if (event === 'cancel-operation') {
|
return this.handleOperationCancelled(uploadIds);
|
}
|
|
// Update upload status based on operation status
|
this.setBulkUpload(uploadIds, 'status', operation.status).then(() => {
|
// Log for debugging
|
console.log(`[UploadManager] Updated ${uploadIds.length} uploads to status: ${operation.status}`);
|
});
|
|
// Handle completion
|
if (operation.status === 'completed') {
|
// For group uploads, mark as processed but keep for reference
|
if (operation.type === 'process_upload_groups') {
|
uploadIds.forEach(uploadId => {
|
this.setBulkUpload([uploadId], 'serverProcessed', true).then(() => {});
|
});
|
|
// Log created posts if available
|
if (operation.result && operation.result.created_posts) {
|
console.log('[UploadManager] Created posts:', operation.result.created_posts);
|
}
|
|
// Remove uploads after a delay to allow UI to update
|
setTimeout(() => {
|
uploadIds.forEach(uploadId => {
|
this.removeUpload(uploadId).then(() => {});
|
});
|
}, 2000);
|
}
|
// For direct uploads, remove immediately
|
else {
|
uploadIds.forEach(uploadId => {
|
this.removeUpload(uploadId).then(() => {});
|
});
|
}
|
}
|
|
// Handle failures
|
if (operation.status === 'failed' || operation.status === 'failed_permanent') {
|
console.error('[UploadManager] Operation failed:', {
|
id: operation.id,
|
type: operation.type,
|
uploadIds: uploadIds,
|
error: operation.error_message
|
});
|
}
|
}
|
|
});
|
}
|
|
storesReady() {
|
return this.stores.ready.length === 2;
|
}
|
|
handleStores(storeName, event) {
|
if (event === 'data-ready') {
|
this.stores.ready.push(storeName);
|
if (this.storesReady()) {
|
this.checkRecovery().then(()=>{});
|
}
|
}
|
}
|
|
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-wrapper',
|
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: '[data-select-all]',
|
actions: '.selection-actions',
|
count: '.selected .info',
|
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: '.item.upload',
|
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) {
|
let isMeta = e.target.closest('[data-upload-id], [data-attachment-id]');
|
if (isMeta) {
|
this.queueUploadMeta(e);
|
}
|
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.config.destination === 'post_group') {
|
this.handleGroupMetaChange(e.target);
|
} else {
|
this.queueUploadMeta(e);
|
}
|
}
|
handleGroupMetaChange(input) {
|
// Get the groupId directly from the input's data attribute
|
const groupId = input.dataset.groupId;
|
if (!groupId) return;
|
|
// Capture values immediately (before debouncer)
|
const inputName = input.name;
|
if (!inputName) return;
|
const inputValue = input.value;
|
|
// Extract the field name from the input name
|
// Names are like "groupId[post_title]" or "groupId_post_title"
|
const name = inputName
|
.replace(`${groupId}[`, '')
|
.replace(`${groupId}_`, '')
|
.replace(']', '');
|
|
// Schedule the save with captured values
|
window.debouncer.schedule(`group-meta-${groupId}-${name}`, async () => {
|
const group = this.stores.groups.get(groupId);
|
if (!group) return;
|
|
// Initialize fields object if it doesn't exist
|
if (!group.fields) {
|
group.fields = {};
|
}
|
|
group.fields[name] = inputValue;
|
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.updateHandlerItems(fieldId);
|
});
|
this.a11y.announce(`${files.length} file(s) dropped for upload`);
|
}
|
}
|
|
async queueUploads(endpoint, fieldId, dependsOn = null) {
|
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.repeaterPath || 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);
|
if (dependsOn) {
|
data.append('depends_on', dependsOn);
|
}
|
}
|
|
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;
|
}
|
|
|
this.notify('groups_uploaded', {
|
fieldId: fieldId,
|
posts: posts,
|
content: field.config.content,
|
});
|
}
|
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);
|
|
|
this.notify('sent-to-queue', {
|
field: field,
|
operation: operationId,
|
});
|
} else {
|
await this.setBulkUpload(uploads, 'status', 'failed');
|
}
|
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: {
|
'X-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 = [];
|
|
const validGroups = groups.filter(group => {
|
const groupUploads = this.getGroupUploadsInOrder(group);
|
return groupUploads.length > 0 && groupUploads.some(u => this.formatFile(u));
|
});
|
|
for (const group of validGroups) {
|
const groupElement = this.groups.get(group.id)?.element;
|
const fields = this.collectGroupFieldsFromDOM(groupElement, group.id);
|
|
const post = {
|
groupId: group.id,
|
images: [],
|
fields: fields
|
};
|
|
const groupUploads = this.getGroupUploadsInOrder(group);
|
|
for (const upload of groupUploads) {
|
const file = this.formatFile(upload);
|
if (file) {
|
files.push(file);
|
const imageData = {
|
upload_id: upload.id,
|
index: uploadMap.length
|
};
|
|
const uploadEl = this.uploads.get(upload.id);
|
const featuredInput = uploadEl?.element?.querySelector(`input[name="${group.id}_featured"]`);
|
if (featuredInput?.checked) {
|
post.fields.featured = upload.id;
|
}
|
|
post.images.push(imageData);
|
uploadMap.push(upload.id);
|
}
|
}
|
|
if (post.images.length > 0) {
|
posts.push(post);
|
}
|
}
|
|
// Handle remaining uploads not in any group
|
const remaining = uploads.filter(u => !u.group);
|
for (const upload of remaining) {
|
const post = {
|
groupId: window.generateID('group'),
|
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);
|
}
|
|
if (post.images.length > 0) {
|
posts.push(post);
|
}
|
}
|
|
return {posts, uploadMap, files};
|
}
|
|
getGroupUploadsInOrder(group) {
|
if (!group.uploads || group.uploads.length === 0) return [];
|
|
return group.uploads
|
.map(uploadId => this.stores.uploads.get(uploadId))
|
.filter(Boolean); // Remove any that don't exist
|
}
|
|
collectGroupFieldsFromDOM(groupElement, groupId) {
|
if (!groupElement) return {};
|
|
const fields = {};
|
const inputs = groupElement.querySelectorAll('input, textarea, select');
|
|
inputs.forEach(input => {
|
// Extract field name from input name like "groupId[post_title]"
|
const name = input.name
|
.replace(`${groupId}[`, '')
|
.replace(`${groupId}_`, '')
|
.replace(']', '');
|
|
// Skip system fields like featured, select-all
|
if (['featured', 'select-all'].some(skip => name.includes(skip))) return;
|
|
if (input.value) {
|
fields[name] = input.value;
|
}
|
});
|
|
return fields;
|
}
|
|
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 };
|
}
|
|
queueUploadMeta(e) {
|
let attachmentId = e.target.closest('[data-attachment-id]')?.dataset.attachmentId;
|
let isUpload = false;
|
if (!attachmentId) {
|
attachmentId = e.target.closest('[data-upload-id]')?.dataset.uploadId;
|
isUpload = true;
|
if (!attachmentId) return;
|
|
|
}
|
|
if (!this.changes.has(attachmentId)) {
|
let object = {};
|
if (isUpload) {
|
object['uploadId'] = attachmentId;
|
} else {
|
object['attachmentId'] = attachmentId;
|
}
|
this.changes.set(attachmentId, object);
|
}
|
|
let field = e.target.closest('[data-field]');
|
let name = field.dataset.field;
|
|
this.changes.get(attachmentId)[name] = e.target.value;
|
|
this.scheduleSave();
|
}
|
scheduleSave() {
|
window.debouncer.schedule(
|
`upload-meta`,
|
async () => {
|
if (this.changes.size > 0) {
|
let items = {};
|
for (let [id, meta] of this.changes.entries()) {
|
console.log(id, meta);
|
items[id] = meta;
|
}
|
let data = {
|
user: window.auth.getUser(),
|
items: items
|
};
|
await this.sendToQueue('uploads/meta', data, 'Uploading Meta', 'Uploading Meta', true);
|
this.changes.clear();
|
}
|
},
|
2000
|
);
|
}
|
|
/*********************************************************************
|
FIELD LOGIC
|
*********************************************************************/
|
scanFields(container, autoUpload = true, imageMeta = true) {
|
const fields = container.querySelectorAll(this.selectors.fields.field);
|
fields.forEach(uploader => this.registerField(uploader, autoUpload, imageMeta));
|
}
|
|
registerField(element, autoUpload = true, imageMeta = true, id = null) {
|
const data = {
|
element: element,
|
id: (id) ? id : this.determineFieldId(element),
|
config: this.extractFieldConfig(element, autoUpload, imageMeta),
|
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, imageMeta) {
|
const config = {
|
autoUpload: autoUpload,
|
showMeta: imageMeta,
|
destination: fieldElement.dataset.destination || 'meta',
|
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',
|
repeaterPath: null
|
};
|
|
const repeaterRow = fieldElement.closest('[data-index]');
|
const repeater = repeaterRow?.closest('[data-field][data-repeater-id]');
|
if (repeater && repeaterRow) {
|
config.repeaterPath = `${repeater.dataset.field}:${repeaterRow.dataset.index}:${config.name}`;
|
}
|
|
return config;
|
}
|
|
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 || '';
|
|
// If inside a repeater row, include repeater name + index for uniqueness
|
const repeaterRow = fieldElement.closest('[data-index]');
|
const repeater = repeaterRow?.closest('[data-field][data-repeater-id]');
|
if (repeater && repeaterRow) {
|
return `${content}${itemID}${repeater.dataset.field}_${repeaterRow.dataset.index}_${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 entry = queue.shift();
|
const blob = await this.processImage(entry.file, maxWidth, maxHeight);
|
results.push({ uploadId: entry.uploadId, blob: blob });
|
}
|
};
|
|
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 processedImages = await this.processImages(
|
imageEntries.map(e => ({ file: e.file, uploadId: e.uploadId }))
|
);
|
|
// Update image uploads with processed blobs
|
for (const { uploadId, blob } of processedImages) {
|
const entry = imageEntries.find(e => e.uploadId === uploadId);
|
if (entry) {
|
entry.upload.blob = blob;
|
entry.upload.fields.size = blob.size;
|
entry.upload.status = 'queued';
|
await this.setUpload(uploadId, entry.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']});
|
const allGroups = Array.from(this.stores.groups.data.values());
|
for (const group of allGroups) {
|
const hasUploads = this.stores.uploads.filterByIndex({group: group.id}).length > 0;
|
if (!hasUploads) {
|
await this.stores.groups.delete(group.id);
|
}
|
}
|
if (pendingUploads.length === 0) 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);
|
});
|
|
let data = {
|
bySource: bySource,
|
pendingUploads: pendingUploads
|
};
|
|
document.body.append(this.templates.create('restoreNotification', data));
|
let notification = document.querySelector('dialog.restore-uploads');
|
this.restoreModal = new window.jvbModal(notification);
|
this.restoreSelection = new window.jvbHandleSelection(notification,
|
{
|
wrapper: {
|
wrapper: '.restore-field',
|
id: 'selection'
|
},
|
items: '.item-grid.restore',
|
selectAll: {
|
bulkControls: '.selection-actions',
|
checkbox: '#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 field = this.fields.get(fieldId);
|
if (!field) return null;
|
|
let data = {
|
uploadId: uploadId,
|
file: file,
|
field: field,
|
};
|
return this.templates.create('uploadItem', data);
|
}
|
|
getSubtypeFromURL(url) {
|
if (!url || url === '') {
|
return '';
|
}
|
const imgs = ['.webp', '.jpg', '.jpeg', '.png', '.gif', '.svg'];
|
const videos = ['.mp4', '.ogg', '.mov', '.webm', '.avi'];
|
|
const path = url.split('?')[0].toLowerCase();
|
|
if (imgs.some(ext => path.endsWith(ext))) return 'image';
|
if (videos.some(ext => path.endsWith(ext))) return 'video';
|
return 'document';
|
}
|
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;
|
const attachmentId = item.dataset.id;
|
|
if (!uploadId && !attachmentId) return;
|
if (!confirm('Remove this item?')) return;
|
|
if (uploadId) {
|
await this.removeUpload(uploadId);
|
} else {
|
const fieldId = this.getFieldIdFromElement(button);
|
item.remove();
|
|
if (fieldId) {
|
this.updateHiddenInput(fieldId);
|
this.maybeLockUploads(fieldId);
|
}
|
}
|
|
this.a11y.announce('Item removed');
|
}
|
|
updateHiddenInput(fieldId) {
|
const field = this.fields.get(fieldId);
|
if (!field?.ui.hidden) return;
|
|
const remaining = Array.from(field.ui.grid?.querySelectorAll(this.selectors.items.item) || [])
|
.map(el => el.dataset.id || el.dataset.uploadId)
|
.filter(Boolean);
|
|
const newValue = remaining.join(',');
|
if (field.ui.hidden.value === newValue) return;
|
|
field.ui.hidden.value = newValue;
|
field.ui.hidden.dispatchEvent(new Event('change', { bubbles: true }));
|
}
|
async setBulkUpload(uploads, key, value) {
|
const promises = Array.from(uploads).map(async (upload) => {
|
if (typeof upload === 'string') upload = await this.stores.uploads.get(upload);
|
if (!upload) return;
|
|
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 (typeof upload === 'string') upload = await this.stores.uploads.get(upload);
|
if (!upload) return;
|
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;
|
const fieldId = upload.field; // grab before clearing
|
|
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);
|
} else {
|
await this.stores.groups.save(group);
|
}
|
}
|
|
await this.clearUpload(uploadId);
|
this.updateHiddenInput(fieldId);
|
this.maybeLockUploads(fieldId);
|
|
let handler = this.selectionHandlers.get(fieldId);
|
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;
|
|
const emptyGroup = field.groupUI.empty;
|
if (emptyGroup?.nextSibling) {
|
field.groupUI.grid.insertBefore(element, emptyGroup.nextSibling);
|
} else {
|
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.createSortable(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 data = {
|
groupId: groupId,
|
fieldId: fieldId,
|
}
|
let element = this.templates.create('imageGroup', data);
|
|
this.groups.set(groupId, {
|
element: element,
|
ui: window.uiFromSelectors(this.selectors.group, element)
|
});
|
|
this.getSelectionHandler(fieldId)?.addWrapper(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);
|
} else {
|
await this.stores.groups.save(group);
|
}
|
}
|
}
|
|
//clear any selection
|
if (element.ui.checkbox) element.ui.checkbox.checked = false;
|
// Remove from field-level selection
|
const fieldHandler = this.selectionHandlers.get(upload.field);
|
if (fieldHandler && fieldHandler.isSelected(uploadId)) {
|
fieldHandler.deselect(uploadId);
|
}
|
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;
|
await this.stores.groups.save(group);
|
}
|
}
|
|
let target = (groupId) ? this.groups.get(groupId)?.ui.grid : field.ui.grid;
|
if (target) {
|
target.append(element.element);
|
if (groupId) {
|
await this.handleReorder(upload.field, groupId);
|
}
|
}
|
await 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)
|
)
|
);
|
const field = this.fields.get(group.field);
|
if (field) {
|
const sortableKey = this.getGroupKey(group.field, groupId);
|
const selectionHandler = this.selectionHandlers.get(sortableKey);
|
if (selectionHandler?.destroy) {
|
selectionHandler.destroy();
|
}
|
this.selectionHandlers.get(group.field)?.removeWrapper(element.element);
|
|
// Existing sortable cleanup
|
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(uploads) {
|
if (uploads.length === 0) return;
|
uploads.forEach(upload => {
|
this.removeUpload(upload);
|
});
|
}
|
/*******************************************************************************
|
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;
|
if (field.config.destination !== 'post_group') return;
|
let handler = new window.jvbHandleSelection(field.element, {
|
selectAll: {
|
checkbox: this.selectors.fields.selectAll,
|
count: this.selectors.fields.count,
|
bulkControls: this.selectors.fields.actions
|
},
|
item: {
|
item: this.selectors.items.item,
|
checkbox: this.selectors.items.checkbox,
|
idAttribute: 'uploadId',
|
},
|
wrapper: {
|
wrapper: '.preview-wrap, .upload-group',
|
id: 'groupId'
|
},
|
});
|
|
handler.subscribe((event, data) => {
|
this.selected.set(fieldId, data.selectedItems);
|
});
|
|
this.selectionHandlers.set(key, handler);
|
}
|
|
return this.selectionHandlers.get(key);
|
}
|
updateHandlerItems(fieldId) {
|
let handler = this.getSelectionHandler(fieldId);
|
if (!handler) return;
|
handler.collectItems();
|
}
|
/*******************************************************************************
|
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 },
|
dragClass: 'dragging',
|
ignore: '.empty-group',
|
|
onStart: (evt) => {
|
// Get the dragged item's ID
|
const draggedItem = evt.item;
|
const uploadId = draggedItem?.dataset.uploadId;
|
|
// Get the selected items Set for this field
|
const selectedItems = this.selected.get(fieldId);
|
|
// If the dragged item isn't selected, select it
|
if (uploadId && (!selectedItems || !selectedItems.has(uploadId))) {
|
const handler = this.selectionHandlers.get(fieldId);
|
if (handler) {
|
handler.select(uploadId);
|
}
|
}
|
},
|
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.stopPropagation();
|
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();
|
e.stopPropagation();
|
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;
|
|
const targetGroupId = dropTarget.dataset.groupId || null;
|
|
// Process sequentially to avoid race conditions
|
for (const uploadId of uploadIds) {
|
await this.addToGroup(uploadId, targetGroupId);
|
}
|
|
await this.handleReorder(fieldId, targetGroupId);
|
this.selectionHandlers.get(fieldId)?.clearSelection();
|
}
|
|
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;
|
}
|
|
if (!groupId) {
|
this.updateHiddenInput(fieldId);
|
} else {
|
let items = Array.from(target.children)
|
.filter(el => el.matches(this.selectors.items.item) && !el.classList.contains('ghost'))
|
.map(upload => upload.dataset.uploadId)
|
.filter(id => id);
|
|
let group = this.stores.groups.get(groupId);
|
if (group) {
|
group.uploads = items;
|
this.stores.groups.save(group).then(()=>{});
|
}
|
}
|
|
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);
|
})
|
]);
|
|
if (this.restoreModal) {
|
this.cleanupRestore();
|
}
|
|
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();
|
}
|
});
|
});
|