From 457c329237f97069063e641b10f384a52d584f21 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 12 May 2026 17:50:11 +0000
Subject: [PATCH] =minor tweaks
---
assets/js/concise/UploadManager.js | 5015 ++++++++++++++++++++++++-----------------------------------
1 files changed, 2,036 insertions(+), 2,979 deletions(-)
diff --git a/assets/js/concise/UploadManager.js b/assets/js/concise/UploadManager.js
index b59101c..15417be 100644
--- a/assets/js/concise/UploadManager.js
+++ b/assets/js/concise/UploadManager.js
@@ -1,93 +1,1470 @@
class UploadManager {
constructor() {
- //Load dependencies
- this.queue = window.jvbQueue;
this.a11y = window.jvbA11y;
+ this.queue = window.jvbQueue;
this.error = window.jvbError;
+ this.templates = window.jvbTemplates;
- //Load Datastore
- this.fieldStore = new window.jvbStore({
- name: 'upload_fields',
- storeName: 'fieldStates',
- keyPath: 'id',
- version: 2,
+ this.subscribers = new Set();
- indexes: [
- { name: 'fieldId', keyPath: 'fieldId' },
- { name: 'timestamp', keyPath: 'timestamp' },
- { name: 'content', keyPath: 'content' },
- { name: 'itemId', keyPath: 'itemId' },
- { name: 'status', keyPath: 'status' }
- ],
-
- stripDOMReferences: true,
- TTL: 86400000*7 // 24 hours -> 1 week
- });
-
- this.uploadStore = new window.jvbStore({
- name: 'uploads',
- storeName: 'uploads',
- keyPath: 'id',
- storeBlobs: true,
-
- indexes: [
- { name: 'fieldId', keyPath: 'fieldId' },
- { name: 'status', keyPath: 'status' },
- { name: 'groupId', keyPath: 'groupId' },
- { name: 'attachmentId', keyPath: 'attachmentId' }
- ],
- });
-
- window.jvbUploadBlobs = this.uploadStore;
-
- // Subscribe to store events
- this.fieldStore.subscribe(this.handleFieldStoreEvent.bind(this));
- this.uploadStore.subscribe(this.handleUploadStoreEvent.bind(this));
-
- //Load Worker
+ this.initStores();
this.initWorker();
- // Core data structures
+
+ //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();
- //Notification and Subscribers
- this.subscribers = new Set();
+ this.initElements();
+ this.initListeners();
+ this.defineTemplates();
+ }
- // Controllers (will be initialized based on features)
- this.dragController = null;
+ defineTemplates() {
+ const T = this.templates;
+ const images = this;
- // Selectors
+ 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]')??input.closest('.radio-button')??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 (manyRefs.inputs) {
+ manyRefs.inputs.forEach(input => {
+ let wrapper = input.closest('[data-field]');
+ input.dataset.groupId = data.groupId;
+ 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 = {
- field: {
+ fields: {
field: '[data-upload-field]',
input: 'input[type="file"]',
- hiddenValue: 'input[type="hidden"]',
- dropZone: '.file-upload-container',
- preview: '.item-grid.preview',
- progress: '.image-progress'
+ 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: '.upload-group',
- grid: '.item-grid.group',
- header: '.group-header',
+ 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"]',
- actions: '.group-actions',
- count: '.selection-controls .info'
+ 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"]',
+ item: '.item.upload',
+ checkbox: '[name="select-item"]',
featured: '[name="featured"]',
- details: 'details'
+ 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);
+
+ if (this.fields.has(upload.field)) {
+ let field = this.fields.get(upload.field);
+ switch (upload.status) {
+ case 'local_processing':
+ this.notify('upload-received', {
+ field: field.element,
+ id: upload.id
+ });
+ }
+ }
+
+
+ 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) {
+ if (!window.targetCheck(e, this.selectors.fields.field)) return;
+
+ //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
+ 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.statusMapping = {
+ this.fields.set(data.id, data);
+
+ element.dataset.uploader = data.id;
+ this.getSelectionHandler(data.id);
+ if (data.config.type !== 'single') {
+ this.initSortable(data.id);
+ }
+ this.maybeLockUploads(data.id);
+
+ return data.id;
+ }
+
+ extractFieldConfig(el, autoUpload, imageMeta) {
+ const config = {
+ autoUpload: autoUpload,
+ showMeta: imageMeta,
+ destination: el.dataset.destination || 'meta',
+ content: this.extractFieldContent(el),
+ mode: el.dataset.mode || 'direct',
+ type: el.dataset.type || 'single',
+ name: el.dataset.field,
+ itemID: this.extractFieldItemId(el) ?? 0,
+ maxFiles: ('max-files' in el.dataset) ? parseInt(el.dataset.maxFiles) : 0,
+ subType: el.dataset.subtype ?? 'image',
+ repeaterPath: null
+ };
+
+ const repeaterRow = el.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 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);
+ }
+ }
+ //TODO: Old method of checkRecovery. All recovery logic has moved to the FormController.js
+ // 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) {
+ if ('crudManager' in window && fieldId.startsWith(window.crudManager.content)) {
+ let [content, itemId, fieldName] = fieldId.split('_');
+ if (parseInt(itemId) > 0) {
+ window.crudManager.openEditModal(itemId);
+ field = document.querySelector(`[data-uploader="${fieldId}"]`);
+ } else {
+ console.log('No field found for '+fieldId);
+ return;
+ }
+ } else {
+ 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);
+ }
+ }
+ //
+ // cleanupRestore() {
+ // this.restoreModal.handleClose();
+ // this.restoreSelection.destroy();
+ // this.restoreSelection = null;
+ // this.restoreModal.destroy();
+ // this.restoreModal.modal.remove();
+ // this.restoreModal = null;
+ // }
+
+ async restoreUploads(uploadIds) {
+ const uploads = uploadIds.map(id => this.stores.uploads.get(id)).filter(Boolean);
+ if (uploads.length === 0) return;
+ await this.restoreSelectedUploads(uploads.map(u => u.id));
+ }
+
+ async clearUploads(uploadIds) {
+ await Promise.all(uploadIds.map(id => this.clearUpload(id)));
+ }
+ /*******************************************************************************
+ STATUS MANAGEMENT
+ *******************************************************************************/
+ getStatusText(status) {
+ let map = {
'received': 'Image Received',
'local_processing': 'Processing Image...',
'queued': 'Waiting to upload...',
@@ -99,2242 +1476,148 @@
'failed_permanent': 'Upload failed permanently'
};
- // Sortable configuration
- this.sortableInstances = new Map();
- this.sortableConfig = {
- animation: 150,
- draggable: '.item',
- handle: '.select-item-label, img', // Can drag by image or checkbox label
- ghostClass: 'sortable-ghost',
- chosenClass: 'sortable-chosen',
- dragClass: 'sortable-drag',
- onEnd: (evt) => {
- this.handleReorder(evt);
- }
+ return map[status]||status;
+ }
+ getStatusProgress(status) {
+ let progress = {
+ 'local_processing': 28,
+ 'queued': 50,
+ 'uploading': 66,
+ 'pending': 75,
+ 'processing': 89,
+ 'completed': 100
};
-
- this.init();
+ return progress[status]??0;
}
-
- async init() {
- // Load existing data
- await this.loadFields();
- await this.loadUploads();
- // Initialize fields
- this.initializeFields();
-
- // Set up core listeners
- this.initListeners();
-
- this.queue.subscribe((event, operation) => {
- if (operation.endpoint !== 'uploads' && operation.endpoint !== 'uploads/meta') {
- return;
- }
- const fieldId = operation.data instanceof FormData
- ? operation.data.get('fieldId')
- : operation.data.fieldId;
- switch(event) {
- case 'cancel-operation':
- if (fieldId) {
- this.clearField(fieldId);
- }
- break;
- case 'operation-status':
- if (fieldId) {
- this.updateFieldStatus(fieldId, operation.status);
- }
- break;
- case 'operation-complete':
- const results = operation.result?.data || [];
- results.forEach(result => {
- const upload = this.uploads.get(result.upload_id);
- if (upload) {
- upload.attachmentId = result.attachment_id;
- upload.status = 'completed';
- this.uploads.set(upload.id, upload);
- }
- });
- if (fieldId) {
- this.cleanField(fieldId);
- }
- break;
- }
-
- });
-
- window.addEventListener('beforeunload', () => {
- this.cleanupAllPreviewUrls();
- });
- }
-
- initWorker() {
- this.worker = {
- worker: null,
- timeout: null,
- tasks: new Map(),
- restart: {
- count: 0,
- max: 3,
- },
- settings: {
- timeout: 10000, //10 seconds per image
- batchSize: 1,
- maxConcurrent: 3,
- restartAfterTimeout: true
- }
- };
- }
-
- /**
- * Initialize all upload fields on the page
- */
- initializeFields() {
- const fields = document.querySelectorAll(this.selectors.field.field);
- fields.forEach(uploader => {
- this.registerUploader(uploader);
- });
- }
-
- scanFields(container) {
- const fields = container.querySelectorAll(this.selectors.field.field);
- fields.forEach(uploader => {
- this.registerUploader(uploader);
- });
- }
-
- registerUploader(uploader) {
- const fieldId = this.determineFieldId(uploader);
- const config = this.extractFieldConfig(uploader);
-
- // Create field data structure
- const field = {
- id: fieldId,
- config: config,
- element: uploader,
- ui: this.buildFieldUI(uploader),
- uploads: new Set(),
- groups: new Set(),
- state: 'ready',
- };
-
- this.fields.set(fieldId, field);
- uploader.dataset.uploader = fieldId;
- this.addFieldSelectionHandler(fieldId);
-
- if (config.destination === 'post_group' && !this.dragController) {
- this.initGroupFeatures();
- }
- if (config.type !== 'single') {
- this.initSortable(field);
- }
-
- return fieldId;
- }
-
- /**
- * Extract configuration from field element
- */
- extractFieldConfig(fieldElement) {
- return {
- destination: fieldElement.dataset.destination || 'meta',
- content: fieldElement.dataset.content || null,
- mode: fieldElement.dataset.mode || 'direct',
- type: fieldElement.dataset.type || 'single',
- name: fieldElement.dataset.field, // Field name for meta
- itemID: fieldElement.dataset.itemId || 0, // Post/term/user ID
- maxFiles: parseInt(fieldElement.dataset.maxFiles) || 999,
- subtype: fieldElement.dataset.subtype || 'image'
- };
- }
-
- /**
- * Build UI element references for a field
- */
- buildFieldUI(fieldElement) {
- let UI = {
- field: fieldElement,
- input: fieldElement.querySelector(this.selectors.field.input),
- dropZone: fieldElement.querySelector(this.selectors.field.dropZone),
- preview: fieldElement.querySelector(this.selectors.field.preview),
- progress: {
- progress: fieldElement.querySelector(this.selectors.field.progress),
- bar: fieldElement.querySelector('.bar'),
- fill: fieldElement.querySelector('.fill'),
- details: fieldElement.querySelector('.details'),
- text: fieldElement.querySelector('.details .text'),
- count: fieldElement.querySelector('.details .count')
- }
- };
-
- let display = fieldElement.querySelector('.group-display');
- if (display) {
- UI.groups = {
- display: display,
- container: fieldElement.querySelector('.item-grid.groups'),
- empty: fieldElement.querySelector('.empty-group'),
- groups: new Map()
- };
- }
-
- return UI;
- }
-
- /**
- * Set up core event listeners
- */
- initListeners() {
- this.clickHandler = this.handleClick.bind(this);
- this.changeHandler = this.handleChange.bind(this);
-
- document.addEventListener('click', this.clickHandler);
- document.addEventListener('change', this.changeHandler);
-
- // External file drops
- this.dragEnterHandler = this.handleExternalDragEnter.bind(this);
- this.dragLeaveHandler = this.handleExternalDragLeave.bind(this);
- this.dragOverHandler = this.handleExternalDragOver.bind(this);
- this.dropHandler = this.handleExternalDrop.bind(this);
-
- document.addEventListener('dragenter', this.dragEnterHandler);
- document.addEventListener('dragleave', this.dragLeaveHandler);
- document.addEventListener('dragover', this.dragOverHandler);
- document.addEventListener('drop', this.dropHandler);
- }
-
- /**
- * Initialize group-specific features (drag & drop for rearranging)
- */
- initGroupFeatures() {
- // Initialize drag controller for rearranging items
- this.dragController = new window.jvbDragHandler({
- // What can be dragged
- draggableSelector: this.selectors.items.item,
-
- // Where items can be dropped
- dropTargetSelector: `${this.selectors.field.preview}, ${this.selectors.groups.grid}, .empty-group`,
-
- // Don't start drag on interactive elements
- ignoreSelector: 'input:not(.upload-select), button, select, textarea, details, summary, a',
- previewElement: 'img, video, .icon',
-
- // Extract upload ID from element
- getItemId: (element) => {
- return element.dataset.uploadId;
- },
-
- // Get selected items for multi-drag
- getSelectedItems: (element) => {
- const fieldId = this.getFieldIdFromElement(element);
- const uploadId = element.dataset.uploadId;
- const selected = this.getCurrentSelection(fieldId);
-
- if (selected && selected.includes(uploadId)) {
- return selected;
- }
-
- return [uploadId];
- },
-
- // Validate drop location
- validateDrop: (itemIds, targetElement) => {
- const targetFieldId = this.getFieldIdFromElement(targetElement);
- const itemElement = document.querySelector(`[data-upload-id="${itemIds[0]}"]`);
- const itemFieldId = this.getFieldIdFromElement(itemElement);
-
- return targetFieldId === itemFieldId;
- },
-
- // Handle successful drop
- onDrop: (itemIds, targetElement) => {
- this.handleItemDrop(itemIds, targetElement);
- targetElement.scrollIntoView({behavior:'smooth', block:'center'});
- },
-
- // Optional callbacks
- onDragStart: (itemIds) => {
- },
-
- onDragEnd: (itemIds, success) => {
- if (success) {
- // Clear selection after successful move
- const itemElement = document.querySelector(`[data-upload-id="${itemIds[0]}"]`);
- const fieldId = this.getFieldIdFromElement(itemElement);
- const handler = this.selectionHandlers.get(fieldId);
- handler?.clearSelection();
- }
- },
-
- // Preview options
- previewOptions: {
- multiOffset: { x: -60, y: -80 },
- singleOffset: { x: -50, y: -60 },
- showCount: true
- }
- });
- }
-
- initSortable(field) {
- if (!window.Sortable) return;
-
- // Main grid
- const mainGrid = field.element.querySelector('.item-grid:not(.group)');
- if (mainGrid) {
- this.sortableInstances.set(`${field.id}-main`,
- new Sortable(mainGrid, {
- ...this.sortableConfig,
- group: {
- name: field.id,
- pull: true,
- put: true
- }
- })
- );
- }
-
- // Group grids (for selection mode with grouping)
- const groupGrids = field.element.querySelectorAll('.item-grid.group');
- groupGrids.forEach((grid, index) => {
- this.sortableInstances.set(`${field.id}-group-${index}`,
- new Sortable(grid, {
- ...this.sortableConfig,
- group: {
- name: field.id,
- pull: true,
- put: true
- }
- })
- );
- });
- }
-
-// Add reorder handler
- handleReorder(evt) {
- const grid = evt.to;
- const fieldWrapper = grid.closest('.field, .upload');
- if (!fieldWrapper) return;
-
- const form = fieldWrapper.closest('form');
- if (!form) return;
-
- // Get form config if available
- const formId = form.dataset.formId;
- if (formId && window.jvbForms) {
- const formConfig = window.jvbForms.forms?.get(formId);
- if (formConfig?.options.autosave) {
- // Trigger autosave after reordering
- window.jvbForms.scheduleSave(formConfig, 1000);
- }
- }
-
- // Announce for accessibility
- if (window.jvbA11y) {
- window.jvbA11y.announce('Item reordered');
- }
-
- // Trigger custom event
- fieldWrapper.dispatchEvent(new CustomEvent('jvb-items-reordered', {
- detail: {
- from: evt.from,
- to: evt.to,
- oldIndex: evt.oldIndex,
- newIndex: evt.newIndex
- },
- bubbles: true
- }));
- }
-
/*******************************************************************************
- * EXTERNAL FILE DROP HANDLERS (for new uploads from desktop)
- *******************************************************************************/
+ UPLOAD METHODS
+ *******************************************************************************/
+ async createUpload(uploadId, file, fieldId) {
+ let field = this.fields.get(fieldId);
+ if (!field) return null;
- handleExternalDragLeave(e) {
- const dropZone = e.target.closest(this.selectors.field.dropZone);
- if (dropZone && !dropZone.contains(e.relatedTarget)) {
- dropZone.classList.remove('dragover');
- }
- }
- handleExternalDragEnter(e) {
- if (!e.dataTransfer.types.includes('Files')) {
- return;
- }
-
- const dropZone = e.target.closest(this.selectors.field.dropZone);
-
- if (dropZone) {
- e.preventDefault();
- dropZone.classList.add('dragover');
- }
+ let data = {
+ uploadId: uploadId,
+ file: file,
+ field: field,
+ };
+ return this.templates.create('uploadItem', data);
}
- handleExternalDragOver(e) {
- if (!e.dataTransfer.types.includes('Files')) return;
-
- const dropZone = e.target.closest(this.selectors.field.dropZone);
- if (dropZone) {
- e.preventDefault();
- e.dataTransfer.dropEffect = 'copy';
+ 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';
}
-
- handleExternalDrop(e) {
- const dropZone = e.target.closest(this.selectors.field.dropZone);
-
- if (!dropZone) return;
-
- e.preventDefault();
- dropZone.classList.remove('dragover');
-
- const files = Array.from(e.dataTransfer.files);
-
- if (files.length === 0) return;
-
- const fieldId = this.getFieldIdFromElement(dropZone);
-
- if (fieldId) {
- this.processFiles(fieldId, files);
- this.a11y.announce(`${files.length} file(s) dropped for upload`);
- } else {
- console.error('No field ID found for drop zone');
- }
- }
-
- /*******************************************************************************
- * ITEM DROP HANDLER (for rearranging existing uploads)
- *******************************************************************************/
-
- /**
- * Handle items being dropped (called by DragController)
- */
- handleItemDrop(itemIds, targetElement) {
- const isPreviewDrop = targetElement.classList.contains('preview');
- let actualTarget = targetElement;
-
- // Handle drop on empty group placeholder
- if (targetElement.classList.contains('empty-group')) {
- const fieldId = this.getFieldIdFromElement(targetElement);
- const group = this.createGroup(fieldId);
-
- if (!group) {
- console.error('Failed to create group');
- return;
- }
-
- actualTarget = group.grid;
- }
-
- // Move each item to target
- itemIds.forEach(uploadId => {
- if (isPreviewDrop) {
- // Moving back to preview (ungrouping)
- this.removeFromGroup(uploadId);
- } else {
- // Moving to a group
- this.addToGroup(uploadId, actualTarget);
- }
- });
-
- // Persist state
- const fieldId = this.getFieldIdFromElement(targetElement);
- this.schedulePersistance(fieldId);
-
- // Announce for accessibility
- const message = itemIds.length > 1
- ? `Moved ${itemIds.length} items`
- : 'Moved item';
- this.a11y.announce(message);
- }
-
- /*******************************************************************************
- * CLICK HANDLERS
- *******************************************************************************/
-
- handleClick(e) {
- // File input triggers
- if (e.target.matches(this.selectors.field.dropZone) ||
- e.target.closest(this.selectors.field.dropZone)) {
- const dropZone = e.target.closest(this.selectors.field.dropZone);
- if (dropZone && !e.target.matches('input, button, a')) {
- const input = dropZone.querySelector(this.selectors.field.input);
- input?.click();
- }
- }
-
- // Group actions
- const actionButton = e.target.closest('[data-action]');
- if (actionButton) {
- this.handleAction(actionButton);
- }
- }
-
- handleChange(e) {
- const fieldId = this.getFieldIdFromElement(e.target);
- // File input change
- if (e.target.matches(this.selectors.field.input)) {
- const fieldId = this.getFieldIdFromElement(e.target);
- const files = Array.from(e.target.files);
-
- if (files.length > 0 && fieldId) {
- this.processFiles(fieldId, files);
- }
- }
-
- // Meta field changes
- if (fieldId) {
- if (this.fields.get(fieldId).config.destination === 'post_group') {
- this.handleGroupMetaChange(e.target);
- } else {
- this.queueUploadMeta(e);
- }
- }
- }
-
- /********************************************************************************
- UTILITY
- ********************************************************************************/
- getCurrentSelection(fieldId) {
- let selected = [];
- for (let [key, handler] of this.selectionHandlers) {
- if ((fieldId === key || key.includes(fieldId)) && handler.selectedItems.size > 0) {
- selected = selected.concat([... handler.selectedItems]);
- }
- }
- return selected;
- }
-
getSubtypeFromMime(mimeType) {
if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('video/')) return 'video';
return 'document';
}
-
- getStatusText(status) {
- return this.statusMapping[status] || status;
- }
-
- getStatusIcon(status) {
- return window.getIcon(this.queue.icons[status]);
- }
- getStatusProgress(status) {
- switch (status) {
- case 'local_processing':
- return 28;
- case 'queued':
- return 50;
- case 'uploading':
- return 66;
- case 'pending':
- return 75;
- case 'processing':
- return 89;
- case 'completed':
- return 100;
- default:
- return 0;
- }
- }
-
- getModalType(field) {
- // Return cached value if available
- if (field._cachedModalType !== undefined) {
- return field._cachedModalType;
- }
-
- // Safety check for field.element
- if (!field || !field.element) {
- field._cachedModalType = null;
- return null;
- }
-
- const dialog = field.element.closest('dialog');
- if (!dialog) {
- field._cachedModalType = null;
- return null;
- }
-
- let modalType = null;
- if (dialog.classList.contains('edit')) modalType = 'edit';
- else if (dialog.classList.contains('create')) modalType = 'create';
- else if (dialog.classList.contains('bulkEdit')) modalType = 'bulkEdit';
- else modalType = dialog.className;
-
- // Cache the result
- field._cachedModalType = modalType;
- return modalType;
- }
- /*******************************************************************************
- * GROUP ACTIONS
- *******************************************************************************/
-
- handleAction(button) {
- const action = button.dataset.action;
- const fieldId = this.getFieldIdFromElement(button);
- switch(action) {
- case 'add-to-group':
- this.handleAddToGroup(button);
- break;
- case 'delete-group':
- this.handleDeleteGroup(button);
- break;
- case 'delete-upload':
- case 'remove-from-group':
- this.handleRemoveItem(button);
- break;
- case 'upload':
- //upload groups
- let field = this.fields.get(fieldId);
- field.element.closest('details').open = false;
- document.body.classList.add('uploading');
-
- this.submitUploads(fieldId);
- break;
- case 'restore':
- this.handleRestoreUploads().then(()=>{});
- break;
- case 'clear-cache':
- if (!confirm(`Save these uploads for later?`)) {
- this.cleanupStoredUploads();
- }
- this.cleanupRestore();
- break;
- }
- }
-
- handleAddToGroup(button) {
- const fieldElement = button.closest(this.selectors.field.field);
- const fieldId = fieldElement?.dataset.uploader;
-
- if (!fieldId) return;
-
- const selected = this.selected.get(fieldId);
-
- if (!selected || selected.size === 0) {
- // Create empty group
- this.createGroup(fieldId);
- } else {
- // Create group with selected items
- const group = this.createGroup(fieldId);
- if (!group) return;
-
- selected.forEach(uploadId => {
- this.addToGroup(uploadId, group.grid);
- });
-
- // Clear selection
- const handler = this.selectionHandlers.get(fieldId);
- handler?.clearSelection();
-
- this.a11y.announce(`Created group with ${selected.size} items`);
- }
-
- this.schedulePersistance(fieldId);
- }
-
- handleDeleteGroup(button) {
- const group = button.closest(this.selectors.groups.container);
- if (!group) return;
-
- const groupId = group.dataset.groupId;
- const fieldId = this.getFieldIdFromElement(group);
-
- if (!confirm('Delete this group? Items will be moved back to the upload area.')) {
- return;
- }
-
- // Move items back to preview
- const items = group.querySelectorAll(this.selectors.items.item);
- items.forEach(item => {
- const uploadId = item.dataset.uploadId;
- this.removeFromGroup(uploadId);
- });
-
- // Remove group
- this.deleteGroup(groupId);
-
- this.a11y.announce('Group deleted, items returned to upload area');
- this.schedulePersistance(fieldId);
- }
-
- handleRemoveItem(button) {
+ /**
+ * Called by handleAction
+ * @param button
+ */
+ async handleRemoveItem(button) {
+ console.log('Handling remove upload');
const item = button.closest(this.selectors.items.item);
if (!item) return;
const uploadId = item.dataset.uploadId;
- const fieldId = this.getFieldIdFromElement(item);
+ const attachmentId = item.dataset.id;
- if (!confirm('Remove this item?')) {
- return;
+ 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.removeUpload(fieldId, uploadId);
this.a11y.announce('Item removed');
- this.schedulePersistance(fieldId);
}
- /*******************************************************************************
- * SELECTION MANAGEMENT
- *******************************************************************************/
-
- /**
- * Add selection handler for a field
- */
- addFieldSelectionHandler(fieldId) {
- if (this.selectionHandlers.has(fieldId)) {
- return this.selectionHandlers.get(fieldId);
- }
-
+ updateHiddenInput(fieldId) {
const field = this.fields.get(fieldId);
- if (!field) return;
+ if (!field?.ui.hidden) return;
- const container = field.ui.field;
- if (!container) return;
-
- const handler = new window.jvbHandleSelection({
- container: container,
- ui: {
- selectAll: container.querySelector('[name="select-all-uploads"]'),
- bulkControls: container.querySelector('.selection-actions'),
- count: container.querySelector('.selection-count')
- },
- itemSelector: '[data-upload-id]',
- checkboxSelector: '[name*="select-item"]'
- });
-
- // Subscribe to selection changes
- handler.subscribe((event, data) => {
- switch(event) {
- case 'item-selected':
- case 'item-deselected':
- case 'range-selected':
- this.selected.set(fieldId, data.selectedItems);
- break;
- case 'select-all':
- this.handleSelectAll(data.container, data.selected);
- break;
- }
- });
-
- this.selectionHandlers.set(fieldId, handler);
- return handler;
- }
-
- /**
- * Add selection handler for a group
- */
- addGroupSelectionHandler(fieldId, groupId) {
- const handlerKey = `${fieldId}_${groupId}`;
-
- if (this.selectionHandlers.has(handlerKey)) {
- return this.selectionHandlers.get(handlerKey);
- }
-
- const group = this.groups.get(groupId);
- if (!group) return;
-
- const handler = new window.jvbHandleSelection({
- container: group.element,
- ui: {
- selectAll: group.element.querySelector(this.selectors.groups.selectAll),
- bulkControls: group.element.querySelector(this.selectors.groups.actions),
- count: group.element.querySelector(this.selectors.groups.count)
- },
- itemSelector: '[data-upload-id]',
- checkboxSelector: '[name*="select-item"]'
- });
-
- handler.subscribe((event, data) => {
- switch(event) {
- case 'item-selected':
- case 'item-deselected':
- case 'range-selected':
- this.selected.set(fieldId, data.selectedItems);
- break;
- case 'select-all':
- this.handleSelectAll(data.container, data.selected);
- break;
- }
- });
-
- this.selectionHandlers.set(handlerKey, handler);
- return handler;
- }
-
- handleSelectAll(container, selected) {
- }
-
- /*******************************************************************************
- * HELPER METHODS
- *******************************************************************************/
-
- determineFieldId(fieldElement) {
- const content = fieldElement.dataset.content ||
- fieldElement.closest('dialog')?.dataset.content ||
- fieldElement.closest('form')?.dataset.save || '';
- const itemID = fieldElement.dataset.itemId ||
- fieldElement.closest('dialog')?.dataset.itemId || '';
- const field = fieldElement.dataset.field || '';
-
- return `${content}_${itemID}_${field}`;
- }
-
- getFromElement(element, type) {
- const map = {
- 'field': { selector: this.selectors.field.field, key: 'uploader', store: this.fields },
- 'upload': { selector: this.selectors.items.item, key: 'uploadId', store: this.uploads },
- 'group': { selector: this.selectors.groups.container, key: 'groupId', store: this.groups }
- };
-
- const config = map[type];
- if (!config) return null;
-
- const el = element.closest(config.selector);
- if (!el) return null;
-
- const id = el.dataset[config.key];
- return config.store.get(id);
- }
- getFieldFromElement(el) { return this.getFromElement(el, 'field'); }
- getUploadFromElement(el) { return this.getFromElement(el, 'upload'); }
- getGroupFromElement(el) { return this.getFromElement(el, 'group'); }
-
- getFieldIdFromElement(el) { return this.getFromElement(el, 'field')?.id ?? null};
- getUploadIdFromElement(el) {return this.getFromElement(el, 'upload')?.id ?? null};
- getGroupIdFromElement(el) {return this.getFromElement(el, 'group')?.id ?? null};
-
-
- /*******************************************************************************
- * FILE PROCESSING
- *******************************************************************************/
- async processFiles(fieldId, files) {
- const field = this.fields.get(fieldId);
- if (!field) return;
-
- // Hide upload container, show group display
- if (field.ui.dropZone) {
- field.ui.dropZone.hidden = true;
- }
- if (field.ui.groups.display) {
- field.ui.groups.display.hidden = false;
- }
-
- const totalFiles = files.length;
- let processedCount = 0;
-
- // Show initial progress
- this.updateUploadProgress(fieldId, 0, totalFiles, 'Processing files...');
-
- // Initialize field uploads set if needed
- if (!field.uploads) {
- field.uploads = new Set();
- }
-
- // Process files
- const processPromises = Array.from(files).map(async (file, index) => {
- try {
-
- // Create upload ID
- const uploadId = `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
-
- // Create upload data
- const uploadData = {
- id: uploadId,
- attachment_id: null,
- fieldId: fieldId,
- originalFile: file,
- processedFile: null,
- preview: null,
- status: 'local_processing',
- element: null,
- location: null,
- meta: {
- originalName: file.name,
- size: file.size,
- type: file.type
- }
- };
-
- // Create preview URL
- uploadData.preview = this.createPreviewUrl(file);
-
- // Process the file (resize if image)
- if (file.type.startsWith('image/')) {
- uploadData.processedFile = await this.processImage(file, field.subtype);
- } else {
- uploadData.processedFile = file;
+ const remaining = Array.from(field.ui.grid?.querySelectorAll(this.selectors.items.item) || [])
+ .map(el => {
+ if (Object.hasOwn(el.dataset, 'id') && el.dataset.id > 0) {
+ return el.dataset.id;
}
- // Store blob data separately in IndexedDB
- await this.uploadStore.saveBlob(uploadId, uploadData.processedFile || file);
-
- // Create DOM element
- const subtype = this.getSubtypeFromMime(file.type);
- uploadData.element = this.createUploadElement({
- ...uploadData,
- subtype: subtype
- }, field.config.destination === 'post_group');
-
- // Show progress on the item
- this.showUploadProgress(uploadId, true);
- this.updateUploadItemProgress(uploadId, 50, 'local_processing');
-
- // Add to preview grid
- if (field.ui.preview) {
- field.ui.preview.appendChild(uploadData.element);
- uploadData.location = field.ui.preview;
+ if (Object.hasOwn(el.dataset, 'upload-id') && el.dataset.uploadId > 0) {
+ return el.dataset.uploadId;
}
+ //For timeline
+ return el.dataset.itemId;
+ })
+ .filter(Boolean);
- // Store upload
- this.uploads.set(uploadId, uploadData);
- field.uploads.add(uploadId);
+ const newValue = remaining.join(',');
+ if (field.ui.hidden.value === newValue) return;
- // Update progress
- processedCount++;
- this.updateUploadProgress(fieldId, processedCount, totalFiles, 'Processing files...');
- this.updateUploadItemProgress(uploadId, 100, 'processed');
- uploadData.status = 'processed';
+ 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;
- // Fade out item progress after a moment
- setTimeout(() => {
- this.showUploadProgress(uploadId, false);
- }, 1000);
-
- return uploadId;
-
- } catch (error) {
- console.error('Error processing file:', file.name, error);
- processedCount++;
- this.updateUploadProgress(fieldId, processedCount, totalFiles, 'Processing files...');
- return null;
+ if (key === 'status') {
+ await this.setUploadStatus(upload, value);
}
+ upload[key] = value;
+ return this.stores.uploads.save(upload);
});
-
- // Wait for all files to process
- await Promise.all(processPromises);
-
- this.updateFieldState(fieldId);
- // Cache the state (now without DOM references)
- await this.schedulePersistance(fieldId);
-
- // Queue for upload if in direct mode
- if (field.config.destination !== 'post_group') {
- await this.queueUpload(fieldId);
- // Lock uploads if max reached
- this.maybeLockUploads(fieldId);
- }
-
+ await Promise.all(promises);
}
- updateFieldState(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field || !field.ui.field) return;
-
- const container = field.ui.field;
- const uploadCount = field.uploads?.size || 0;
- const hasGroups = field.ui.groups?.container?.querySelectorAll('.upload-group').length > 0;
-
- // Set data attributes for CSS targeting
- container.dataset.hasUploads = uploadCount > 0 ? 'true' : 'false';
- container.dataset.uploadCount = uploadCount.toString();
- container.dataset.hasGroups = hasGroups ? 'true' : 'false';
-
- // Update ARIA labels for accessibility
- if (field.ui.preview) {
- field.ui.preview.setAttribute('aria-label',
- `Upload preview area with ${uploadCount} item${uploadCount !== 1 ? 's' : ''}`
- );
- }
- }
-
- updateUploadProgress(fieldId, current, total, message) {
- const field = this.fields.get(fieldId);
- if (!field?.ui?.progress?.progress) return;
-
- const progress = field.ui.progress;
- const percent = total > 0 ? (current / total) * 100 : 0;
-
- if (progress.fill) {
- progress.fill.style.width = `${percent}%`;
- }
- if (progress.text) {
- progress.text.textContent = message;
- }
- if (progress.count) {
- progress.count.textContent = `${current}/${total}`;
- }
-
- progress.progress.hidden = (current === total);
- }
-
- updateFieldStatus(fieldId, status) {
- const field = this.fields.get(fieldId);
- if (!field) return;
-
- field.state = status;
- // Update UI based on status
- }
-
- updateUploadStatus(uploadId, status) {
- const upload = this.uploads.get(uploadId);
+ async setUploadStatus(upload, status) {
+ if (typeof upload === 'string') upload = await this.stores.uploads.get(upload);
if (!upload) return;
-
- upload.status = status;
- this.updateUploadUI(uploadId);
- }
-
- updateUploadUI(uploadId) {
- const upload = this.uploads.get(uploadId);
- if (!upload?.element) return;
-
- // Update status classes
- upload.element.className = upload.element.className.replace(/status-[\w-]+/g, '');
- upload.element.classList.add(`status-${upload.status}`);
-
- // Update progress if showing
- const progress = upload.element.querySelector('.progress');
- if (progress) {
- this.updateUploadItemProgress(uploadId,
- this.getStatusProgress(upload.status),
- upload.status
- );
+ if (upload.progress) {
+ window.showProgress(upload.progress, this.getStatusProgress(status), 100, this.getStatusText(status), this.queue.icons[status]??'');
}
}
- /**
- * Show/hide progress indicator on individual upload items
- */
- showUploadProgress(uploadId, show = true) {
- const upload = this.uploads.get(uploadId);
- if (!upload || !upload.element) return;
+ async removeUpload(uploadId) {
+ let upload = this.stores.uploads.get(uploadId);
+ if (!upload) return;
+ const fieldId = upload.field; // grab before clearing
- const progressEl = upload.element.querySelector('.progress');
- if (progressEl) {
- if (show) {
- progressEl.style.removeProperty('animation');
- progressEl.hidden = false;
+ 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 {
- progressEl.style.animation = 'fadeOut var(--transition-base)';
- setTimeout(() => {
- progressEl.hidden = true;
- }, 300);
- }
- }
- }
-
- /**
- * Update individual upload progress bar
- */
- updateUploadItemProgress(uploadId, percent, status = null) {
- const upload = this.uploads.get(uploadId);
- if (!upload || !upload.element) return;
-
- const progressEl = upload.element.querySelector('.progress');
- if (!progressEl) return;
-
- const fill = progressEl.querySelector('.fill');
- const details = progressEl.querySelector('.details');
- const icon = progressEl.querySelector('.icon');
-
- if (fill) {
- fill.style.width = `${percent}%`;
- }
-
- if (status && details) {
- details.textContent = this.getStatusText(status);
- }
-
- if (status && icon) {
- icon.innerHTML = this.getStatusIcon(status).outerHTML;
- }
- }
- checkFieldLimits(fieldId, additionalFiles) {
- const field = this.fields.get(fieldId);
- if (!field) return false;
-
- const currentCount = field.uploads?.size || 0;
- const totalCount = currentCount + additionalFiles;
-
- return totalCount <= field.maxFiles;
-
-
- }
- validateFile(file, field) {
- // Type validation
- if (!this.settings.allowedTypes.includes(file.type)) {
- this.notify(`Invalid file type: ${file.type}`, 'error');
- return false;
- }
-
- // Size validation
- if (file.size > this.settings.maxFileSize) {
- this.notify(`File too large: ${this.formatBytes(file.size)}`, 'error');
- return false;
- }
-
- return true;
- }
-
- formatBytes(bytes, decimals = 2) {
- if (bytes === 0) return '0 Bytes';
-
- const k = 1024;
- const dm = decimals < 0 ? 0 : decimals;
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
-
- const i = Math.floor(Math.log(bytes) / Math.log(k));
-
- return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
- }
-
- shouldProcessClientSide(file, subtype) {
- // Only process images client-side
- if (subtype === 'image' && file.type.startsWith('image/')) {
- return true;
- }
-
- // Videos and documents go straight to server
- return false;
- }
-
- async processImage(file, uploadId) {
- const timeout = this.worker.settings.timeout;
-
- return new Promise((resolve, reject) => {
- let timeoutId;
- let taskCompleted = false;
-
- // Set timeout
- timeoutId = setTimeout(() => {
- if (!taskCompleted) {
- taskCompleted = true;
-
- // Remove from active tasks
- this.worker.tasks.delete(uploadId);
-
- // Maybe restart worker if configured
- if (this.worker.settings.restartAfterTimeout) {
- this.restartCompressionWorker();
- }
-
- reject(new Error(`Processing timeout for ${file.name}`));
- }
- }, timeout);
-
- // Track this task
- this.worker.tasks.set(uploadId, { file, timeoutId });
-
- // Process image
- this.handleProcess(file, uploadId)
- .then(result => {
- if (!taskCompleted) {
- taskCompleted = true;
- clearTimeout(timeoutId);
- this.worker.tasks.delete(uploadId);
- resolve(result);
- }
- })
- .catch(error => {
- if (!taskCompleted) {
- taskCompleted = true;
- clearTimeout(timeoutId);
- this.worker.tasks.delete(uploadId);
- reject(error);
- }
- });
- });
- }
-
- async handleProcess(file, uploadId) {
- // Skip non-images
- if (!file.type.startsWith('image/')) {
- return file;
- }
-
- const maxDimension = this.getMaxDimension();
- const quality = 0.85;
-
- // Try worker first if available
- if (this.shouldUseWorker(file)) {
- try {
- // Ensure worker is initialized
- if (!this.worker.worker) {
- this.initCompressionWorker();
- }
-
- if (this.worker.worker) {
- return await this.processWithWorker(file, uploadId, maxDimension, quality);
- }
- } catch (error) {
- console.warn('Worker processing failed, falling back to main thread:', error);
+ await this.stores.groups.save(group);
}
}
- // Fallback to main thread
- return await this.processOnMainThread(file, maxDimension, quality);
- }
-
- /**
- * Process image on main thread with better error handling
- */
- async processOnMainThread(file, maxDimension, quality) {
- return new Promise((resolve, reject) => {
- const img = new Image();
- const canvas = document.createElement('canvas');
- const ctx = canvas.getContext('2d');
- let objectUrl = null;
-
- const cleanup = () => {
- img.onload = null;
- img.onerror = null;
- if (objectUrl) {
- URL.revokeObjectURL(objectUrl);
- objectUrl = null;
- }
- // Explicitly clean up canvas
- canvas.width = 1;
- canvas.height = 1;
- ctx.clearRect(0, 0, 1, 1);
- };
-
- img.onload = () => {
- try {
- const { width, height } = this.calculateOptimalDimensions(img, maxDimension);
- canvas.width = width;
- canvas.height = height;
-
- // Enhanced image smoothing
- ctx.imageSmoothingEnabled = true;
- ctx.imageSmoothingQuality = 'high';
- ctx.drawImage(img, 0, 0, width, height);
-
- const outputFormat = this.getOptimalFormat(file);
- const outputQuality = this.getOptimalQuality(file, quality);
-
- canvas.toBlob(
- (blob) => {
- cleanup();
- if (blob) {
- const processedFile = new File(
- [blob],
- this.getProcessedFileName(file, outputFormat),
- { type: outputFormat, lastModified: Date.now() }
- );
- resolve(processedFile);
- } else {
- reject(new Error('Canvas toBlob failed'));
- }
- },
- outputFormat,
- outputQuality
- );
-
- } catch (error) {
- cleanup();
- reject(new Error(`Canvas processing failed: ${error.message}`));
- }
- };
-
- img.onerror = () => {
- cleanup();
- reject(new Error(`Failed to load image: ${file.name}`));
- };
-
- try {
- objectUrl = this.createPreviewUrl(file);
- img.src = objectUrl;
- } catch (error) {
- cleanup();
- reject(new Error(`Failed to create object URL: ${error.message}`));
- }
- });
- }
-
- /**
- * Get optimal output format
- */
- getOptimalFormat(file) {
- // Keep original format for certain types
- if (file.type === 'image/gif' || file.type === 'image/svg+xml') {
- return file.type;
- }
-
- // Use WebP if supported, otherwise JPEG
- return this.supportsWebP() ? 'image/webp' : 'image/jpeg';
- }
-
- /**
- * Get optimal quality setting
- */
- getOptimalQuality(file, requestedQuality) {
- // Higher quality for smaller files
- if (file.size < 500 * 1024) return Math.max(requestedQuality, 0.9);
- if (file.size < 2 * 1024 * 1024) return requestedQuality;
-
- // Lower quality for very large files
- return Math.min(requestedQuality, 0.8);
- }
-
- /**
- * Generate processed file name
- */
- getProcessedFileName(originalFile, outputFormat) {
- const baseName = originalFile.name.replace(/\.[^/.]+$/, '');
-
- const extensions = {
- 'image/webp': '.webp',
- 'image/jpeg': '.jpg',
- 'image/png': '.png',
- 'image/gif': '.gif'
- };
-
- return baseName + (extensions[outputFormat] || '.jpg');
- }
-
- /**
- * Get maximum dimension based on device capabilities
- */
- getMaxDimension() {
- const screenWidth = window.screen.width;
- const devicePixelRatio = window.devicePixelRatio || 1;
-
- // Scale based on device capabilities
- if (screenWidth * devicePixelRatio > 2560) return 2400;
- if (screenWidth * devicePixelRatio > 1920) return 1920;
- return 1200;
- }
-
- /**
- * Determine if we should use Web Worker
- */
- shouldUseWorker(file) {
- // Use worker for large files or when available
- return this.worker.worker &&
- file.size > 1024 * 1024 && // > 1MB
- typeof OffscreenCanvas !== 'undefined';
- }
-
- async processWithWorker(file, uploadId, maxDimension, quality) {
- return new Promise((resolve, reject) => {
- if (!this.worker.worker) {
- reject(new Error('Worker not available'));
- return;
- }
-
- // Create unique message ID for this task
- const messageId = `${uploadId}_${Date.now()}`;
-
- // Handler for this specific message
- const messageHandler = (e) => {
- if (e.data.messageId !== messageId) return;
-
- // Remove handler
- this.worker.worker.removeEventListener('message', messageHandler);
- this.worker.worker.removeEventListener('error', errorHandler);
-
- if (e.data.success) {
- const processedFile = new File(
- [e.data.blob],
- this.getProcessedFileName(file, e.data.format || 'image/webp'),
- { type: e.data.format || 'image/webp', lastModified: Date.now() }
- );
- resolve(processedFile);
- } else {
- reject(new Error(e.data.error || 'Worker processing failed'));
- }
- };
-
- const errorHandler = (error) => {
- this.worker.worker.removeEventListener('message', messageHandler);
- this.worker.worker.removeEventListener('error', errorHandler);
- reject(new Error(`Worker error: ${error.message}`));
- };
-
- // Add handlers
- this.worker.worker.addEventListener('message', messageHandler);
- this.worker.worker.addEventListener('error', errorHandler);
-
- // Send message to worker
- this.worker.worker.postMessage({
- messageId,
- file,
- maxDimension,
- quality,
- outputFormat: this.getOptimalFormat(file)
- });
- });
- }
-
- /**
- * Restart compression worker
- */
- restartCompressionWorker() {
- // Terminate existing worker
- if (this.worker.worker) {
- this.worker.worker.terminate();
- this.worker.worker = null;
- }
-
- // Clear active tasks
- this.worker.tasks.clear();
-
- // Check restart limit
- if (this.worker.restart.count >= this.worker.restart.max) {
- console.error('Max worker restarts reached, disabling worker');
- return;
- }
-
- this.worker.restart.count++;
-
- // Reinitialize
- this.initCompressionWorker();
- }
-
- /**
- * Initialize Web Worker for image compression
- */
- initCompressionWorker() {
- if (this.worker.worker || typeof Worker === 'undefined') return;
-
- try {
- const workerScript = `
- self.onmessage = async function(e) {
- const { messageId, file, maxDimension, quality, outputFormat } = e.data;
-
- try {
- // Create ImageBitmap from file
- const bitmap = await createImageBitmap(file);
-
- // Calculate dimensions
- const scale = Math.min(maxDimension / bitmap.width, maxDimension / bitmap.height, 1);
- const width = Math.round(bitmap.width * scale);
- const height = Math.round(bitmap.height * scale);
-
- // Create OffscreenCanvas
- const canvas = new OffscreenCanvas(width, height);
- const ctx = canvas.getContext('2d');
-
- // Draw and resize
- ctx.imageSmoothingEnabled = true;
- ctx.imageSmoothingQuality = 'high';
- ctx.drawImage(bitmap, 0, 0, width, height);
-
- // Clean up bitmap
- bitmap.close();
-
- // Convert to blob
- const blob = await canvas.convertToBlob({
- type: outputFormat,
- quality: quality
- });
-
- self.postMessage({
- messageId,
- success: true,
- blob: blob,
- format: outputFormat
- });
-
- } catch (error) {
- self.postMessage({
- messageId,
- success: false,
- error: error.message
- });
- }
- };
- `;
-
- const blob = new Blob([workerScript], { type: 'application/javascript' });
- this.worker.worker = new Worker(this.createPreviewUrl(blob));
-
- } catch (error) {
- console.warn('Failed to initialize compression worker:', error);
- this.worker.worker = null;
- }
- }
-
- /**
- * Calculate optimal dimensions with aspect ratio preservation
- */
- calculateOptimalDimensions(img, maxDimension) {
- let { width, height } = img;
-
- // Don't upscale
- if (width <= maxDimension && height <= maxDimension) {
- return { width, height };
- }
-
- // Calculate scale factor
- const scale = Math.min(maxDimension / width, maxDimension / height);
-
- return {
- width: Math.round(width * scale),
- height: Math.round(height * scale)
- };
- }
-
-
- /**
- * Check WebP support
- */
- supportsWebP() {
- const canvas = document.createElement('canvas');
- return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0;
- }
-
- createPreviewUrl(file) {
- const url = URL.createObjectURL(file);
- // Track for cleanup
- if (!this.previewUrls) this.previewUrls = new Set();
- this.previewUrls.add(url);
- return url;
- }
-
- revokePreviewUrl(url) {
- if (url?.startsWith('blob:')) {
- URL.revokeObjectURL(url);
- this.previewUrls?.delete(url);
- }
- }
-
- maybeLockUploads(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field?.ui?.dropZone) return;
-
- if (field.config.destination === 'post_group') {
- return;
- }
-
- const uploadCount = field.uploads?.size || 0;
- const maxFiles = field.config?.maxFiles || 999;
-
- // Hide dropzone if at max files
- field.ui.dropZone.hidden = uploadCount >= maxFiles;
-
- // Update field state
- field.element.classList.toggle('at-max-uploads', uploadCount >= maxFiles);
- }
- createUploadElement(upload, draggable = false) {
- let image = window.getTemplate('uploadItem');
- if (!image) {
- console.error('Image template not found');
- return;
- }
- image.dataset.uploadId = upload.id;
- if (upload.originalFile) {
- image.dataset.subtype = this.getSubtypeFromMime(upload.originalFile.type);
- }
-
-
- image.querySelector('[name="featured"]').value = upload.id;
- let [
- featured,
- img,
- video,
- preview,
- details
- ] = [
- image.querySelector('[name="featured"]'),
- image.querySelector('img'),
- image.querySelector('video'),
- image.querySelector('label > span'),
- image.querySelector('details')
- ];
- [
- featured.value,
- img.src,
- img.alt
- ] = [
- upload.id,
- upload.preview,
- upload.originalFile?.name ?? upload.meta?.originalName ?? '',
- ];
-
- switch (image.dataset.subtype) {
- case 'image':
- [
- img.src,
- img.alt
- ] = [
- upload.preview,
- upload.originalFile?.name ?? upload.meta?.originalName?? ''
- ];
- video.remove();
- preview.remove();
- break;
- case 'video':
- video.src = upload.preview;
- img.remove();
- preview.remove();
- break;
- case 'document':
- const fileName = upload.originalFile?.name ?? upload.meta?.originalName ?? '';
- const extension = fileName.split('.').pop()?.toLowerCase() ?? '';
- const iconMap = {
- 'pdf': 'file-pdf',
- 'csv': 'file-csv',
- 'doc': 'file-doc',
- 'docx': 'file-doc',
- 'txt': 'file-txt',
- 'xls': 'file-xls',
- 'xlsx': 'file-xls'
- };
-
- const icon = window.getIcon(iconMap[extension] || 'file');
-
- preview.innerText = upload.originalFile.name;
- preview.prepend(icon);
- img.remove();
- video.remove();
- break;
- }
- if (details) {
- let template = window.getTemplate('uploadMeta');
- if (template){
- details.append(template);
- }
- }
- image.draggable = draggable;
-
- // Update input IDs safely
- image.querySelectorAll('input').forEach(input => {
- let id = input.id;
- if (id) {
- let newId = id + upload.id;
- let label = input.parentNode.querySelector(`label[for="${id}"]`);
- input.id = newId;
- if (label) {
- label.htmlFor = newId;
- }
- }
- });
-
- return image;
- }
- /*******************************************************************************
- * QUEUE INTEGRATION
- *******************************************************************************/
- async submitUploads(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field?.uploads || field.uploads.size === 0) {
- return;
- }
-
- let uploads = Array.from(field.uploads);
- if (uploads.length === 0) {
- this.error.log('No uploads to upload', {
- component: 'UploadManager',
- action: 'submitGroupedUploads',
- fieldId: fieldId
- });
- return;
- }
-
- const fieldGroups = this.getFieldGroups(fieldId);
-
- if (fieldGroups.length === 0) {
- this.error.log('No groups created for post_group upload', {
- component: 'UploadManager',
- action: 'submitGroupedUploads',
- fieldId: fieldId
- });
- return;
- }
-
- // Build posts array from groups
- const posts = [];
- const formData = new FormData();
- let uploadMap = [];
-
- uploads = uploads.map((upload) => {
- return this.uploads.get(upload);
- });
-
- fieldGroups.forEach((group, groupIndex) => {
- const post = {
- images: [],
- fields: {}
- };
- for (let [name, value] of Object.entries(group.changes)) {
- post.fields[name] = value;
- }
-
- let groupUploads = uploads.filter((upload) => {
- return upload['groupId'] === group.id;
- });
-
- groupUploads.forEach((upload) => {
- if (upload) {
- const fileToUpload = upload.processedFile || upload.originalFile;
- if (fileToUpload) {
- formData.append('files[]', fileToUpload);
-
- const imageData = {
- upload_id: upload.id,
- index: uploadMap.length
- };
- post.images.push(imageData);
- uploadMap.push(upload.id);
- }
- }
- });
- // Add images for this group
- // group.uploads.forEach(uploadId => {
- // const upload = this.uploads.get(uploadId);
- // if (upload) {
- // const fileToUpload = upload.processedFile || upload.originalFile;
- // if (fileToUpload) {
- // formData.append('files[]', fileToUpload);
- //
- // const imageData = {
- // upload_id: upload.id,
- // index: uploadMap.length
- // };
- //
- // // Check if this is the featured image
- // const radioInput = upload.element?.querySelector('[name="featured"]');
- // if (radioInput?.checked) {
- // post.fields.featured = upload.id;
- // }
- //
- // post.images.push(imageData);
- // uploadMap.push(upload.id);
- // }
- // }
- // });
-
- posts.push(post);
- });
-
- //Each remaining upload (without a groupId) becomes its own post
- let remainingUploads = uploads.filter((upload) => {
- return !Object.hasOwn(upload, 'groupId');
- });
-
- remainingUploads.forEach((upload) => {
- if (upload) {
-
- const post = {
- images: [],
- fields: {}
- };
- const fileToUpload = upload.processedFile || upload.originalFile;
- if (fileToUpload) {
- formData.append('files[]', fileToUpload);
-
- const imageData = {
- upload_id: upload.id,
- index: uploadMap.length
- };
- post.images.push(imageData);
- uploadMap.push(upload.id);
- }
- posts.push(post);
- }
- });
-
-
- // Add metadata to FormData
- formData.append('content', field.config.content);
- formData.append('user', field.config.itemID); // Assuming itemID is user ID
- formData.append('posts', JSON.stringify(posts));
- formData.append('upload_ids', JSON.stringify(uploadMap));
-
- for (const [key, value] of formData.entries()) {
- console.log(key, value);
- }
- const operation = {
- endpoint: 'uploads/groups',
- method: 'POST',
- data: formData,
- title: `Creating ${posts.length} ${field.config.content}${posts.length > 1 ? 's' : ''} from uploads...`,
- popup: `Creating ${posts.length} post${posts.length > 1 ? 's' : ''}...`,
- canMerge: false,
- headers: {
- 'action_nonce': jvbSettings.dash
- },
- append: '_upload',
- };
-
- try {
- const operationId = await this.queue.addToQueue(operation);
-
- uploads.forEach(uploadId => {
- let upload = this.uploads.get(uploadId);
- if (upload) {
- upload.operationId = operationId;
- this.updateUploadStatus(uploadId, 'queued');
- }
- });
-
- field.operationId = operationId;
- this.a11y.announce(`Creating ${posts.length} post${posts.length > 1 ? 's' : ''} from your uploads`);
-
- return operationId;
- } catch (error) {
- this.error.log(error, {
- component: 'UploadManager',
- action: 'submitGroupedUploads',
- fieldId: fieldId
- });
- throw error;
- } finally {
- this.schedulePersistance(field.id);
- }
- }
-
- async queueUpload(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field?.uploads) return;
-
- const uploads = Array.from(field.uploads);
- if (uploads.length === 0) {
- return;
- }
-
- const data = this.prepareUploadData(field, uploads);
- this.a11y.announce('Queuing for upload');
- let img = (uploads.length === 1) ? 'file' : 'files';
- const operation = {
- endpoint: 'uploads',
- method: 'POST',
- data: data,
- title: `Uploading ${uploads.length} ${img} to server...`,
- popup: `Uploading ${uploads.length} ${img}...`,
- canMerge: false,
- headers: {
- 'action_nonce': jvbSettings.dash
- },
- append: '_upload'
- }
- try {
- const operationId = await this.queue.addToQueue(operation);
-
- uploads.forEach(uploadId => {
- let upload = this.uploads.get(uploadId);
- if (!upload) {
- return;
- }
- upload.operationId = operationId;
- this.updateUploadStatus(uploadId, 'queued');
- });
- field.operationId = operationId;
-
- return operationId;
- } catch (error) {
- throw error;
- } finally {
- this.schedulePersistance(field.id);
- }
- }
-
- prepareUploadData(field, uploads) {
-
- const formData = new FormData();
- formData.append('content', field.config.content);
- formData.append('mode', field.config.mode);
- formData.append('field_name', field.config.name);
- formData.append('fieldId', field.id);
- formData.append('field_type', field.config.type);
- formData.append('subtype', field.config.subtype);
- formData.append('item_id', field.config.itemID); //post, term, or user id
- formData.append('destination', field.config.destination || 'meta'); //meta, post, post_group
- let uploadMap = [];
-
- const fieldGroups = this.getFieldGroups(field.id);
- if (field.config.destination === 'post_group' && fieldGroups.length > 0) {
- // User has created groups
- let groups = [];
- let titles = [];
- let featuredImages = [];
-
- fieldGroups.forEach(group => {
- let groupUploadIndices = [];
- let featuredIndex = null;
-
- group.uploads.forEach(uploadId => {
- let upload = this.uploads.get(uploadId);
- if (upload) {
- const fileToUpload = upload.processedFile || upload.originalFile;
- if (fileToUpload) {
- formData.append('files[]', fileToUpload);
- const fileIndex = uploadMap.length;
- uploadMap.push(upload.id);
- groupUploadIndices.push(upload.id);
-
- // Check if this is the featured image
- const radioInput = upload.element?.querySelector('[name="featured"]');
- if (radioInput?.checked) {
- featuredIndex = upload.id;
- }
- }
- }
- });
-
- groups.push(groupUploadIndices);
- titles.push(group.title || '');
- featuredImages.push(featuredIndex);
- });
-
- formData.append('groups', JSON.stringify(groups));
- formData.append('group_titles', JSON.stringify(titles));
- formData.append('featured_images', JSON.stringify(featuredImages));
- } else {
- // No groups - just append all files
- uploads.forEach(uploadId => {
- let upload = this.uploads.get(uploadId);
- if (upload) {
- const fileToUpload = upload.processedFile || upload.originalFile;
- if (fileToUpload) {
- formData.append('files[]', fileToUpload);
- uploadMap.push(upload.id);
- }
- }
- });
- }
- formData.append('upload_ids', JSON.stringify(uploadMap));
-
- // console.log('Final FormData:');
- // for (let pair of formData.entries()) {
- // console.log(pair[0], pair[1]);
- // }
-
- return formData;
- }
-
- getFieldGroups(fieldId) {
- const groups = [];
-
- this.groups.forEach((groupData, groupId) => {
- if (groupData.fieldId === fieldId) {
- const field = this.fields.get(fieldId);
- const groupElement = field?.ui?.groups?.groups?.get(groupId);
-
- groups.push({
- id: groupId,
- uploads: Array.from(groupData.uploads || new Set()),
- changes: groupData.changes || {},
- element: groupElement || null
- });
- }
- });
-
- return groups;
- }
-
- async queueUploadMeta(e) {
- const upload = this.getUploadFromElement(e.target);
- if (!upload) return;
-
- const field = this.fields.get(upload.fieldId);
- if (!field) return;
-
- const container = e.target.closest('.upload-meta');
- if (!container) return;
-
- let data = {};
- data[e.target.name] = e.target.value;
-
- upload.meta = {
- ...upload.meta,
- ... data
- };
-
- let queueData = {};
- //If there is an attachment ID, use that: else, use our generated upload id
- queueData[upload.attachmentId??upload.id] = upload.meta;
-
- const operation = {
- endpoint: 'uploads/meta',
- method: 'POST',
- data: queueData,
- title: `Updating meta`,
- canMerge: true,
- headers: {
- 'action_nonce': jvbSettings.dash
- }
- };
-
- try {
- await this.queue.addToQueue(operation);
- } catch (error) {
- this.error.log(error, {
- component: 'UploadManager',
- action: 'sendMetaUpdate',
- uploadId: upload.id
- });
- }
- }
- /*******************************************************************************
- * GROUP MANAGEMENT
- *******************************************************************************/
-
- createGroup(fieldKey, groupId = null) {
- const field = this.fields.get(fieldKey);
- if (!field) {
- console.error('Field not found:', fieldKey);
- return null;
- }
-
- if (!groupId) {
- groupId = `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
- }
-
- const groupElement = this.createGroupElement(groupId, fieldKey);
- if (!groupElement) {
- console.error('Failed to create group element');
- return null;
- }
-
- // Store in field UI Map
- if (!field.ui.groups) {
- field.ui.groups = {
- groups: new Map(),
- container: null,
- empty: null,
- display: null
- };
- }
-
- field.ui.groups.groups.set(groupId, groupElement);
-
- // Insert into DOM
- if (field.ui.groups.container && field.ui.groups.empty) {
- field.ui.groups.container.insertBefore(groupElement, field.ui.groups.empty);
- } else if (field.ui.groups.container) {
- field.ui.groups.container.appendChild(groupElement);
- }
-
- // Create group object
- const group = {
- id: groupId,
- fieldId: fieldKey,
- element: groupElement,
- grid: groupElement.querySelector('.item-grid.group'),
- uploads: new Set(),
- changes: {}
- };
-
- // Store group
- this.groups.set(groupId, group);
-
- // Initialize selection handler for this group
- this.addGroupSelectionHandler(fieldKey, groupId);
-
- // Persist state
- this.schedulePersistance(fieldKey);
-
- return group;
- }
-
- createGroupElement(groupId, fieldId) {
- let groupElement = window.getTemplate('imageGroup');
- if (!groupElement) return;
-
- groupElement.dataset.groupId = groupId;
- groupElement.dataset.fieldId = fieldId;
-
- let fields = window.getTemplate('groupMetadata');
- const fieldsContainer = groupElement.querySelector('.fields');
- if (fieldsContainer && fields) {
- fieldsContainer.append(fields);
-
- // Set unique IDs and names for form fields
- const titleInput = fieldsContainer.querySelector('[name="post_title"]');
- const excerptInput = fieldsContainer.querySelector('[name="post_excerpt"]');
-
- if (titleInput) {
- titleInput.id = `${groupId}_title`;
- titleInput.name = `${groupId}[post_title]`;
- }
- if (excerptInput) {
- excerptInput.id = `${groupId}_excerpt`;
- excerptInput.name = `${groupId}[post_excerpt]`;
- }
- let field = this.fields.get(fieldId);
- if (field.config.content !== '') {
- let summary = groupElement.querySelector('summary');
- summary.textContent = field.config.content + ' Fields';
- }
- } else {
- groupElement.querySelector('details').remove();
- }
-
- const gridContainer = groupElement.querySelector('.item-grid.group');
- if (gridContainer) {
- gridContainer.dataset.groupId = groupId;
- }
-
- return groupElement;
- }
-
- deleteGroup(groupId, confirm = true) {
- let group = this.groups.get(groupId);
- if (!group) {
- return;
- }
-
- let keepUploads = true;
- if (confirm && group.uploads && group.uploads.size > 0) {
- keepUploads = !window.confirm('Delete uploads in group?');
- }
-
- if (confirm && keepUploads) {
- // Move any remaining uploads back to preview
- if (group.uploads && group.uploads.size > 0) {
- Array.from(group.uploads).forEach(uploadId => {
- this.addImageToGroup(uploadId, null, false);
- });
- }
- }
-
- // Remove from groups Map
- this.groups.delete(groupId);
-
- // Remove DOM element
- let groupElement = group.element;
- if (groupElement) {
- groupElement.remove();
- this.a11y.announce('Group removed');
- }
-
- this.schedulePersistance(group.fieldId);
- }
-
- addToGroup(uploadId, target = null, persist = true) {
- let upload = this.uploads.get(uploadId);
- if(!upload) {
- return;
- }
- let field = this.fields.get(upload.fieldId);
- if (!field) {
- return;
- }
-
- //Already in the Preview Grid, or already in the group we're moving to
- if ((!target && upload.location === field.ui.preview) || target === upload.location) {
- return;
- }
-
- // Remove from previous location
- if (upload.location) {
- let groupId = upload.location.dataset.groupId;
- if (groupId) {
- let group = this.groups.get(groupId);
- if (group && group.uploads) {
- group.uploads.delete(uploadId);
-
- if (group.uploads.size === 0) {
- this.deleteGroup(groupId);
- }
- }
- }
- }
-
- const checkbox = upload.element.querySelector('[name*="select-item"]');
- if (checkbox) {
- checkbox.checked = false;
- }
-
- let featured = upload.element.querySelector('[name="featured"]');
- featured.hidden = !target;
-
-
- //If no target, it's going to the preview grid
- if (!target) {
- target = field.ui.preview;
- upload.groupId = null;
- } else if (!target.classList.contains('item-grid') || !target.classList.contains('preview')) {
- // It's a group target
- let groupId = target.dataset.groupId;
- featured.name = groupId+'_'+featured.name;
- let group = this.groups.get(groupId);
- if (!group) {
- group = this.createGroup(upload.fieldId);
- target = group.grid;
- groupId = group.id;
- }
- if (group) {
- group.uploads.add(uploadId);
- upload.groupId = groupId;
- }
-
- }
-
- upload.location = target;
- target.append(upload.element);
-
- if (persist) {
- this.schedulePersistance(field.id);
- }
- }
-
- removeFromGroup(uploadId) {
- const upload = this.uploads.get(uploadId);
- if (!upload) return;
-
- const field = this.fields.get(upload.fieldId);
- if (!field) return;
-
- // Remove from current group if in one
- if (upload.groupId) {
- const group = this.groups.get(upload.groupId);
- if (group?.uploads) {
- group.uploads.delete(uploadId);
-
- // Delete empty group
- if (group.uploads.size === 0) {
- this.deleteGroup(upload.groupId, false);
- }
- }
- upload.groupId = null;
- }
-
- // Move back to preview
- if (field.ui?.preview) {
- field.ui.preview.appendChild(upload.element);
- upload.location = field.ui.preview;
- }
-
- // Hide featured radio
- const featured = upload.element.querySelector('[name="featured"]');
- if (featured) {
- featured.hidden = true;
- featured.checked = false;
- }
- }
-
- removeUpload(fieldId, uploadId) {
- const field = this.fields.get(fieldId);
- const upload = this.uploads.get(uploadId);
-
- if (!field || !upload) return;
-
- // Remove from field
- field.uploads?.delete(uploadId);
-
- // Remove from group if grouped
- if (upload.groupId) {
- const group = this.groups.get(upload.groupId);
- if (group && group.uploads) {
- group.uploads.delete(uploadId);
-
- if (group.uploads.size === 0) {
- this.removeGroup(upload.groupId);
- }
- }
- }
-
- // Clean up element
- upload.element?.remove();
-
- // Clean up memory
- this.clearUpload(uploadId);
-
- // Update field state after removal
- this.updateFieldState(fieldId);
-
- // Update UI
+ await this.clearUpload(uploadId);
+ this.updateHiddenInput(fieldId);
this.maybeLockUploads(fieldId);
- const handler = this.selectionHandlers.get(field.id);
+
+ let handler = this.selectionHandlers.get(fieldId);
if (handler) {
handler.deselect(uploadId);
}
@@ -2342,789 +1625,563 @@
this.a11y.announce('Upload removed');
}
- /*******************************************************************************
- * STATE MANAGEMENT
- *******************************************************************************/
- schedulePersistance(fieldId) {
- const key = `persist_${fieldId}`;
- window.debouncer.schedule(
- key,
- () => this.persistFieldState(fieldId),
- 1000
- );
+ 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);
}
- async persistFieldState(fieldId) {
+ /*******************************************************************************
+ 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();
+ }
+ if (this.selectionHandlers.get(group.field) && element && element.element) {
+ this.selectionHandlers.get(group.field).removeWrapper(element.element)
+ }
+
+ // Existing sortable cleanup
+ if (this.sortables.has(sortableKey)) {
+ 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??0;
+
+ field.ui.dropZone.hidden = max > 0 && 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;
- // Convert Sets to Arrays for storage
- const fieldData = {
- ...field,
- id: fieldId, // Use as primary key
- fieldId: fieldId,
- uploads: Array.from(field.uploads || []).map(uploadId => {
- return this.uploads.get(uploadId);;
- }),
- groups: Array.from(this.groups.entries())
- .filter(([id, data]) => data.fieldId === fieldId && data.uploads && data.uploads.size > 0)
- .map(([id, data]) => ({
- id: data.id,
- uploads: Array.from(data.uploads),
- changes: data.changes || {}
- })),
-
- // Context for restoration
- context: {
- url: this.normalizeUrl(window.location.href),
- fullUrl: window.location.href,
- modalType: this.getModalType(field),
- formId: field.formId,
- fieldSelector: `.field.upload[data-field="${field.config.name}"]`
- },
- timestamp: Date.now()
- };
-
- // Save to store
- await this.fieldStore.save(fieldData);
- }
- normalizeUrl(url) {
- try {
- const urlObj = new URL(url);
- // Return just the origin + pathname (no query string or hash)
- return urlObj.origin + urlObj.pathname;
- } catch (e) {
- return url;
+ if (!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);
}
- /**
- * Get uploads for a field, optionally cleaned for storage
- * @param {string} fieldId
- * @param {boolean} clean - Remove DOM references for IndexedDB storage
- * @returns {Array}
- */
- getFieldUploads(fieldId, clean = false) {
+ 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);
- if (!field || !field.uploads) return [];
+ const emptyZone = field?.groupUI?.empty;
+ if (!emptyZone) return;
- return Array.from(field.uploads)
- .map(uploadId => {
- const upload = this.uploads.get(uploadId);
- if (!upload) return null;
-
- if (clean) {
- // Return cleaned version without DOM references or blob URLs
- return {
- id: upload.id,
- fieldId: upload.fieldId,
- status: upload.status,
- // DON'T include preview (blob URL)
- // DON'T include originalFile or processedFile (in blob storage)
- attachmentId: upload.attachmentId,
- operationId: upload.operationId,
- groupId: upload.groupId || null,
- changes: upload.changes || {}, // ← ADD: Include changes
- meta: {
- originalName: upload.meta?.originalName || upload.originalFile?.name,
- size: upload.meta?.size || upload.originalFile?.size,
- type: upload.meta?.type || upload.originalFile?.type,
- title: upload.meta?.title,
- alt: upload.meta?.alt,
- caption: upload.meta?.caption
- }
- };
- }
-
- // Return full upload object
- return upload;
- })
- .filter(Boolean);
- }
-
- async checkForStoredUploads() {
- if (!this.db) return;
-
- const tx = this.db.transaction(['fieldStates'], 'readonly');
- const fieldStore = tx.objectStore('fieldStates');
-
- const allFieldStates = await new Promise(resolve => {
- const request = fieldStore.getAll();
- request.onsuccess = () => resolve(request.result);
+ emptyZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ e.dataTransfer.dropEffect = 'move';
+ emptyZone.classList.add('drag-over');
});
- //
- // allFieldStates.forEach(field => {
- // console.log(`Field ${field.fieldId} has ${field.uploads.length} uploads:`);
- // field.uploads.forEach((upload, idx) => {
- // console.log(` Upload ${idx}:`, {
- // id: upload.id,
- // status: upload.status,
- // operationId: upload.operationId,
- // hasOperationId: !!upload.operationId
- // });
- // });
- // });
-
- // Filter for pending uploads (not yet sent to server)
- const pendingFields = allFieldStates.filter(field =>
- field.uploads.some(upload =>
- // If no operationId, it hasn't been sent to server yet
- !upload.operationId &&
- // And it's been processed locally
- (upload.status === 'completed' ||
- upload.status === 'processed' ||
- upload.status === 'local_processing' ||
- upload.status === 'processed-original')
- )
- );
-
- if (pendingFields.length === 0) return;
-
- // Show recovery notification
- this.showRecoveryNotification(pendingFields);
- }
-
- async handleRestoreUploads() {
- let notification = document.querySelector('dialog.restore-uploads');
- if (!notification) {
- return;
- }
-
- const selectedUploads = this.getSelectedRestorationUploads(notification);
- if (selectedUploads.length === 0) {
- return;
- }
- await this.restoreSelectedUploads(selectedUploads);
-
- this.cleanupRestore();
- }
-
- getSelectedRestorationUploads(notificationEl) {
- let selected = [];
- const checkboxes = notificationEl.querySelectorAll('[type=checkbox]:checked');
-
- checkboxes.forEach(checkbox => {
- const item = checkbox.closest('.item');
- if (item) {
- selected.push({
- uploadId: item.dataset.uploadId,
- fieldId: item.dataset.fieldId
- });
+ emptyZone.addEventListener('dragleave', (e) => {
+ if (!emptyZone.contains(e.relatedTarget)) {
+ emptyZone.classList.remove('drag-over');
}
});
- return selected;
+ 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();
+ });
}
- handleGroupMetaChange(input) {
- let group = this.getGroupFromElement(input);
- if (!group) {
- return;
+ 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);
}
- if (!Object.hasOwn(group, 'changes')) {
- group.changes = {};
- }
- let name = input.name;
- if (name.includes('group')) {
- let replace = group.id+'_';
- let replace2 = group.id+'[';
- name = name.replace(replace, '').replace(replace2,'').replace(']', '');
- }
- group.changes[`${name}`] = input.value;
- this.groups.set(group.id, group);
- this.schedulePersistance(group.fieldId);
+
+ 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;
- /*******************************************************************************
- * RESTORING UPLOADS
- *******************************************************************************/
- async showRecoveryNotification(pendingFields) {
- const totalUploads = pendingFields.reduce((sum, field) => sum + field.uploads.length, 0);
- const totalGroups = pendingFields.reduce((sum, field) =>
- sum + (field.groups?.length || 0), 0);
-
- let notification = window.getTemplate('restoreNotification');
- if (!notification) {
- console.error('Restore notification template not found');
+ if (!target) {
+ console.log('Couldn\'t Reorder items...');
return;
}
- // Build appropriate message
- let message;
- if (totalGroups > 0) {
- let group = totalGroups > 1 ? 'groups' : 'group';
- let upload = totalUploads > 1 ? 'uploads' : 'upload';
- message = `${totalGroups} ${group} with ${totalUploads} ${upload} can be restored.`;
+ if (!groupId) {
+ this.updateHiddenInput(fieldId);
} else {
- message = `${totalUploads} upload(s) from ${pendingFields.length} field(s) can be recovered.`;
- }
+ 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);
- const detailsEl = notification.querySelector('.restore-details');
- if (detailsEl) {
- detailsEl.textContent = message;
- }
-
- // Build the restoration preview
- for (const field of pendingFields) {
- let fieldTemplate = window.getTemplate('restoreField');
- if (!fieldTemplate) continue;
-
- // Set field name/title
- const titleEl = fieldTemplate.querySelector('h3');
- if (titleEl) {
- titleEl.textContent = field.config.name || 'Unnamed Field';
- }
-
- const itemGrid = fieldTemplate.querySelector('.item-grid.restore');
-
- // Process each upload
- for (const upload of field.uploads) {
-
- let uploadItem = window.getTemplate('uploadItem');
- if (!uploadItem) continue;
- //
- // const imgEl = uploadItem.querySelector('img');
- // const placeholderEl = uploadItem.querySelector('.image-placeholder');
- //
- const blobData = await this.uploadStore.getBlob(upload.id);
-
-
- if (blobData) {
- try {
- // Create new blob URL from stored data
- const blob = new Blob([blobData.data], { type: blobData.type });
- const previewUrl = this.createPreviewUrl(blob);
-
- let [
- featured,
- img,
- video,
- preview,
- details
- ] = [
- uploadItem.querySelector('[name="featured"]'),
- uploadItem.querySelector('img'),
- uploadItem.querySelector('video'),
- uploadItem.querySelector('label > span'),
- uploadItem.querySelector('details')
- ];
-
- uploadItem.dataset.uploadId = upload.id;
-
-
- uploadItem.dataset.fieldId = field.id;
-
- let subtype = this.getSubtypeFromMime(blobData.type);
- uploadItem.dataset.subtype = subtype;
- switch (subtype) {
- case 'image':
- [
- img.src,
- img.alt
- ] = [
- previewUrl,
- upload.originalFile?.name ?? upload.meta?.originalName?? ''
- ];
- video.remove();
- preview.remove();
- break;
- case 'video':
- video.src = previewUrl;
- img.remove();
- preview.remove();
- break;
- case 'document':
- let extension = '';
- let icon;
- switch (extension) {
- case 'pdf':
- icon = window.getIcon('file-pdf');
- break;
- case 'csv':
- icon = window.getIcon('file-csv');
- break;
- case 'doc':
- icon = window.getIcon('file-doc');
- break;
- case 'txt':
- icon = window.getIcon('file-txt');
- break;
- case 'xls':
- icon = window.getIcon('file-xls');
- break;
- default:
- icon = window.getIcon('file');
- break;
- }
-
- preview.innerText = upload.originalFile.name;
- preview.prepend(icon);
- img.remove();
- video.remove();
- break;
- }
-
- // Store URL for cleanup later
- uploadItem.dataset.previewUrl = previewUrl;
- } catch (error) {
- console.warn('Failed to create preview for upload:', upload.id, error);
- }
- }
-
- // Set upload metadata
- const nameEl = uploadItem.querySelector('summary span');
- if (nameEl) {
- nameEl.textContent = upload.meta?.originalName || 'Unknown file';
- }
-
- const metaEl = uploadItem.querySelector('details');
- if (metaEl && upload.meta) {
- metaEl.textContent = `${this.formatBytes(upload.meta.size)} • ${upload.meta.type}`;
- }
-
- // Update input IDs safely
- uploadItem.querySelectorAll('input').forEach(input => {
- let id = input.id;
- if (id) {
- let newId = id + upload.id;
- let label = input.parentNode.querySelector(`label[for="${id}"]`);
- input.id = newId;
- if (label) {
- label.htmlFor = newId;
- }
- }
- });
-
- if (itemGrid) {
- itemGrid.appendChild(uploadItem);
- }
- }
-
- notification.querySelector('.wrap').appendChild(itemGrid);
- }
-
- document.querySelector('.field.upload').appendChild(notification);
- notification = document.querySelector('dialog.restore-uploads');
- this.restoreModal = new window.jvbModal(notification);
- this.restoreSelection = new window.jvbHandleSelection({
- container: notification,
- ui: {
- selectAll: notification.querySelector('#select-all-restore'),
- count: notification.querySelector('.selection-count'),
- },
- });
-
- this.restoreModal.handleOpen();
-
- }
-
- async restoreSelectedUploads(selectedUploads) {
- // Group by field
- const byField = new Map();
- selectedUploads.forEach(item => {
- if (!byField.has(item.fieldId)) {
- byField.set(item.fieldId, []);
- }
- byField.get(item.fieldId).push(item.uploadId);
- });
-
- // Get full field states from IndexedDB
- if (!this.db) {
- // this.notifications.add('Cannot restore: Database not available', 'error');
- return;
- }
-
- const tx = this.db.transaction(['fieldStates'], 'readonly');
- const store = tx.objectStore('fieldStates');
-
- for (const [fieldId, uploadIds] of byField.entries()) {
- const request = store.get(fieldId);
- const fieldState = await new Promise(resolve => {
- request.onsuccess = () => resolve(request.result);
- request.onerror = () => resolve(null);
- });
-
- if (fieldState) {
- // Filter to only selected uploads
- fieldState.uploads = fieldState.uploads.filter(u => uploadIds.includes(u.id));
- await this.restoreField(fieldState);
- }
- }
-
- // this.notifications.add(`Restored ${selectedUploads.length} upload(s)`, 'success');
- }
-
- async restoreField(fieldState) {
- const { config, context, uploads, groups, id } = fieldState; // ← Use 'id'
-
- // If in a modal, open it first
- if (context.modalType) {
- await this.openModalForRestore(context);
- }
-
- // Find field element
- let fieldElement = document.querySelector(`.field.upload[data-field="${config.name}"]`);
-
- if (!fieldElement) {
- const uploaderKey = `${config.content}_${config.itemID}_${config.name}`;
- fieldElement = document.querySelector(`.field.upload[data-uploader="${uploaderKey}"]`);
- }
-
- if (!fieldElement) {
- console.warn(`Field ${config.name} not found for restoration`, config);
- return;
- }
-
- // Register the field if not already registered
- let fieldKey = fieldElement.dataset.uploader;
- if (!fieldKey || !this.fields.has(fieldKey)) {
- fieldKey = this.registerUploader(fieldElement, config);
- }
-
- const field = this.fields.get(fieldKey);
- if (!field) {
- console.error('Failed to register field for restoration');
- return;
- }
-
- // Merge saved state back into field
- field.state = fieldState.state || 'ready';
-
- // Rebuild UI references
- field.ui = this.buildFieldUI(fieldElement);
-
- if (field.ui.groups?.display) {
- field.ui.groups.display.hidden = false;
- }
-
- // Restore groups
- if (groups && groups.length > 0) {
- await this.restoreGroups(fieldKey, groups);
- }
-
- // Restore uploads
- for (const uploadData of uploads) {
- await this.restoreUpload(field, uploadData);
- }
-
- // Update UI
- this.updateFieldState(fieldKey);
- this.maybeLockUploads(fieldKey);
-
- // Queue for upload if needed
- if (config.mode === 'direct' && config.destination !== 'post_group') {
- await this.queueUpload(fieldKey);
- }
- }
-
- async restoreUpload(field, uploadData) {
- // Try to get blob data from IndexedDB
- const blobData = await this.uploadStore.getBlob(uploadData.id);
-
- if (blobData) {
- const file = blobData.data instanceof File
- ? blobData.data
- : new File(
- [blobData.data],
- blobData.name,
- { type: blobData.type, lastModified: blobData.lastModified }
- );
-
- uploadData.originalFile = file;
- uploadData.processedFile = file;
- uploadData.preview = this.createPreviewUrl(file);
- } else {
- console.warn('Blob data not found for upload:', uploadData.id);
- return; // Skip this upload if we can't restore the file
- }
-
- // Add to field
- if (!field.uploads) field.uploads = new Set();
- field.uploads.add(uploadData.id);
-
- // Recreate DOM element
- const subtype = this.getSubtypeFromMime(uploadData.originalFile.type);
- uploadData.element = this.createUploadElement({
- ...uploadData,
- subtype: subtype
- }, field.config.destination === 'post_group');
-
- // Restore to correct location
- let location;
- if (uploadData.groupId && field.ui.groups.groups.has(uploadData.groupId)) {
- location = field.ui.groups.groups.get(uploadData.groupId).querySelector('.item-grid');
- } else {
- location = field.ui.preview;
- }
-
- if (location) {
- location.appendChild(uploadData.element);
- uploadData.location = location;
- }
-
- // Store in memory
- this.uploads.set(uploadData.id, uploadData);
- if (uploadData.groupId) {
- const group = this.groups.get(uploadData.groupId);
- if (group && group.uploads) {
- group.uploads.add(uploadData.id);
- }
- }
- }
-
- async restoreGroups(fieldKey, groups) {
- for (const groupData of groups) {
- // Use createGroup which properly initializes EVERYTHING including selection handlers
- const group = this.createGroup(fieldKey, groupData.id);
-
+ let group = this.stores.groups.get(groupId);
if (group) {
- // Update the group metadata from saved state
- if (groupData.meta) {
- group.meta = { ...groupData.meta };
- }
- if (groupData.changes) {
- group.changes = { ...groupData.changes };
- }
-
-
- // If you saved group titles, restore them
- if (groupData.title) {
- const titleInput = group.element.querySelector('[name*="post_title"]');
- if (titleInput) {
- titleInput.value = groupData.title;
- }
- }
+ group.uploads = items;
+ this.stores.groups.save(group).then(()=>{});
}
}
+
+ this.a11y.announce('Items reordered');
}
-
- async openModalForRestore(context) {
- const { modalType, formId } = context;
-
- // Find and click the appropriate button to open the modal
- let trigger = null;
-
- switch(modalType) {
- case 'create':
- trigger = document.querySelector('[data-action="create"]');
- break;
- case 'edit':
- // Need to find the specific edit button
- trigger = document.querySelector(`[data-action="edit"][data-id="${context.itemId}"]`);
- break;
- case 'bulkEdit':
- trigger = document.querySelector('[data-action="bulk-edit"]');
- break;
- }
-
- if (trigger) {
- trigger.click();
-
- // Wait for modal to open
- await new Promise(resolve => setTimeout(resolve, 300));
- }
- }
-
/*******************************************************************************
- INDEXEDDB CACHE FUNCTIONALITY
+ * EVENT SYSTEM
*******************************************************************************/
- handleFieldStoreEvent(event, data) {
- switch(event) {
- case 'data-loaded':
- break;
- case 'item-saved':
- console.log(`Field state saved: ${data.key}`);
- break;
- }
- }
-
- handleUploadStoreEvent(event, data) {
- switch(event) {
- case 'data-loaded':
- this.checkForStoredUploads();
- break;
- case 'item-saved':
- this.showSaveIndicator(data.key);
- break;
- }
- }
- async saveUpload(upload) {
- // Use the processed file if available, otherwise original
- const fileToStore = upload.processedFile || upload.originalFile || upload.file;
-
- if (fileToStore instanceof File || fileToStore instanceof Blob) {
- await this.uploadStore.saveBlob(upload.id, fileToStore);
-
- // Don't store file objects in main store
- const { file, originalFile, processedFile, ...cleanUpload } = upload;
- await this.uploadStore.save(cleanUpload);
- } else {
- await this.uploadStore.save(upload);
- }
- }
-
- async loadFields() {
- // Load all field states from the store
- const fields = await this.fieldStore.getAll();
-
- fields.forEach(field => {
- // Reconstruct upload sets
- if (field.uploads && Array.isArray(field.uploads)) {
- field.uploads = new Set(field.uploads.map(u => u.id));
- }
- this.fields.set(field.fieldId, field);
- });
- }
-
- async loadUploads() {
- const uploads = await this.uploadStore.getAll();
- uploads.forEach(upload => {
- this.uploads.set(upload.id, upload);
- });
- }
-
- /**************************************************************************
- SUBSCRIBERS
- **************************************************************************/
- /**
- * Event system
- */
subscribe(callback) {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
- notify(event, data) {
- this.subscribers.forEach(cb => cb(event, data));
+ notify(event, data = {}) {
+ this.subscribers.forEach(cb => {
+ try { cb(event, data); } catch (e) { console.error('Subscriber error:', e); }
+ });
}
- /*******************************************************************************
- * CLEANUP
- *******************************************************************************/
-
+ /********************************************************************
+ CLEANUP
+ ********************************************************************/
destroy() {
- // Remove core listeners
- document.removeEventListener('click', this.clickHandler);
- document.removeEventListener('change', this.changeHandler);
- document.removeEventListener('dragenter', this.dragEnterHandler);
- document.removeEventListener('dragleave', this.dragLeaveHandler);
- document.removeEventListener('dragover', this.dragOverHandler);
- document.removeEventListener('drop', this.dropHandler);
+ this.subscribers.clear();
+ this.previewUrls.forEach(url => {
+ this.revokePreviewUrl(url);
+ });
+ this.previewUrls.clear();
+ }
- // Destroy drag controller
- if (this.dragController) {
- this.dragController.destroy();
+ 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();
}
- // Destroy selection handlers
- this.selectionHandlers.forEach(handler => handler.destroy());
- this.selectionHandlers.clear();
-
- this.cleanupAllPreviewUrls();
- this.sortableInstances.forEach(instance => {
- if (instance?.destroy) {
- instance.destroy();
- }
- });
- this.sortableInstances.clear();
-
- // Clear data
- this.fields.clear();
- this.uploads.clear();
- this.groups.clear();
- this.selected.clear();
- this.subscribers.clear();
- }
-
- destroySortable(fieldName) {
- // Destroy all sortable instances for this field
- const instances = Array.from(this.sortableInstances.keys())
- .filter(key => key.startsWith(fieldName));
-
- instances.forEach(key => {
- const instance = this.sortableInstances.get(key);
- if (instance?.destroy) {
- instance.destroy();
- }
- this.sortableInstances.delete(key);
- });
- }
-
- cleanupRestore() {
- this.restoreModal.handleClose();
- this.restoreSelection.destroy();
- this.restoreSelection = null;
- this.restoreModal.destroy();
- this.restoreModal.modal.remove();
- this.restoreModal = null;
- }
-
- async cleanupStoredUploads() {
- this.fieldStore.clear();
- this.uploadStore.clear();
+ this.a11y.announce('Cache cleared for this page');
}
/**
- * Clear all uploads for a field and cleanup resources
+ * Get files from all upload fields in a form
+ * Returns array of {file, fieldName, uploadId, meta}
*/
- async clearField(fieldId) {
- // Clear from stores
- await this.fieldStore.delete(fieldId);
+ async getFilesForForm(formElement) {
+ const uploadFields = formElement.querySelectorAll(this.selectors.fields.field);
+ const allFiles = [];
- // Clear related uploads
- const field = this.fields.get(fieldId);
- if (field?.uploads) {
- for (const uploadId of field.uploads) {
- await this.uploadStore.delete(uploadId);
+ 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 || {}
+ });
+ }
}
}
- // Clear from memory
- this.fields.delete(fieldId);
+ return allFiles;
}
- async clearUpload(uploadId, persist = true) {
- const upload = this.uploads.get(uploadId);
- if (!upload) return;
+ /**
+ * 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 });
- // Clean up preview URL using helper
- this.revokePreviewUrl(upload.preview);
+ // Clear all uploads
+ await Promise.all(
+ uploads.map(upload => this.clearUpload(upload.id))
+ );
- // Clean up element preview URL
- if (upload.element) {
- const previewUrl = upload.element.dataset.previewUrl;
- this.revokePreviewUrl(previewUrl);
- delete upload.element.dataset.previewUrl;
- }
-
- if (persist) {
- await this.schedulePersistance(upload.fieldId);
- }
-
- // Remove from memory
- this.uploads.delete(uploadId);
-
- // Remove from IndexedDB
- this.uploadStore.delete(uploadId);
- this.uploadStore.delete(uploadId, 'blobs');
- }
- cleanupAllPreviewUrls() {
- if (this.previewUrls) {
- this.previewUrls.forEach(url => {
- try {
- URL.revokeObjectURL(url);
- } catch (e) {
- // Ignore errors during cleanup
- }
- });
- this.previewUrls.clear();
- }
+ // 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);
+ })
+ );
}
}
-// Initialize when DOM is ready
-document.addEventListener('DOMContentLoaded', () => {
- window.jvbUploads = new UploadManager();
+document.addEventListener('DOMContentLoaded', async function () {
+ window.auth.subscribe((event) => {
+ if (event === 'auth-loaded') {
+ window.jvbUploads = new UploadManager();
+ }
+ });
});
--
Gitblit v1.10.0