| | |
| | | /** |
| | | * QueueManager |
| | | * Uses DataStore for persistent storage |
| | | */ |
| | | class QueueManager { |
| | | constructor(config = {}) { |
| | | this.canUpdateUI = true; |
| | | console.log('jvbSettings', jvbSettings); |
| | | this.config = { |
| | | apiBase: jvbSettings.api, |
| | | maxRetries: 3, |
| | | pollInterval: 5000, |
| | | activityDelay: 2000, //2 seconds |
| | | autosync: true, |
| | | endpoint: 'queue', |
| | | ...config |
| | | }; |
| | | this.user = jvbSettings.currentUser; |
| | | |
| | | |
| | | this.headers = { |
| | | 'X-WP-Nonce': jvbSettings.nonce, |
| | | ...config.headers |
| | | }; |
| | | |
| | | constructor() { |
| | | this.a11y = window.jvbA11y; |
| | | this.errors = window.jvbError; |
| | | this.error = window.jvbError; |
| | | |
| | | // Initialize DataStore for queue persistence |
| | | this.store = new window.jvbStore({ |
| | | name: 'queue', |
| | | endpoint: this.config.endpoint, |
| | | useIndexedDB: true, |
| | | TTL: Infinity, //Queue data doesn't expire, |
| | | showLoading: false |
| | | }); |
| | | this.user = window.auth.getUser(); |
| | | |
| | | this.queue = new Map(); |
| | | if (!this.user) { |
| | | return; |
| | | } |
| | | |
| | | this.classes = [ |
| | | 'offline', |
| | | 'synced', |
| | | 'pending' |
| | | ]; |
| | | |
| | | // Queue state |
| | | this.canUpdateUI = true; |
| | | this.isProcessing = false; |
| | | this.isPolling = false; |
| | | this.queue = new Map(); |
| | | this.items = new Map(); |
| | | this.subscribers = new Set(); |
| | | this.loadFromStorage = false; |
| | | |
| | | // Status definitions |
| | | this.statuses = [ |
| | | 'queued', |
| | | 'localProcessing', |
| | | 'uploading', |
| | | 'pending', |
| | | 'processing', |
| | | 'completed', |
| | | 'failed', |
| | | 'failed_permanent' |
| | | ]; |
| | | this.api = jvbSettings.api; |
| | | this.endpoint = 'queue'; |
| | | |
| | | // Initialize |
| | | this.initUI(); |
| | | this.init(); |
| | | } |
| | | init() { |
| | | this.headers = { |
| | | 'X-WP-Nonce': window.auth.getNonce(), |
| | | }; |
| | | this.initElements(); |
| | | this.initListeners(); |
| | | this.initQueue(); |
| | | |
| | | if (this.user) { |
| | | this.ui.toggle.hidden = false; |
| | | this.ui.panel.hidden = false; |
| | | this.initStore(); |
| | | if (this.canUpdateUI && this.ui.panel) { |
| | | this.popup = window.jvbPopup.registerPopup({ |
| | | popup: this.ui.panel, |
| | | toggle: this.ui.toggle.button, |
| | | name: 'Queue Panel', |
| | | }); |
| | | } |
| | | this.defineTemplates(); |
| | | } |
| | | |
| | | async initQueue() { |
| | | const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false) |
| | | initElements() { |
| | | this.panelStatuses = ['syncing', 'synced', 'pending', 'offline']; |
| | | this.statuses = ['queued', 'localProcessing', 'uploading', 'pending', 'processing', 'completed', 'failed', 'failed_permanent']; |
| | | this.pendingStatuses = ['queued', 'localProcessing', 'uploading']; |
| | | this.workingStatuses = ['pending','processing']; |
| | | this.completedStatuses = ['completed', 'failed', 'failed_permanent']; |
| | | |
| | | if (incomplete.length > 0) { |
| | | this.startPolling(); |
| | | } else { |
| | | this.updateStatusPanel('synced'); |
| | | this.icons = { |
| | | queued: 'arrows-clockwise', localProcessing: 'arrows-clockwise', uploading: 'syncing', |
| | | pending: 'cloud', processing: 'syncing', completed: 'cloud-check', |
| | | failed: 'cloud-warning', failed_permanent: 'cloud-warning' |
| | | }; |
| | | this.selectors = { |
| | | panel: 'aside#queue', |
| | | toggle: { |
| | | button: 'button.qtoggle', |
| | | indicator: '.qtoggle .indicator', |
| | | count: '.qtoggle .count' |
| | | }, |
| | | refresh: { |
| | | button: '#queue .m-actions .refresh', |
| | | countdown: '#queue .m-actions .refresh .countdown' |
| | | }, |
| | | popup: { |
| | | popup: '#queue .popup', |
| | | message: '#queue .popup span' |
| | | }, |
| | | items: { |
| | | container: '#queue .qitems', |
| | | }, |
| | | actions: { |
| | | retry: '#queue .retry-all', |
| | | clear: '#queue .dismiss-all' |
| | | }, |
| | | filters: { |
| | | filter: '#queue [data-filter]', |
| | | all: { |
| | | label: '#queue [for="qfilter-all"]', |
| | | radio: '#queue [data-filter="all"]', |
| | | count: '#queue [data-filter="all"] .count' |
| | | }, |
| | | queued: { |
| | | label: '#queue [for="qfilter-queued"]', |
| | | input: '#queue [data-filter="queued"]', |
| | | count: '#queue [for="qfilter-queued"] .count' |
| | | }, |
| | | localProcessing: { |
| | | label: '#queue [for="qfilter-localProcessing"]', |
| | | input: '#queue [data-filter="localProcessing"]', |
| | | count: '#queue [for="qfilter-localProcessing"] .count', |
| | | }, |
| | | uploading: { |
| | | label: '#queue [for="qfilter-uploading"]', |
| | | input: '#queue [data-filter="uploading"]', |
| | | count: '#queue [for="qfilter-uploading"] .count', |
| | | }, |
| | | pending: { |
| | | label: '#queue [for="qfilter-pending"]', |
| | | input: '#queue [data-filter="pending"]', |
| | | count: '#queue [for="qfilter-pending"] .count', |
| | | }, |
| | | processing: { |
| | | label: '#queue [for="qfilter-processing"]', |
| | | input: '#queue [data-filter="processing"]', |
| | | count: '#queue [for="qfilter-processing"] .count', |
| | | }, |
| | | completed: { |
| | | label: '#queue [for="qfilter-completed"]', |
| | | input: '#queue [data-filter="completed"]', |
| | | count: '#queue [for="qfilter-completed"] .count', |
| | | }, |
| | | failed: { |
| | | label: '#queue [for="qfilter-failed"]', |
| | | input: '#queue [data-filter="failed"]', |
| | | count: '#queue [for="qfilter-failed"] .count', |
| | | }, |
| | | }, |
| | | item: { |
| | | type: '.type', |
| | | status: '.status', |
| | | details: '.info .details', |
| | | icon: '.status .icon', |
| | | startedAt: '.started time', |
| | | completed: { |
| | | wrap: '.completed', |
| | | label: '.completed span', |
| | | time: '.completed time', |
| | | }, |
| | | progress: { |
| | | progress: '.progress', |
| | | fill: '.progress .fill', |
| | | details: '.progress .details', |
| | | icon: '.progress .icon' |
| | | }, |
| | | actions: { |
| | | cancel: 'button.cancel', |
| | | retry: 'button.retry', |
| | | refresh: 'button.refresh', |
| | | dismiss: 'button.dismiss', |
| | | } |
| | | }, |
| | | }; |
| | | this.ui = window.uiFromSelectors(this.selectors); |
| | | if (!this.ui.panel) this.canUpdateUI = false; |
| | | } |
| | | |
| | | defineTemplates() { |
| | | const T = window.jvbTemplates; |
| | | |
| | | T.define('emptyState'); |
| | | T.define('queueItem', { |
| | | setup({el, refs, manyRefs, data}) { |
| | | el.dataset.id = data.id; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | |
| | | initListeners() { |
| | | this.activityListeners = null; |
| | | this.clickHandler = this.handleClick.bind(this); |
| | | this.onlineHandler = this.handleOnline.bind(this); |
| | | this.offlineHandler = this.handleOffline.bind(this); |
| | | this.unloadHandler = this.handleBeforeUnload.bind(this); |
| | | this.visibilityHandler = this.handleVisibilityChange.bind(this); |
| | | |
| | | document.addEventListener('click', this.clickHandler); |
| | | window.addEventListener('online', this.onlineHandler); |
| | | window.addEventListener('offline', this.offlineHandler); |
| | | |
| | | // window.addEventListener('beforeunload', this.unloadHandler); |
| | | |
| | | document.addEventListener('visibilitychange', this.visibilityHandler); |
| | | } |
| | | handleOnline() { |
| | | this.updatePanel('synced'); |
| | | if (this.getQueueByStatus(this.pendingStatuses).length > 0) { |
| | | this.processQueue(); |
| | | } |
| | | } |
| | | handleOffline() { |
| | | this.updatePanel('offline'); |
| | | } |
| | | |
| | | this.store.subscribe((event, data) => { |
| | | switch (event) { |
| | | case 'data-fetched': |
| | | case 'data-cached': |
| | | this.updateOperationsFromServer(data.data.items); |
| | | break; |
| | | case 'items-updated': |
| | | this.updateOperationsFromServer(data.items); |
| | | break; |
| | | case 'item-stored': |
| | | this.updateOperationsFromServer([data]) |
| | | break; |
| | | handleVisibilityChange(e) { |
| | | if (this.isPolling && document.hidden) { |
| | | this.stopPolling(); |
| | | } else { |
| | | this.maybeStartPolling(); |
| | | } |
| | | } |
| | | handleBeforeUnload(e) { |
| | | if (!this.ui.panel) return; |
| | | const total = this.getQueueByStatus(this.pendingStatuses).length; |
| | | if (total > 0) { |
| | | // Modern browsers ignore custom messages, but this triggers the native dialog |
| | | e.preventDefault(); |
| | | e.returnValue = ''; // Required for Chrome |
| | | return ''; // Required for some older browsers |
| | | } |
| | | } |
| | | handleClick(e) { |
| | | if (!window.targetCheck(e, this.selectors.panel+', '+this.selectors.toggle.button)) return; |
| | | const refresh = window.targetCheck(e, this.selectors.refresh.button); |
| | | if (refresh) { |
| | | this.ui.refresh.button.classList.add('fetching'); |
| | | this.store.clearCache(); |
| | | this.store.clearFilters(); |
| | | this.store.fetch().finally(() => { |
| | | this.ui.refresh.button.classList.remove('fetching'); |
| | | }); |
| | | return; |
| | | } |
| | | |
| | | |
| | | const refreshPage = window.targetCheck(e, this.selectors.actions.refresh); |
| | | if (refreshPage) { |
| | | this.handleRefresh(opId); |
| | | return; |
| | | } |
| | | |
| | | const clear = window.targetCheck(e, this.selectors.actions.clear); |
| | | if (clear) { |
| | | this.opActions('completed', 'dismiss').then(()=>{}); |
| | | return; |
| | | } |
| | | |
| | | const retry = window.targetCheck(e, this.selectors.actions.retry); |
| | | if (retry) { |
| | | this.opActions('failed', 'retry').then(()=>{}); |
| | | return; |
| | | } |
| | | |
| | | const action = window.targetCheck(e, '[data-action]'); |
| | | if (action) { |
| | | const opId = action.closest('[data-id]')?.dataset.id; |
| | | if (opId) { |
| | | this.opActions(opId, action.dataset.action); |
| | | } |
| | | return; |
| | | } |
| | | |
| | | const filter = window.targetCheck(e, this.selectors.filters.filter); |
| | | if (filter) { |
| | | this.setFilter(filter.dataset.filter); |
| | | } |
| | | } |
| | | |
| | | setFilter(filter) { |
| | | // Update active button |
| | | Object.values(this.ui.filters).forEach(filterObj => { |
| | | if (filterObj.input?.dataset.filter === filter) { |
| | | filterObj.input.checked = true; |
| | | } |
| | | }); |
| | | |
| | | this.store.fetch(); |
| | | this.notify('queue-initialized', {operations: incomplete}); |
| | | if (filter === 'all') { |
| | | this.store.clearFilters(); |
| | | } else { |
| | | this.store.setFilter('status', filter); |
| | | } |
| | | } |
| | | |
| | | trackActivity() { |
| | | if (!this.activityListeners) { |
| | | const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart']; |
| | | this.activityListeners = events.map(event => { |
| | | const handler = () => this.resetActivityTimer(); |
| | | document.addEventListener(event, handler, {passive: true}); |
| | | return {event, handler}; |
| | | }); |
| | | } |
| | | this.resetActivityTimer(); |
| | | } |
| | | resetActivityTimer() { |
| | | if (this.activityTimer) { |
| | | clearTimeout(this.activityTimer); |
| | | } |
| | | this.activityTimer = setTimeout(() => { |
| | | this.processQueue(); |
| | | }, 1750); |
| | | } |
| | | stopActivityTracking() { |
| | | if (this.activityTimer) { |
| | | clearTimeout(this.activityTimer); |
| | | this.activityTimer = null; |
| | | } |
| | | if (this.activityListeners) { |
| | | this.activityListeners.forEach(({event, handler}) => { |
| | | document.removeEventListener(event, handler); |
| | | }); |
| | | this.activityListeners = null; |
| | | } |
| | | } |
| | | |
| | | initStore() { |
| | | if (!this.user) return; |
| | | const store = window.jvbStore.register( |
| | | 'queue', |
| | | { |
| | | storeName: 'queue', |
| | | keyPath: 'id', |
| | | endpoint: this.endpoint, |
| | | TTL: Infinity, |
| | | isAuth: true, |
| | | indexes: [ |
| | | {name: 'status', keyPath: 'status'}, |
| | | {name: 'type', keyPath: 'type'}, |
| | | ], |
| | | filters: { |
| | | user: window.auth.getUser() |
| | | }, |
| | | showLoading: false, |
| | | } |
| | | ) |
| | | this.store = store.queue; |
| | | this.store.subscribe((event, data) => { |
| | | switch (event) { |
| | | case 'data-loaded': |
| | | const serverOps = this.store.getAll(); |
| | | |
| | | serverOps.forEach(serverOp => { |
| | | const localOp = this.queue.get(serverOp.id); |
| | | const mapped = this.mapServerOperation(serverOp); |
| | | |
| | | this.queue.set(mapped.id, mapped); |
| | | |
| | | // Notify if changed |
| | | if (localOp && localOp.status !== mapped.status) { |
| | | this.notify('operation-status', mapped); |
| | | } |
| | | }); |
| | | |
| | | this.maybeStartPolling(); |
| | | this.updateUI(); |
| | | break; |
| | | |
| | | case 'items-save': |
| | | this.maybeStartPolling(); |
| | | this.updateUI(); |
| | | break; |
| | | |
| | | case 'item-saved': |
| | | if (data.item) { |
| | | this.queue.set(data.item.id, data.item); |
| | | if (data.previousItem?.status !== data.item.status) { |
| | | this.notify('operation-status', data.item); |
| | | } |
| | | } |
| | | this.maybeStartPolling(); |
| | | break; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * |
| | | * @param {object} operation |
| | | * @param {string} operation.endpoint The endpoint, excluding the apiBase |
| | | * @param {object} operation.data The data to save |
| | | * @param {boolean} operation.canMerge Whether data can merge |
| | | * @param {string} operation.title The title of the operation for the Queue Panel |
| | | * @param {string} operation.popup The string to show in the popup |
| | | * @param {object} operation.headers Optional additional headers. Defaults to the API nonce |
| | | * |
| | | * @returns {string|null} Returns the operation id, for reference |
| | | * Handle refresh button click - clears cache for the relevant store |
| | | */ |
| | | handleRefresh(opId) { |
| | | const op = this.getQueue(opId); |
| | | if (!op) return; |
| | | |
| | | // Determine which store to refresh based on operation type |
| | | let storeName = null; |
| | | |
| | | // Map operation types to store names |
| | | const typeToStore = { |
| | | 'content_update': op.data?.posts ? Object.values(op.data.posts)[0]?.content : null, |
| | | 'batch_creation': op.data?.content, |
| | | 'image_upload': 'uploads', |
| | | 'video_upload': 'uploads', |
| | | 'document_upload': 'uploads', |
| | | }; |
| | | |
| | | storeName = typeToStore[op.type]; |
| | | |
| | | // If we found a store name, clear its cache |
| | | if (storeName && window.jvbStore) { |
| | | const store = window.jvbStore.stores.get(storeName); |
| | | if (store) { |
| | | window.jvbStore.clearCache(storeName); |
| | | window.jvbStore.fetch(storeName); |
| | | |
| | | // Give visual feedback |
| | | const button = this.items.get(opId)?.ui?.actions?.refresh; |
| | | if (button) { |
| | | const originalText = button.querySelector('span').textContent; |
| | | button.querySelector('span').textContent = 'Refreshed!'; |
| | | button.disabled = true; |
| | | |
| | | setTimeout(() => { |
| | | button.querySelector('span').textContent = originalText; |
| | | button.disabled = false; |
| | | }, 2000); |
| | | } |
| | | } |
| | | } else { |
| | | // Fallback: just reload the page if we can't determine the store |
| | | if (confirm('Refresh the page to see changes?')) { |
| | | window.location.reload(); |
| | | } |
| | | } |
| | | } |
| | | /**************************************************************************** |
| | | OPERATIONS |
| | | ****************************************************************************/ |
| | | addToQueue(operation) { |
| | | const item = { |
| | | id: `u${this.user}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, |
| | |
| | | method: 'POST', |
| | | headers: {}, |
| | | data: {}, |
| | | delay: false, |
| | | canMerge: true, |
| | | popup: 'Saving changes...', |
| | | title: 'Operation', |
| | | status: 'queued', |
| | | timestamp: Date.now(), |
| | | created_at: new Date().toISOString(), |
| | | retries: 0, |
| | | user: this.user, |
| | | ... operation |
| | | }; |
| | | |
| | | item.headers = { |
| | | ...this.headers, |
| | | ...item.headers |
| | | }; |
| | | ... this.headers, |
| | | ... item.headers |
| | | } |
| | | if (!item.endpoint || !item.data) return null; |
| | | |
| | | if (!item.endpoint || !item.data) { |
| | | console.error('Invalid operation queued: missing endpoint or data'); |
| | | return null; |
| | | if (item.popup && this.ui.popup?.message) { // Add popup support |
| | | this.ui.popup.message.textContent = item.popup; |
| | | this.ui.popup.popup.hidden = false; |
| | | setTimeout(() => this.ui.popup.popup.hidden = true, 2000); |
| | | } |
| | | |
| | | const existingOps = Array.from(this.queue.values()).filter(op=> |
| | | op.status === 'queued' && |
| | | op.endpoint === item.endpoint && |
| | | op.canMerge |
| | | ); |
| | | if (!item.delay) { |
| | | this.queue.set(item.id, item); |
| | | this.processOperation(item).then(()=> {}); |
| | | this.store.clearCache(); |
| | | this.maybeStartPolling(); |
| | | this.toggleQueue(); |
| | | return item.id; |
| | | } |
| | | |
| | | const existingOps = Array.from(this.getAllQueue()).filter(op=> { |
| | | return op.status === 'queued' && |
| | | op.endpoint === item.endpoint && |
| | | op.canMerge |
| | | }); |
| | | if (existingOps.length > 0) { |
| | | const existing = existingOps[0]; |
| | | existing.data = window.deepMerge(existing.data, item.data); |
| | | existing.timestamp = Date.now(); |
| | | |
| | | this.setQueue(existing); |
| | | |
| | | this.updateOperationStatus(existing.id, existing.status); |
| | | this.updateUI(); |
| | | |
| | | this.startActivityTracking(); |
| | | this.trackActivity(); |
| | | return existing.id; |
| | | } |
| | | |
| | | console.log('Added to Queue: ', item); |
| | | |
| | | //Add new operation to DataStore |
| | | this.store.clearCache(); |
| | | this.setQueue(item); |
| | | |
| | | this.updateOperationStatus(item.id, item.status); |
| | | this.updateUI(); |
| | | |
| | | this.startActivityTracking(); |
| | | this.trackActivity(); |
| | | return item.id; |
| | | |
| | | |
| | | } |
| | | |
| | | setQueue(item) { |
| | | this.queue.set(item.id, item); |
| | | this.store.setItem(item.id, item); |
| | | } |
| | | |
| | | updateOperationStatus(itemID, status) { |
| | | let item = this.queue.get(itemID); |
| | | if (!item){ |
| | | return; |
| | | async opActions(statusOrId, action) { |
| | | //Extract ids based on status, if it exists |
| | | if (this.statuses.includes(statusOrId)) { |
| | | statusOrId = this.getQueueByStatus(statusOrId).map(op => op.id); |
| | | } else if (typeof statusOrId === 'string') { |
| | | //If it's still a string, wrap the id inside an array |
| | | statusOrId = [statusOrId]; |
| | | } |
| | | item.status = status; |
| | | this.notify('operation-status', item); |
| | | this.updateOperationUI(item); |
| | | } |
| | | if (statusOrId.length ===0) return; |
| | | if (!['cancel', 'dismiss', 'retry'].includes(action)) return; |
| | | |
| | | getQueue(itemID) { |
| | | if (this.queue.has(itemID)) { |
| | | return this.queue.get(itemID); |
| | | } |
| | | return this.store.getItem(itemID); |
| | | } |
| | | |
| | | clearQueue(itemID) { |
| | | if (this.queue.has(itemID)) { |
| | | this.queue.delete(itemID); |
| | | } |
| | | this.store.clearItem(itemID); |
| | | } |
| | | |
| | | startActivityTracking() { |
| | | if (!this.activityListeners) { |
| | | const activityEvents = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart']; |
| | | this.activityListeners = activityEvents.map(event => { |
| | | const handler = () => this.resetActivityTimer(); |
| | | document.addEventListener(event, handler, {passive: true}); |
| | | return {event, handler}; |
| | | const shouldRemove = ['cancel', 'dismiss'].includes(action); |
| | | if (shouldRemove) { |
| | | statusOrId.forEach(id => { |
| | | this.removeOperationUI(id) |
| | | }); |
| | | } |
| | | this.resetActivityTimer(); |
| | | } |
| | | |
| | | resetActivityTimer() { |
| | | this.lastActivity = Date.now(); |
| | | try { |
| | | const response = await window.auth.fetch( |
| | | `${this.api}${this.endpoint}`, |
| | | { |
| | | method: 'POST', |
| | | headers: { |
| | | 'Content-Type': 'application/json', |
| | | ... this.headers |
| | | }, |
| | | body: JSON.stringify({ |
| | | action, |
| | | ids: Array.isArray(statusOrId) ? statusOrId : [statusOrId], |
| | | user: this.user |
| | | }) |
| | | } |
| | | ); |
| | | if (!response.ok) { |
| | | throw new Error(`${action} failed: ${response.status}`); |
| | | } |
| | | const result = await response.json(); |
| | | if (!result.success) { |
| | | throw new Error(result.message || `${action} operation failed`); |
| | | } |
| | | statusOrId.forEach(id => { |
| | | let item = this.getQueue(id); |
| | | if (item) { |
| | | this.notify(`${action}-operation`, item); |
| | | } |
| | | |
| | | if (this.activityTimer) { |
| | | clearTimeout(this.activityTimer); |
| | | } |
| | | |
| | | this.activityTimer = setTimeout(() => { |
| | | this.processQueue(); |
| | | }, this.config.activityDelay); |
| | | } |
| | | |
| | | stopActivityTracking() { |
| | | if (this.activityTimer) { |
| | | clearTimeout(this.activityTimer); |
| | | this.activityTimer = null; |
| | | } |
| | | if (this.activityListeners) { |
| | | this.activityListeners.forEach(({event, handler}) => { |
| | | document.removeEventListener(event, handler); |
| | | if (shouldRemove) { |
| | | this.clearQueue(id); |
| | | } else { |
| | | let item = this.getQueue(id); |
| | | item.status = 'queued'; |
| | | this.setQueue(item); |
| | | this.updateOperationStatus(item.id, item.status); |
| | | } |
| | | }); |
| | | this.activityListeners = null; |
| | | |
| | | if (action === 'retry') { |
| | | this.trackActivity(); |
| | | } |
| | | this.updateUI(); |
| | | return result; |
| | | } catch (error) { |
| | | await window.jvbError.log(error, { |
| | | component: 'Queue', |
| | | operation: 'performQueueAction', |
| | | action: action, |
| | | operationIds: statusOrId, |
| | | itemCount: statusOrId.length |
| | | }, () => this.opActions(statusOrId, action)); |
| | | return {success: false, error: error.message}; |
| | | } |
| | | } |
| | | |
| | | setProcessing(on) { |
| | | this.isProcessing = on; |
| | | this.ui.toggle.classList.toggle('saving', on); |
| | | } |
| | | /** |
| | | * Send any queued operations to the server |
| | | * @returns {Promise<void>} |
| | | */ |
| | | |
| | | async processQueue() { |
| | | if (this.isProcessing) return; |
| | | |
| | | const queue = this.getOperationsByStatus('queued'); |
| | | const queue = this.getQueueByStatus('queued'); |
| | | |
| | | if (queue.length === 0) { |
| | | this.stopActivityTracking(); |
| | | return; |
| | | } |
| | | this.setProcessing(true); |
| | | this.setProcessing(); |
| | | |
| | | for (const operation of queue) { |
| | | await this.processOperation(operation); |
| | | } |
| | | |
| | | this.setProcessing(false); |
| | | this.stopActivityTracking(); |
| | | |
| | | const pending = this.getOperationsByStatus(['queued', 'completed', 'failed_permanent'], false); |
| | | if (pending.length > 0) { |
| | | this.startPolling(); |
| | | const remainingQueue = this.getQueueByStatus('queued'); |
| | | if (remainingQueue.length === 0) { |
| | | this.stopActivityTracking(); |
| | | } else { |
| | | // Still have queued items, restart activity tracking |
| | | this.trackActivity(); |
| | | } |
| | | |
| | | this.toggleQueue(this.maybeStartPolling()); |
| | | } |
| | | |
| | | async processOperation(operation) { |
| | | try { |
| | | //update to uploading |
| | | this.updateOperationStatus(operation.id, 'uploading'); |
| | | |
| | | //build request |
| | | const url = `${this.config.apiBase}${operation.endpoint}`; |
| | | let requestBody; |
| | | |
| | | if (operation.data instanceof FormData) { |
| | | operation.data.append('id', operation.id); |
| | | operation.data.append('user', this.user); |
| | | requestBody = operation.data; |
| | | } else { |
| | | requestBody = JSON.stringify({ |
| | | ...operation.data, |
| | | id: operation.id, |
| | | user: this.user |
| | | }); |
| | | operation.headers['Content-Type'] = 'application/json'; |
| | | //Add it to memory if it isn't already there |
| | | if (!this.queue.has(operation.id)) { |
| | | this.queue.set(operation.id, operation); |
| | | } |
| | | let skip = false; |
| | | if (operation.data?._isFormData && !operation.data instanceof FormData) { |
| | | skip = true; |
| | | operation.data = await this.store.objectToFormData(operation.data); |
| | | } |
| | | |
| | | const response = await fetch(url, { |
| | | method: operation.method, |
| | | headers: operation.headers, |
| | | body: requestBody |
| | | }); |
| | | this.updateOperationStatus(operation.id, 'uploading'); |
| | | |
| | | const result = await response.json(); |
| | | let requestBody; |
| | | let req; |
| | | if (operation.data instanceof FormData) { |
| | | operation.data.append('id', operation.id); |
| | | operation.data.append('user', window.auth.getUser()); |
| | | requestBody = operation.data; |
| | | req = operation.data; |
| | | } else { |
| | | req = { |
| | | ...operation.data, |
| | | id: operation.id, |
| | | user: window.auth.getUser() |
| | | }; |
| | | requestBody = JSON.stringify(req); |
| | | operation.headers['Content-Type'] = 'application/json'; |
| | | } |
| | | if (operation.endpoint === 'unknown' || requestBody === undefined || requestBody === null) return; |
| | | |
| | | if (response.ok && result.success !== false) { |
| | | // Handle server-side merge |
| | | if (result.id && operation.id !== result.id) { |
| | | |
| | | // Check if the returned ID exists locally |
| | | const existingOp = this.getQueue(result.id); |
| | | |
| | | if (existingOp) { |
| | | // Merge data from both operations |
| | | existingOp.data = window.deepMerge(existingOp.data, operation.data); |
| | | existingOp.status = 'pending'; |
| | | existingOp.serverData = result; |
| | | this.updateOperationStatus(existingOp.id, existingOp.status); |
| | | // Update the existing operation |
| | | this.setQueue(existingOp); |
| | | |
| | | this.removeOperationFromUI(operation.id); |
| | | |
| | | // Switch reference to the merged operation |
| | | operation = existingOp; |
| | | } else { |
| | | // Server merged with an operation we don't have locally |
| | | // Update the ID and continue |
| | | this.clearQueue(operation.id); |
| | | operation.id = result.id; |
| | | operation.status = 'pending'; |
| | | operation.serverData = result; |
| | | this.updateOperationStatus(operation.id, operation.status); |
| | | this.setQueue(operation); |
| | | } |
| | | } else { |
| | | // Normal processing - no merge |
| | | operation.status = 'pending'; |
| | | operation.serverData = result; |
| | | this.updateOperationStatus(operation.id, 'pending'); |
| | | this.setQueue(operation); |
| | | const response = await window.auth.fetch( |
| | | `${this.api}${operation.endpoint}`, |
| | | { |
| | | method: operation.method, |
| | | headers: operation.headers, |
| | | body: requestBody |
| | | } |
| | | |
| | | this.a11y.announce(`${operation.title} sent to server for processing.`); |
| | | |
| | | ); |
| | | console.log('Sending request with data: ', req); |
| | | const result = await response.json(); |
| | | if (skip) { |
| | | operation.data = {}; |
| | | } |
| | | console.log('Result: ', result); |
| | | if (response.ok && result.success) { |
| | | this.notify('sent-to-server', req); |
| | | if (result.id && operation.id !== result.id) { |
| | | operation = await this.handleServerMerge(operation, result); |
| | | } else { |
| | | operation.status = result.status??'failed'; |
| | | operation.serverData = result; |
| | | this.updateOperationStatus(operation.id, operation.status); |
| | | } |
| | | this.a11y.announce(`${operation.title} sent to server for processing`); |
| | | } else { |
| | | throw new Error(result.message || `HTTP ${response.status}`); |
| | | } |
| | | this.setQueue(operation); |
| | | } catch (error) { |
| | | console.error('Operation failed:', error); |
| | | |
| | | console.error('Operation failed: ', error); |
| | | operation.retries++; |
| | | operation.lastError = error.message; |
| | | |
| | | if (operation.retries >= this.config.maxRetries) { |
| | | if (operation.retries >= 3) { |
| | | operation.status = 'failed_permanent'; |
| | | } else { |
| | | operation.status = 'failed'; |
| | | operation.nextRetry = Date.now() + (Math.pow(2, operation.retries) * 1000); |
| | | } |
| | | this.updateOperationStatus(operation.id, operation.status); |
| | | |
| | | this.setQueue(operation); |
| | | } |
| | | } |
| | | |
| | | async handleServerMerge(operation, result) { |
| | | const existingOp = this.getQueue(result.id); |
| | | if (existingOp) { |
| | | operation.status = result.status||'pending'; |
| | | operation.serverData = result; |
| | | return this.mergeOp(existingOp, operation); |
| | | } else { |
| | | this.clearQueue(operation.id); |
| | | this.setQueue(result); |
| | | return result; |
| | | } |
| | | } |
| | | |
| | | mergeOp(oldOp, newOp) { |
| | | oldOp.data = window.deepMerge(oldOp.data, newOp.data); |
| | | oldOp.status = newOp.status; |
| | | if (Object.hasOwn(newOp, 'serverData')) { |
| | | oldOp.serverData = newOp.serverData; |
| | | } |
| | | this.updateOperationStatus(oldOp.id, oldOp.status); |
| | | this.removeOperationUI(newOp.id); |
| | | this.clearQueue(newOp.id); |
| | | return oldOp; |
| | | } |
| | | sortByDate(ops) { |
| | | return ops.sort((a, b) => { |
| | | const aTime = a.updated_at ?? a.timestamp ?? 0; |
| | | const bTime = b.updated_at ?? b.timestamp ?? 0; |
| | | return aTime - bTime; |
| | | }); |
| | | } |
| | | |
| | | sortOperations(ops) { |
| | | const statusPriority = { |
| | | 'processing': 0, |
| | | 'uploading': 1, |
| | | 'pending': 2, |
| | | 'queued': 3, |
| | | 'localProcessing': 4, |
| | | 'failed': 5, |
| | | 'completed': 6, |
| | | 'failed_permanent': 7 |
| | | }; |
| | | |
| | | return ops.sort((a, b) => { |
| | | // First by status priority |
| | | const priorityDiff = (statusPriority[a.status] ?? 99) - (statusPriority[b.status] ?? 99); |
| | | if (priorityDiff !== 0) return priorityDiff; |
| | | |
| | | // Then by updated_at (most recent first) |
| | | const aTime = a.updated_at ?? a.timestamp ?? 0; |
| | | const bTime = b.updated_at ?? b.timestamp ?? 0; |
| | | return new Date(bTime) - new Date(aTime); |
| | | }); |
| | | } |
| | | |
| | | getAllQueue() { |
| | | let index = new Set(); |
| | | |
| | | let ops = [ |
| | | ... Array.from(this.queue.values()) |
| | | ]; |
| | | if (!this.loadFromStorage) { |
| | | this.loadFromStorage = true; |
| | | ops = [ |
| | | ... ops, |
| | | ...Array.from(this.store.data.values()) |
| | | ]; |
| | | |
| | | ops = ops.filter(el => { |
| | | const isAdded = index.has(el.id); |
| | | index.add(el.id); |
| | | return !isAdded; |
| | | }); |
| | | } |
| | | //Sort operations by operation updated_at |
| | | return this.sortOperations(ops); |
| | | } |
| | | |
| | | getQueueByStatus(status) { |
| | | if (typeof status === 'string') { |
| | | status = [status]; |
| | | } |
| | | |
| | | let ops = this.getAllQueue(); |
| | | return ops.filter(op => status.includes(op.status)); |
| | | } |
| | | |
| | | |
| | | updateOperationStatus(itemID, status) { |
| | | let item = this.getQueue(itemID); |
| | | if (!item) return; |
| | | if (!this.statuses.includes(status)) { |
| | | console.log('Invalid status: ', status); |
| | | return; |
| | | } |
| | | |
| | | item.status = status; |
| | | this.notify('operation-status', item); |
| | | this.setQueue(item); |
| | | } |
| | | setQueue(item) { |
| | | this.store.save(item); |
| | | this.queue.set(item.id, item); |
| | | } |
| | | getQueue(itemID) { |
| | | return this.queue.has(itemID) ? this.queue.get(itemID) : this.store.get(itemID); |
| | | } |
| | | clearQueue(itemID) { |
| | | this.queue.delete(itemID); |
| | | this.store.delete(itemID); |
| | | } |
| | | /**************************************************************************** |
| | | POLLING |
| | | ****************************************************************************/ |
| | | maybeStartPolling() { |
| | | const incomplete = this.getQueueByStatus([...this.pendingStatuses, ...this.workingStatuses]); |
| | | if (incomplete.length > 0) { |
| | | this.startPolling(); |
| | | return true; |
| | | } |
| | | this.updatePanel('synced'); |
| | | return false; |
| | | } |
| | | startPolling() { |
| | | if (this.isPolling) return; |
| | | this.isPolling = true; |
| | | this.pollServer(); |
| | | this.pollTimer = setInterval(() => { |
| | | this.pollServer(); |
| | | }, this.config.pollInterval); |
| | | |
| | | this.updateCountdown(); |
| | | this.updatePanel('pending'); |
| | | this.runPollCycle(); |
| | | } |
| | | |
| | | pollServer(force = false) { |
| | | const operations = this.getOperationsByStatus(['pending', 'processing', 'uploading']); |
| | | |
| | | if (operations.length === 0 && !force) { |
| | | this.stopPolling(); |
| | | return; |
| | | } |
| | | this.updateStatusPanel('pending'); |
| | | async runPollCycle() { |
| | | if (!this.isPolling) return; |
| | | |
| | | try { |
| | | // const operationIds = operations.map(op => op.id); |
| | | // this.store.setFilter('operation_ids', operationIds.join(',')); |
| | | this.store.fetch(); |
| | | this.ui.refresh.button.classList.add('fetching'); |
| | | this.store.clearCache(); |
| | | await this.store.fetch(); |
| | | this.ui.refresh.button.classList.remove('fetching'); |
| | | if (!this.maybeStartPolling()) { |
| | | this.stopPolling(); |
| | | this.updatePanel('synced'); |
| | | return; |
| | | } |
| | | } catch (error) { |
| | | console.error('Polling error:', error); |
| | | } finally { |
| | | this.updateStatusPanel(); |
| | | } |
| | | |
| | | // Schedule next poll with countdown |
| | | this.startCountdown(5, () => this.runPollCycle()); |
| | | } |
| | | |
| | | async updateOperationsFromServer(serverOperations) { |
| | | let hasChanges = false; |
| | | const processedIds = new Set(); |
| | | for (const serverOp of serverOperations) { |
| | | let operation = (this.queue.has(serverOp.id)) ? this.queue.get(serverOp.id) : {}; |
| | | processedIds.add(serverOp.id); |
| | | if (serverOp.status !== operation.status) { |
| | | operation = { |
| | | ... operation, |
| | | ... serverOp |
| | | }; |
| | | // Update in DataStore |
| | | this.queue.set(operation.id, operation); |
| | | startCountdown(count, onComplete) { |
| | | if (!this.ui.refresh.countdown) { |
| | | console.warn('Countdown element not found'); |
| | | return; |
| | | } |
| | | this.ui.refresh.countdown.classList.add('counting'); |
| | | this.ui.refresh.countdown.textContent = count; |
| | | |
| | | // Update UI for this operation |
| | | this.updateOperationStatus(operation.id, operation.status); |
| | | this.countdownTimer = setInterval(() => { |
| | | count--; |
| | | if (count > 0) { |
| | | this.ui.refresh.countdown.textContent = count; |
| | | } else { |
| | | this.stopCountdown(); |
| | | if (onComplete) onComplete(); |
| | | } |
| | | } |
| | | |
| | | // Clean up operations that were completed/dismissed on server |
| | | const localOps = this.getOperationsByStatus(['pending', 'processing', 'uploading']); |
| | | for (const localOp of localOps) { |
| | | if (!processedIds.has(localOp.id)) { |
| | | localOp.status = 'completed'; |
| | | localOp.completedAt = Date.now(); |
| | | this.setQueue(localOp); |
| | | hasChanges = true; |
| | | this.updateOperationStatus(localOp.id, localOp.status); |
| | | } |
| | | } |
| | | |
| | | // Check if all operations are completed |
| | | const pendingOps = this.getOperationsByStatus(['pending', 'processing', 'uploading']); |
| | | |
| | | if (pendingOps.length === 0) { |
| | | this.stopPolling(); |
| | | } |
| | | |
| | | this.updateUI(); |
| | | }, 1000); |
| | | } |
| | | |
| | | stopPolling() { |
| | |
| | | clearInterval(this.pollTimer); |
| | | this.pollTimer = null; |
| | | } |
| | | this.stopCountdown(); |
| | | } |
| | | |
| | | stopCountdown() { |
| | | if (this.countdownTimer) { |
| | | clearInterval(this.countdownTimer); |
| | | this.countdownTimer = null; |
| | | } |
| | | this.ui.refresh.countdown.classList.remove('counting'); |
| | | this.ui.refresh.countdown.textContent = ''; |
| | | } |
| | | |
| | | /*********************************************************** |
| | | USER ACTIONS |
| | | ***********************************************************/ |
| | | |
| | | /** |
| | | * |
| | | * @param {array} ids |
| | | * @param {string }action |
| | | * @returns {Promise<void>} |
| | | */ |
| | | async updateServerOperations(ids, action) { |
| | | //ensure ids are in an array |
| | | ids = Array.isArray(ids) ? ids : ((ids.includes(',')) ? ids.split(',') : [ids]); |
| | | ids = ids.filter((id) => { |
| | | let item = this.getQueue(id); |
| | | return this.getAllowedActions(item.status).includes(action); |
| | | }); |
| | | |
| | | if (ids.length === 0) { |
| | | return; |
| | | } |
| | | |
| | | if (['cancel', 'dismiss'].includes(action)) { |
| | | ids.forEach(id => { |
| | | this.removeOperationFromUI(id); |
| | | }); |
| | | } |
| | | |
| | | try { |
| | | const url = `${this.config.apiBase}${this.config.endpoint}`; |
| | | |
| | | const response = await fetch( |
| | | url, |
| | | { |
| | | method: 'POST', |
| | | headers: { |
| | | 'Content-Type': 'application/json', |
| | | ...this.headers |
| | | }, |
| | | body: JSON.stringify({ids,action}) |
| | | } |
| | | ); |
| | | |
| | | if (!response.ok) { |
| | | const errorData = await response.json().catch(()=>{}); |
| | | throw new Error(errorData.message || `${action} failed: ${response.status}`); |
| | | } |
| | | |
| | | const result = await response.json(); |
| | | if (!result.success) { |
| | | throw new Error(result.message || `${action} operation failed`); |
| | | } |
| | | |
| | | if (['cancel', 'dismiss'].includes(action)) { |
| | | ids.forEach(id => { |
| | | let item = this.getQueue(id); |
| | | this.notify(`${action}-operation`, item); |
| | | this.clearQueue(id); |
| | | }); |
| | | } else { |
| | | ids.forEach(id => { |
| | | let item = this.getQueue(id); |
| | | this.notify(`${action}-operation`, item); |
| | | |
| | | item.status = 'queued'; |
| | | item.retries = 0; |
| | | this.setQueue(item); |
| | | this.updateOperationStatus(item.id, item.status); |
| | | }); |
| | | this.startActivityTracking(); |
| | | } |
| | | this.updateUI(); |
| | | |
| | | return result; |
| | | } catch (error) { |
| | | const result = await window.jvbError.log(error, { |
| | | component: 'QueueManager', |
| | | operation: 'performQueueAction', |
| | | action: action, |
| | | operationIds: ids, |
| | | itemCount: ids.length |
| | | }, () => this.updateServerOperations(ids, action)); // Retry callback |
| | | |
| | | if (result.retried) { |
| | | return result; // Return successful retry result |
| | | } else { |
| | | throw error; // Re-throw if not retried |
| | | } |
| | | } |
| | | } |
| | | |
| | | getAllowedActions(status) { |
| | | const actionMap = { |
| | | 'queued': ['cancel'], |
| | | 'localProcessing': ['cancel'], |
| | | 'pending': ['cancel'], |
| | | 'processing': [], |
| | | 'completed': ['dismiss'], |
| | | 'failed': ['retry', 'dismiss'], |
| | | 'failed_permanent': ['dismiss'] |
| | | }; |
| | | return actionMap[status] || []; |
| | | } |
| | | |
| | | |
| | | /********************************************* |
| | | LISTENERS |
| | | *********************************************/ |
| | | initListeners() { |
| | | this.clickHandler = this.handleClick.bind(this); |
| | | this.changeHandler = this.handleChange.bind(this); |
| | | this.keyHandler = this.handleEscape.bind(this); |
| | | |
| | | document.addEventListener('click', this.clickHandler); |
| | | this.ui.panel?.addEventListener('change', this.changeHandler); |
| | | |
| | | this.handleOnline = () => { |
| | | this.updateStatusPanel(); |
| | | if (this.hasQueuedOperations()) { |
| | | this.processQueue(); |
| | | } |
| | | }; |
| | | this.handleOffline = () => this.updateStatusPanel('offline'); |
| | | this.handleBeforeUnload = (e) => { |
| | | const hasPending = this.getOperationsByStatus(['queued', 'uploading']); |
| | | if (hasPending.length > 0) { |
| | | e.preventDefault(); |
| | | return 'You have unsaved changes in the queue.'; |
| | | } |
| | | }; |
| | | |
| | | window.addEventListener('online', this.handleOnline); |
| | | window.addEventListener('offline', this.handleOffline); |
| | | window.addEventListener('beforeunload', this.handleBeforeUnload); |
| | | } |
| | | handleClick(e) { |
| | | if(!e.target.closest(this.selectors.panel) && !e.target.closest(this.selectors.toggle)) { |
| | | if (this.panelIsOpen()) { |
| | | this.togglePanel(false); |
| | | } |
| | | return; |
| | | } |
| | | |
| | | if (e.target.closest(this.selectors.toggle)) { |
| | | this.togglePanel(!this.panelIsOpen()); |
| | | } else if (e.target.closest(this.selectors.refreshButton)) { |
| | | this.pollServer(true); |
| | | } else if (e.target.closest(this.selectors.clearButton)) { |
| | | const completedOps = this.getOperationsByStatus('completed'); |
| | | if (completedOps.length > 0) { |
| | | const ids = completedOps.map(op => op.id); |
| | | this.updateServerOperations(ids, 'dismiss'); |
| | | } |
| | | } else if (e.target.closest(this.selectors.retryButton)) { |
| | | const failedOps = this.getOperationsByStatus('failed'); |
| | | if (failedOps.length > 0) { |
| | | const ids = failedOps.map(op => op.id); |
| | | this.updateServerOperations(ids, 'retry'); |
| | | } |
| | | } else if (e.target.closest('[data-action]')) { |
| | | const button = e.target.closest('[data-action]'); |
| | | const operationId = button.closest('[data-id]')?.dataset.id; |
| | | if (operationId) { |
| | | this.updateServerOperations(operationId, button.dataset.action); |
| | | } |
| | | } else if (e.target.closest('.filters [data-filter]')) { |
| | | const filter = e.target.closest('[data-filter]').dataset.filter; |
| | | this.setFilter(filter); |
| | | } |
| | | |
| | | } |
| | | |
| | | handleChange(e) { |
| | | } |
| | | |
| | | handleEscape(e) { |
| | | if (e.key === 'Escape') { |
| | | this.togglePanel(false); |
| | | } |
| | | } |
| | | panelIsOpen() { |
| | | return this.ui.panel?.classList.contains('expanded'); |
| | | } |
| | | togglePanel(open) { |
| | | if (!this.ui.panel) return; |
| | | |
| | | if (open) { |
| | | document.addEventListener('keydown', this.keyHandler); |
| | | } else { |
| | | document.removeEventListener('keydown', this.keyHandler); |
| | | } |
| | | this.ui.toggle.title = (open) ? 'Hide Queue' : 'Show Queue'; |
| | | this.a11y.announce((open) ? 'Opened Queue Panel': 'Closed Queue Panel'); |
| | | this.ui.panel.ariaExpanded = open; |
| | | this.ui.panel.classList.toggle('expanded', open); |
| | | } |
| | | |
| | | /********************************************* |
| | | UI |
| | | *********************************************/ |
| | | initUI() { |
| | | this.icons = { |
| | | queued: 'refresh', localProcessing: 'refresh', uploading: 'syncing', |
| | | pending: 'cloud', processing: 'syncing', completed: 'synced', |
| | | failed: 'error', failed_permanent: 'error' |
| | | }; |
| | | |
| | | this.selectors = { |
| | | panel: 'aside#queue', |
| | | toggle: 'button.qtoggle', |
| | | refreshButton: 'button.refreshNow', |
| | | countdown: '.countdown', |
| | | indicator: '.qtoggle .indicator', |
| | | count: '.qtoggle .count', |
| | | popup: '.popup', |
| | | itemsContainer: '.qitems', |
| | | clearButton: '.dismiss-all', |
| | | retryButton: '.retry-all', |
| | | filters: { |
| | | all: '.filters [data-filter="all"]', |
| | | received: '.filters [data-filter="queued"]', |
| | | localProcessing: '.filters [data-filter="localProcessing"]', |
| | | uploading: '.filters [data-filter="uploading"]', |
| | | pending: '.filters [data-filter="pending"]', |
| | | processing: '.filters [data-filter="processing"]', |
| | | completed: '.filters [data-filter="completed"]', |
| | | failed: '.filters [data-filter="failed"]', |
| | | } |
| | | }; |
| | | |
| | | this.ui = { |
| | | panel: document.querySelector(this.selectors.panel), |
| | | toggle: document.querySelector(this.selectors.toggle), |
| | | count: document.querySelector(this.selectors.count), |
| | | indicator: document.querySelector(this.selectors.indicator), |
| | | }; |
| | | if (!this.ui.panel) { |
| | | this.canUpdateUI = false; |
| | | return; |
| | | } |
| | | |
| | | for (let [key, selector] of Object.entries(this.selectors)) { |
| | | if (['panel', 'toggle', 'count', 'indicator'].includes(key)) { |
| | | continue; |
| | | } |
| | | if (typeof selector === 'object') { |
| | | this.ui[key] = {}; |
| | | for (let [k, s] of Object.entries(selector)) { |
| | | this.ui[key][k] = this.ui.panel.querySelector(s); |
| | | } |
| | | }else { |
| | | this.ui[key] = this.ui.panel.querySelector(selector); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /**************************************************************************** |
| | | UI |
| | | ****************************************************************************/ |
| | | updateUI() { |
| | | if (!this.canUpdateUI) { |
| | | return; |
| | | } |
| | | const stats = this.getQueueStats(); |
| | | if (!this.canUpdateUI) return; |
| | | |
| | | // Update count badge |
| | | if (this.ui.count) { |
| | | const total = stats.total - stats.completed; |
| | | this.ui.count.textContent = total > 0 ? total : ''; |
| | | this.ui.count.style.display = total > 0 ? '' : 'none'; |
| | | } |
| | | window.debouncer.schedule( |
| | | 'queue-ui', |
| | | this.handleUpdateUI.bind(this) |
| | | ) |
| | | } |
| | | handleUpdateUI() { |
| | | const operations = this.getAllQueue(); |
| | | this.ui.actions.retry.disabled = operations.filter(op => op.status === 'failed').length === 0; |
| | | this.ui.actions.clear.disabled = operations.filter(op => op.status === 'completed').length ===0; |
| | | |
| | | // Update indicator |
| | | if (this.ui.indicator) { |
| | | const hasActive = stats.queued > 0 || stats.uploading > 0 || |
| | | stats.pending > 0 || stats.processing > 0; |
| | | this.ui.indicator.classList.toggle('active', hasActive); |
| | | } |
| | | let failed = this.getOperationsByStatus('failed'); |
| | | let completed = this.getOperationsByStatus('completed'); |
| | | this.ui.clearButton.disabled = completed.length === 0; |
| | | this.ui.retryButton.disabled = failed.length === 0; |
| | | let activeCount = operations.filter(op => |
| | | [...this.pendingStatuses, ...this.workingStatuses].includes(op.status) |
| | | ); |
| | | activeCount = activeCount.length; |
| | | this.ui.toggle.count.hidden = activeCount === 0; |
| | | this.ui.toggle.count.textContent = activeCount; |
| | | |
| | | // Update filter counts |
| | | Object.entries(this.ui.filters).forEach(([status, button]) => { |
| | | const count = status === 'all' ? stats.total : stats[status] || 0; |
| | | const countEl = button.querySelector('.count'); |
| | | if (countEl) { |
| | | countEl.textContent = count > 0 ? count : ''; |
| | | for (let status of this.statuses) { |
| | | if (status === 'failed_permanent') continue; |
| | | let total = operations.filter(op => op.status === status).length; |
| | | this.ui.filters[status].label.hidden = total === 0; |
| | | this.ui.filters[status].input.dataset.count = `${total}`; |
| | | if (total > 0) { |
| | | this.ui.filters[status].count.textContent = total; |
| | | } else { |
| | | this.ui.filters[status].count.textContent = ''; |
| | | } |
| | | } |
| | | button.setAttribute('data-count', count); |
| | | |
| | | this.renderOperations(); |
| | | } |
| | | |
| | | renderOperations() { |
| | | if (!this.ui.items.container) return; |
| | | |
| | | const status = this.store.filters?.status ?? 'all'; |
| | | const operations = (status === 'all') ? this.getAllQueue() : this.getQueueByStatus(status); |
| | | const sortedOps = this.sortOperations(operations); |
| | | |
| | | if (sortedOps.length === 0) { |
| | | window.removeChildren(this.ui.items.container); |
| | | const empty = window.jvbTemplates.create('emptyQueue'); |
| | | this.ui.items.container.append(empty); |
| | | this.a11y.announce('No items in queue'); |
| | | return; |
| | | } else { |
| | | this.ui.items.container.querySelector('.empty-group')?.remove(); |
| | | } |
| | | |
| | | // Track which items should exist |
| | | const expectedIds = new Set(sortedOps.map(op => op.id)); |
| | | |
| | | // Remove items that shouldn't exist |
| | | this.items.forEach((item, id) => { |
| | | if (!expectedIds.has(id)) { |
| | | item.element?.remove(); |
| | | this.items.delete(id); |
| | | } |
| | | }); |
| | | |
| | | // Update operation list |
| | | this.renderOperations(); |
| | | // Update/add items in order |
| | | sortedOps.forEach((op, index) => { |
| | | let item = this.items.get(op.id); |
| | | if (!item) { |
| | | item = this.createOperationElement(op); |
| | | } |
| | | if (item?.element) { |
| | | this.updateOperationUI(op.id); |
| | | // Reorder by re-appending (moves to end in correct order) |
| | | this.ui.items.container.append(item.element); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | getStatusLabel(status) { |
| | | const labels = { |
| | | 'queued': 'Queued', |
| | | 'localProcessing': 'Processing locally', |
| | | 'uploading': 'Uploading', |
| | | 'pending': 'Waiting on server', |
| | | 'processing': 'Processing', |
| | | 'completed': 'Completed', |
| | | 'failed': 'Failed (will retry)', |
| | | 'failed_permanent': 'Failed permanently' |
| | | createOperationElement(op) { |
| | | const el = window.jvbTemplates.create('queueItem', op); |
| | | const item = { |
| | | element: el, |
| | | ui: window.uiFromSelectors(this.selectors.item, el) |
| | | }; |
| | | return labels[status] || status; |
| | | |
| | | this.items.set(op.id, item); |
| | | return item; |
| | | } |
| | | |
| | | getItemMessage(item) { |
| | | if (item.message) return item.message; |
| | | if (item.error_message) return item.error_message; |
| | | updateOperationUI(opId) { |
| | | let item = (this.items.has(opId)) ? this.items.get(opId) : this.createOperationElement(opId); |
| | | if (!item) return; |
| | | let op = this.getQueue(opId); |
| | | |
| | | switch(item.status) { |
| | | case 'queued': |
| | | return 'Waiting to send...'; |
| | | case 'uploading': |
| | | return 'Sending to server...'; |
| | | case 'pending': |
| | | return item.position ? `Position ${item.position} in queue` : 'In server queue'; |
| | | case 'processing': |
| | | return item.progress ? `${item.progress}% complete` : 'Processing...'; |
| | | case 'completed': |
| | | return 'Successfully completed'; |
| | | case 'failed': |
| | | return `Failed: ${item.lastError || 'Unknown error'} (Retry ${item.retries}/${this.config.maxRetries})`; |
| | | case 'failed_permanent': |
| | | return `Failed: ${item.lastError || 'Unknown error'}`; |
| | | default: |
| | | return ''; |
| | | let element = item.element; |
| | | |
| | | element.classList.remove(... this.statuses); |
| | | element.classList.add(op.status); |
| | | |
| | | let progress = this.getProgress(op); |
| | | if (item.ui.type && item.ui.type.textContent !== op.title) item.ui.type.textContent = op.title; |
| | | if (item.ui.status) { |
| | | item.ui.status.title = this.statusLabel(op.status); |
| | | } |
| | | if (item.ui.icon) { |
| | | item.ui.icon.className = `icon icon-${this.icons[op.status]}`; |
| | | } |
| | | if (item.ui.details) item.ui.details.textContent = this.itemMessage(op); |
| | | if (item.ui.startedAt) { |
| | | item.ui.startedAt.setAttribute('datetime', op.created_at); |
| | | item.ui.startedAt.textContent = window.formatTimeAgo(op.created_at); |
| | | } |
| | | let text = op.status === 'completed' ? 'Completed: ' : 'Last updated: '; |
| | | const shouldShowCompleted = op.status === 'completed' && (op.completed_at || op.updated_at); |
| | | item.ui.completed.wrap.hidden = !shouldShowCompleted; |
| | | if (shouldShowCompleted) { |
| | | const completedTime = op.completed_at ?? op.updated_at; |
| | | item.ui.completed.label.textContent = 'Completed: '; |
| | | item.ui.completed.time.setAttribute('datetime', completedTime); |
| | | item.ui.completed.time.textContent = window.formatTimeAgo(completedTime); |
| | | } |
| | | |
| | | window.showProgress(item.ui.progress, progress, 100, this.statusLabel(op.status)); |
| | | if (item.ui.actions.cancel) item.ui.actions.cancel.hidden = this.completedStatuses.includes(op.status); |
| | | if (item.ui.actions['retry']) { |
| | | if (op.retries >= 3) item.ui.actions['retry'].disabled = true; |
| | | item.ui.actions['retry'].hidden = op.status !=='failed'; |
| | | } |
| | | if (item.ui.actions.dismiss) item.ui.actions.dismiss.hidden = this.pendingStatuses.includes(op.status); |
| | | if (item.ui.actions.refresh) { |
| | | item.ui.actions.refresh.hidden = op.status !== 'completed'; |
| | | } |
| | | } |
| | | } |
| | | |
| | | calculateProgress(item) { |
| | | if (item.progress) return item.progress; |
| | | |
| | | // Estimate progress based on status |
| | | getProgress(op) { |
| | | // Check server-provided percentage first |
| | | if (op.progress_percentage !== undefined) { |
| | | return op.progress_percentage; |
| | | } |
| | | // Legacy: check old 'progress' field |
| | | if (op.progress !== undefined) { |
| | | return op.progress; |
| | | } |
| | | // Fallback to status-based calculation |
| | | if (!this.statuses.includes(op.status)) return 0; |
| | | const statusProgress = { |
| | | 'queued': 10, |
| | | 'uploading': 25, |
| | |
| | | 'failed': 0, |
| | | 'failed_permanent': 0 |
| | | }; |
| | | |
| | | return statusProgress[item.status] || 0; |
| | | return statusProgress[op.status] ?? 0; |
| | | } |
| | | removeOperationUI(opId) { |
| | | let op = this.items.get(opId); |
| | | if (!op) return; |
| | | window.fade(op.element, false); |
| | | } |
| | | |
| | | getQueueStats() { |
| | | const stats = {}; |
| | | this.statuses.forEach(status => { |
| | | stats[status] = 0; |
| | | }); |
| | | |
| | | Array.from(this.store.items.values()) |
| | | .forEach(op => { |
| | | if (stats.hasOwnProperty(op.status)) { |
| | | stats[op.status]++; |
| | | } |
| | | }); |
| | | |
| | | stats.total = Array.from(this.store.items.values()).length; |
| | | |
| | | return stats; |
| | | updatePanel(status = 'syncing') { |
| | | if (!this.ui.panel || !this.panelStatuses.includes(status)) return; |
| | | this.ui.panel.classList.remove(...this.panelStatuses); |
| | | this.ui.panel.classList.add(status); |
| | | } |
| | | |
| | | renderOperations() { |
| | | if (!this.ui.itemsContainer) return; |
| | | |
| | | const activeFilter = this.getActiveFilter(); |
| | | const operations = this.getFilteredOperations(activeFilter); |
| | | |
| | | // Clear container |
| | | window.removeChildren(this.ui.itemsContainer); |
| | | |
| | | // Render each operation |
| | | if (operations.length === 0) { |
| | | let empty = window.getTemplate('emptyQueue'); |
| | | this.ui.itemsContainer.append(empty); |
| | | this.a11y.announce('Nothing queued.'); |
| | | } else { |
| | | let empty = this.ui.itemsContainer.querySelector('.emptyQueue'); |
| | | if (empty) { |
| | | empty.remove(); |
| | | } |
| | | operations.forEach(op => { |
| | | const element = this.createOperationUI(op); |
| | | this.ui.itemsContainer.appendChild(element); |
| | | }); |
| | | } |
| | | /**************************************************************************** |
| | | UTILITY |
| | | ****************************************************************************/ |
| | | statusLabel(status) { |
| | | if (!this.statuses.includes(status)) return''; |
| | | const labels = { |
| | | 'queued': 'Queued', |
| | | 'localProcessing': 'Processing locally', |
| | | 'uploading': 'Uploading', |
| | | 'pending': 'Waiting on server', |
| | | 'processing': 'Processing', |
| | | 'completed': 'Completed', |
| | | 'failed': 'Failed', |
| | | 'failed_permanent': 'Failed permanently', |
| | | 'merged': 'Merged' |
| | | }; |
| | | return labels[status]; |
| | | } |
| | | itemMessage(item) { |
| | | if (Object.hasOwn(item, 'message') && item.message !== '') return item.message; |
| | | if (Object.hasOwn(item, 'error_message') && item.error_message) return item.error_message; |
| | | |
| | | createOperationUI(operation) { |
| | | const listItem = window.getTemplate('queueItem'); |
| | | listItem.dataset.id = operation.id; |
| | | |
| | | this.updateOperationUI(operation, listItem); |
| | | return listItem; |
| | | } |
| | | |
| | | updateOperationUI(item, element = null) { |
| | | if (!element) { |
| | | element = this.ui.itemsContainer?.querySelector(`[data-id="${item.id}"]`); |
| | | } |
| | | if (!element) { |
| | | element = this.createOperationUI(item); |
| | | } |
| | | |
| | | // Remove old status classes |
| | | this.statuses.forEach(status => element.classList.remove(status)); |
| | | element.classList.add(item.status); |
| | | |
| | | // Update content |
| | | let timeDisplay = ''; |
| | | |
| | | if (item.updated_at) { |
| | | // Server now sends ISO format timestamps - much more reliable! |
| | | timeDisplay = window.formatTimeAgo(new Date(item.updated_at)); |
| | | } else if (item.created_at) { |
| | | timeDisplay = window.formatTimeAgo(new Date(item.created_at)); |
| | | } |
| | | const progressPercent = this.calculateProgress(item); |
| | | |
| | | // Update text content safely |
| | | const typeEl = element.querySelector('.type'); |
| | | const statusEl = element.querySelector('.status'); |
| | | const detailsEl = element.querySelector('.info .details'); |
| | | const timeEl = element.querySelector('.info .time'); |
| | | const progressFill = element.querySelector('.progress .fill'); |
| | | |
| | | if (typeEl) typeEl.textContent = item.title; |
| | | if (statusEl) { |
| | | statusEl.querySelector('.icon')?.remove(); |
| | | let status = this.getStatusLabel(item.status); |
| | | statusEl.title = status; |
| | | statusEl.prepend(window.getIcon(this.icons[item.status])); |
| | | statusEl.querySelector('span').textContent = status; |
| | | } |
| | | if (detailsEl) detailsEl.textContent = this.getItemMessage(item); |
| | | if (timeEl) timeEl.textContent = timeDisplay; |
| | | if (progressFill) progressFill.style.width = `${progressPercent}%`; |
| | | |
| | | // Update action buttons |
| | | const actionsContainer = element.querySelector('.actions'); |
| | | if (actionsContainer) { |
| | | this.updateActionButtons(item, actionsContainer); |
| | | } |
| | | } |
| | | |
| | | updateActionButtons(item, container) { |
| | | window.removeChildren(container); |
| | | |
| | | switch (item.status) { |
| | | switch(item.status) { |
| | | case 'queued': |
| | | case 'localProcessing': |
| | | return 'Waiting to send...'; |
| | | case 'uploading': |
| | | return 'Sending to server...'; |
| | | case 'pending': |
| | | // Show cancel button for in-progress items |
| | | const cancelBtn = window.getTemplate('button'); |
| | | cancelBtn.classList.add('cancel'); |
| | | cancelBtn.dataset.action = 'cancel'; |
| | | cancelBtn.textContent = 'Cancel'; |
| | | container.appendChild(cancelBtn); |
| | | break; |
| | | |
| | | case 'failed': |
| | | case 'failed_permanent': |
| | | // Show retry and dismiss buttons |
| | | const retryBtn = window.getTemplate('button'); |
| | | const dismissBtn = window.getTemplate('button'); |
| | | |
| | | retryBtn.classList.add('retry'); |
| | | retryBtn.textContent = 'Retry'; |
| | | retryBtn.disabled = item.retries >= this.maxRetries; |
| | | retryBtn.dataset.action = 'retry'; |
| | | |
| | | dismissBtn.classList.add('dismiss'); |
| | | dismissBtn.textContent = 'Dismiss'; |
| | | dismissBtn.dataset.action = 'dismiss'; |
| | | |
| | | container.appendChild(retryBtn); |
| | | container.appendChild(dismissBtn); |
| | | break; |
| | | |
| | | case 'completed': |
| | | // Show dismiss button only |
| | | const dismissCompletedBtn = window.getTemplate('button'); |
| | | dismissCompletedBtn.dataset.action = 'dismiss'; |
| | | dismissCompletedBtn.classList.add('dismiss'); |
| | | dismissCompletedBtn.textContent = 'Dismiss'; |
| | | container.appendChild(dismissCompletedBtn); |
| | | break; |
| | | } |
| | | } |
| | | |
| | | removeOperationFromUI(operationId) { |
| | | const element = this.ui.itemsContainer?.querySelector(`[data-id="${operationId}"]`); |
| | | if (element) { |
| | | element.style.opacity = '0'; |
| | | element.style.transform = 'scale(0.9)'; |
| | | setTimeout(() => element.remove(), 300); |
| | | } |
| | | } |
| | | |
| | | updateCountdown() { |
| | | if (!this.ui.countdown || !this.isPolling) return; |
| | | |
| | | let seconds = this.config.pollInterval / 1000; |
| | | |
| | | this.countdownTimer = setInterval(() => { |
| | | seconds--; |
| | | |
| | | this.ui.countdown.textContent = seconds; |
| | | |
| | | if (seconds <= 0) { |
| | | clearInterval(this.countdownTimer); |
| | | if (this.isPolling) { |
| | | setTimeout(() => this.updateCountdown(), 100); |
| | | return item.position ? `Position ${item.position} in queue` : 'In server queue'; |
| | | case 'processing': |
| | | // Show progress count if available |
| | | if (item.count && item.progress_count !== undefined) { |
| | | const processed = item.progress_count; |
| | | const total = item.count; |
| | | const percentage = Math.round((processed / total) * 100); |
| | | return `Processing ${processed}/${total} items (${percentage}%)`; |
| | | } |
| | | // Fallback to percentage only |
| | | if (item.progress_percentage !== undefined) { |
| | | return `${item.progress_percentage}% complete`; |
| | | } |
| | | return 'Processing...'; |
| | | case 'completed': |
| | | return 'Successfully completed. Refresh to see changes.'; |
| | | case 'merged': |
| | | return item.merged_into |
| | | ? `Merged with another operation (${item.merged_into.substring(0, 8)}...)` |
| | | : 'Merged with another operation'; |
| | | case 'failed': |
| | | return `Failed: ${item.lastError || 'Unknown error'} (Retry ${item.retries}/${2})`; |
| | | case 'failed_permanent': |
| | | return `Failed: ${item.lastError || 'Unknown error'}`; |
| | | default: |
| | | return ''; |
| | | } |
| | | } |
| | | toggleQueue(on = true) { |
| | | if (!this.ui.panel) return; |
| | | this.ui.panel.hidden = !on; |
| | | this.ui.toggle.button.hidden = !on; |
| | | } |
| | | setProcessing(on = true) { |
| | | this.isProcessing = on; |
| | | this.ui.toggle.button.classList.toggle('saving', on); |
| | | } |
| | | |
| | | /** |
| | | * Map server operation format to frontend format |
| | | * Server uses: type, data (requestData), status (from state/outcome) |
| | | * Frontend uses: endpoint, data, status, headers, method, etc. |
| | | */ |
| | | mapServerOperation(serverOp) { |
| | | const localOp = this.queue.get(serverOp.id); |
| | | |
| | | // If we have local operation data, preserve it |
| | | if (localOp && localOp.endpoint) { |
| | | const mappedOp = { |
| | | ...localOp, |
| | | ...serverOp, |
| | | endpoint: localOp.endpoint, |
| | | method: localOp.method, |
| | | headers: localOp.headers, |
| | | progress_percentage: serverOp.progress_percentage, |
| | | progress_count: serverOp.progress_count, |
| | | count: serverOp.count |
| | | }; |
| | | |
| | | if (serverOp.merged_into) { |
| | | this.handleMergedOperation(mappedOp); |
| | | } |
| | | }, 1000); |
| | | } |
| | | |
| | | updateStatusPanel(status) { |
| | | this.ui.panel?.classList.remove(...this.classes); |
| | | if (!this.classes.includes(status)) { |
| | | return; |
| | | } |
| | | this.ui.panel?.classList.add(status); |
| | | } |
| | | |
| | | /*************************************************** |
| | | FILTERS |
| | | **************************************************/ |
| | | setFilter(filter) { |
| | | Object.values(this.ui.filters).forEach(button => { |
| | | if (button) { |
| | | button.classList.toggle('active', button.dataset.filter === filter); |
| | | } |
| | | }); |
| | | |
| | | this.activeFilter = filter; |
| | | this.renderOperations(); |
| | | } |
| | | |
| | | getActiveFilter() { |
| | | const activeButton = this.ui.panel?.querySelector('.filter.active'); |
| | | return activeButton?.dataset.filter || 'all'; |
| | | } |
| | | |
| | | getFilteredOperations(filter) { |
| | | const operations = Array.from(this.store.items.values()); |
| | | |
| | | if (filter === 'all') { |
| | | return operations; |
| | | } |
| | | |
| | | return operations.filter(op => op.status === filter); |
| | | |
| | | // Minimal mapping for server-only operations |
| | | // Extract endpoint from type if possible, otherwise use type |
| | | const endpoint = serverOp.type ? serverOp.type.replace('_update', '').replace('_', '/') : 'unknown'; |
| | | |
| | | const mappedOp = { |
| | | ...serverOp, |
| | | endpoint: endpoint, |
| | | method: 'POST', |
| | | headers: { ...this.headers }, |
| | | }; |
| | | if (serverOp.merged_into) { |
| | | this.handleMergedOperation(mappedOp); |
| | | } |
| | | return mappedOp |
| | | } |
| | | |
| | | /************************************************************************** |
| | | NOTIFICATIONS |
| | | **************************************************************************/ |
| | | showPopup(message, type = 'success') { |
| | | if (!this.ui.popup) return; |
| | | /** |
| | | * Handle merged operations |
| | | * The target operation already has merged data from server, |
| | | * so we just need to clean up the merged operation locally |
| | | */ |
| | | handleMergedOperation(operation) { |
| | | if (!operation.merged_into) return; |
| | | |
| | | const span = this.ui.popup.querySelector('span'); |
| | | if (span) { |
| | | span.textContent = message; |
| | | } |
| | | console.log(`[Queue] Operation ${operation.id} merged into ${operation.merged_into}`); |
| | | |
| | | this.ui.popup.className = `popup ${type} show`; |
| | | |
| | | // Auto-dismiss merged operation after brief display |
| | | // The target operation already has all the merged data from server |
| | | setTimeout(() => { |
| | | this.ui.popup.classList.remove('show'); |
| | | this.clearQueue(operation.id); |
| | | this.removeOperationFromUI(operation.id); |
| | | }, 3000); |
| | | } |
| | | /************************************************************************** |
| | | HELPERS |
| | | **************************************************************************/ |
| | | getOperationsByStatus(status, include = true) { |
| | | |
| | | status = Array.isArray(status) ? status : ((status.includes(',')) ? status.split(',') : [status]); |
| | | if (include) { |
| | | return Array.from(this.queue.values()).filter(op => |
| | | status.includes(op.status) |
| | | ); |
| | | } |
| | | return Array.from(this.queue.values()).filter(op => |
| | | !status.includes(op.status) |
| | | ); |
| | | } |
| | | hasQueuedOperations() { |
| | | return this.queue.some(op => |
| | | op.status === 'queued' |
| | | ); |
| | | } |
| | | /**************************************************************************** |
| | | SUBSCRIPTION |
| | | ****************************************************************************/ |
| | | subscribe(callback) { |
| | | if (!this.subscribers) { |
| | | return; |
| | | } |
| | | this.subscribers.add(callback); |
| | | return () => this.subscribers.delete(callback); |
| | | } |
| | |
| | | notify(event, data) { |
| | | this.subscribers.forEach(cb => cb(event, data)); |
| | | } |
| | | |
| | | /************************************************************************** |
| | | /**************************************************************************** |
| | | CLEANUP |
| | | **************************************************************************/ |
| | | ****************************************************************************/ |
| | | destroy() { |
| | | this.stopPolling(); |
| | | if (this.isPolling) { |
| | | this.stopPolling(); |
| | | } |
| | | this.stopActivityTracking(); |
| | | |
| | | if (this.clickHandler) { |
| | | document.removeEventListener('click', this.clickHandler); |
| | | } |
| | | |
| | | if (this.keyHandler) { |
| | | document.removeEventListener('keydown', this.keyHandler); |
| | | } |
| | | |
| | | document.removeEventListener('click', this.clickHandler); |
| | | this.subscribers.clear(); |
| | | } |
| | | } |
| | | |
| | | document.addEventListener('DOMContentLoaded', function() { |
| | | window.jvbQueue = new QueueManager(); |
| | | document.addEventListener('DOMContentLoaded', async function() { |
| | | window.auth.subscribe((event) => { |
| | | if (event === 'auth-loaded') { |
| | | window.jvbQueue = new QueueManager(); |
| | | } |
| | | }); |
| | | }); |
| | | |