| | |
| | | class QueueManager { |
| | | constructor(config = {}) { |
| | | this.canUpdateUI = true; |
| | | console.log('jvbSettings', jvbSettings); |
| | | this.config = { |
| | | apiBase: jvbSettings.api, |
| | | maxRetries: 3, |
| | |
| | | endpoint: 'queue', |
| | | ...config |
| | | }; |
| | | this.user = jvbSettings.currentUser; |
| | | console.log(this.user); |
| | | |
| | | |
| | | this.headers = { |
| | | 'X-WP-Nonce': jvbSettings.nonce, |
| | | ...config.headers |
| | | }; |
| | | |
| | | this.a11y = window.jvbA11y; |
| | | this.errors = window.jvbError; |
| | | |
| | | // Initialize DataStore for queue persistence |
| | | this.store = new window.jvbStore({ |
| | | name: 'queue', |
| | | storeName: 'operations', |
| | | keyPath: 'id', |
| | | endpoint: this.config.endpoint, |
| | | TTL: Infinity, |
| | | indexes: [ |
| | | {name: 'status', keyPath: 'status'}, |
| | | {name: 'type', keyPath: 'type'}, |
| | | ], |
| | | showLoading: false, |
| | | getBlobs: async (ids) => { |
| | | if (window.jvbUploadBlobs) { |
| | | if (!Array.isArray(ids) && typeof ids === 'string') { |
| | | ids = [ids]; |
| | | } |
| | | // Get individual blobs (not all items) |
| | | const blobs = await Promise.all( |
| | | ids.map(id => window.jvbUploadBlobs.getBlob(id)) |
| | | ); |
| | | return blobs.filter(Boolean); // Remove nulls |
| | | } |
| | | return null; |
| | | } |
| | | }); |
| | | |
| | | this.classes = [ |
| | | 'offline', |
| | | 'synced', |
| | | 'pending' |
| | | ]; |
| | | |
| | | // Queue state |
| | | this.isProcessing = false; |
| | |
| | | 'failed_permanent' |
| | | ]; |
| | | |
| | | this.user = window.auth.getUser(); |
| | | |
| | | if (!this.user) { |
| | | console.log('Queue: User not logged in, queue disabled'); |
| | | this.store = null; |
| | | this.canUpdateUI = false; |
| | | return; |
| | | } |
| | | |
| | | this.headers = { |
| | | 'X-WP-Nonce': window.auth.getNonce(), |
| | | ...config.headers |
| | | }; |
| | | |
| | | this.a11y = window.jvbA11y; |
| | | this.errors = window.jvbError; |
| | | |
| | | // Initialize DataStore for queue persistence |
| | | const store = window.jvbStore.register('queue', { |
| | | storeName: 'queue', |
| | | keyPath: 'id', |
| | | endpoint: this.config.endpoint, |
| | | TTL: Infinity, |
| | | indexes: [ |
| | | {name: 'status', keyPath: 'status'}, |
| | | {name: 'type', keyPath: 'type'}, |
| | | ], |
| | | showLoading: false, |
| | | delayFetch: false, // Queue should fetch immediately |
| | | }); |
| | | this.store = store.queue; |
| | | |
| | | this.classes = [ |
| | | 'offline', |
| | | 'synced', |
| | | 'pending' |
| | | ]; |
| | | |
| | | |
| | | |
| | | // Initialize |
| | | this.initUI(); |
| | | this.initListeners(); |
| | | console.log(this.ui); |
| | | if (this.ui.panel) { |
| | | this.popup = new window.jvbPopup({ |
| | | popup: this.ui.panel, |
| | |
| | | name: 'Queue Panel', |
| | | }); |
| | | } |
| | | |
| | | this.updateUI = () => window.debouncer.schedule('queue-ui-update', this._updateUI.bind(this), 100); |
| | | this.initQueue(); |
| | | |
| | | if (this.user) { |
| | | this.ui.toggle.hidden = false; |
| | | this.ui.panel.hidden = false; |
| | | } |
| | | } |
| | | |
| | | async initQueue() { |
| | | const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false) |
| | | |
| | | if (incomplete.length > 0) { |
| | | this.startPolling(); |
| | | } else { |
| | | let polling = this.maybeStartPolling(); |
| | | if (!polling) { |
| | | this.updateStatusPanel('synced'); |
| | | } |
| | | |
| | | |
| | | this.store.subscribe((event, data) => { |
| | | switch (event) { |
| | | case 'data-loaded': |
| | | // Initial load from IndexedDB |
| | | const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false); |
| | | if (incomplete.length > 0) { |
| | | this.startPolling(); |
| | | } |
| | | case 'items-saved': |
| | | this.maybeStartPolling(); |
| | | this.updateUI(); |
| | | break; |
| | | case 'item-saved': |
| | | if (this.hasQueuedOperations()) { |
| | | this.startPolling(); |
| | | console.log(data,'Item saved data'); |
| | | if (data.previousItem && data.previousItem.status !== data.item.status) { |
| | | this.handleOperationStatusChange(data.item, data.previousItem.status); |
| | | } |
| | | this.maybeStartPolling(); |
| | | break; |
| | | default: |
| | | this.updateUI(); |
| | | break; |
| | | } |
| | | |
| | | }); |
| | | this.notify('queue-initialized', {operations: incomplete}); |
| | | } |
| | | |
| | | maybeStartPolling() |
| | | { |
| | | const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false); |
| | | if (incomplete.length > 0) { |
| | | this.startPolling(); |
| | | return true; |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Handle operation status changes and notify subscribers |
| | | */ |
| | | handleOperationStatusChange(operation) { |
| | | |
| | | // Notify based on new status |
| | | switch(operation.status) { |
| | | case 'completed': |
| | | console.log(operation); |
| | | this.notify('operation-completed', operation); |
| | | break; |
| | | case 'failed': |
| | | this.notify('operation-failed', operation); |
| | | break; |
| | | case 'failed_permanent': |
| | | this.notify('operation-failed-permanent', operation); |
| | | break; |
| | | } |
| | | } |
| | | /** |
| | | * |
| | | * @param {object} operation |
| | | * @param {string} operatio n.endpoint The endpoint, excluding the apiBase |
| | | * @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 |
| | |
| | | method: 'POST', |
| | | headers: {}, |
| | | data: {}, |
| | | sendNow: false, // true = process immediately |
| | | canMerge: true, |
| | | popup: 'Saving changes...', |
| | | title: 'Operation', |
| | |
| | | return null; |
| | | } |
| | | |
| | | if (item.sendNow) { |
| | | this.processOperation(item).then(()=> {}); |
| | | this.store.clearCache(); |
| | | window.debouncer.schedule('fastQueue', this.startPolling.bind(this), 200); |
| | | this.showQueue(); |
| | | return item.id; |
| | | } |
| | | |
| | | const existingOps = Array.from(this.store.data.values()).filter(op=> |
| | | op.status === 'queued' && |
| | | op.endpoint === item.endpoint && |
| | |
| | | return existing.id; |
| | | } |
| | | |
| | | console.log('Added to Queue: ', item); |
| | | this.store.clearCache(); |
| | | |
| | | //Add new operation to DataStore |
| | | this.setQueue(item); |
| | |
| | | |
| | | } |
| | | |
| | | |
| | | setQueue(item) { |
| | | this.store.save(item); // Remove first parameter |
| | | this.store.save(item); |
| | | } |
| | | |
| | | updateOperationStatus(itemID, status) { |
| | | let item = this.store.get(itemID); |
| | | if (!item){ |
| | | return; |
| | | } |
| | | if (!item) return; |
| | | |
| | | // Update status |
| | | item.status = status; |
| | | |
| | | this.notify('operation-status', item); |
| | |
| | | } |
| | | |
| | | resetActivityTimer() { |
| | | this.lastActivity = Date.now(); |
| | | |
| | | if (this.activityTimer) { |
| | | clearTimeout(this.activityTimer); |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | hideQueue(){ |
| | | this.ui.panel.hidden = true; |
| | | this.ui.toggle.hidden = true; |
| | | } |
| | | showQueue() { |
| | | this.ui.panel.hidden = false; |
| | | this.ui.toggle.hidden = false; |
| | | } |
| | | |
| | | setProcessing(on) { |
| | | this.isProcessing = on; |
| | | this.ui.toggle.classList.toggle('saving', on); |
| | |
| | | this.setProcessing(false); |
| | | this.stopActivityTracking(); |
| | | |
| | | const pending = this.getOperationsByStatus(['queued', 'completed', 'failed_permanent'], false); |
| | | if (pending.length > 0) { |
| | | this.startPolling(); |
| | | } |
| | | this.maybeStartPolling() ? this.showQueue() : this.hideQueue(); |
| | | } |
| | | |
| | | async processOperation(operation) { |
| | | async processOperation(operation, skip = false) { |
| | | try { |
| | | //update to uploading |
| | | this.updateOperationStatus(operation.id, 'uploading'); |
| | | if (!skip) { |
| | | this.updateOperationStatus(operation.id, 'uploading'); |
| | | |
| | | // Get fresh copy from store to restore FormData |
| | | operation = this.getQueue(operation.id); |
| | | if (operation.data?._isFormData) { |
| | | operation.data = await this.store.objectToFormData(operation.data); |
| | | } |
| | | } |
| | | |
| | | //build request |
| | | const url = `${this.config.apiBase}${operation.endpoint}`; |
| | | let requestBody; |
| | | console.log(operation.data); |
| | | |
| | | if (operation.data instanceof FormData) { |
| | | operation.data.append('id', operation.id); |
| | | operation.data.append('user', this.user); |
| | | requestBody = operation.data; |
| | | // console.log('Sending formData: '); |
| | | // for (const pair of requestBody.entries()) { |
| | | // console.log(pair[0], pair[1]); |
| | | // } |
| | | |
| | | console.log('Sending to server:'); |
| | | for (var [key, value] of requestBody.entries()) { |
| | | console.log(key, value); |
| | | } |
| | | } else { |
| | | requestBody = JSON.stringify({ |
| | | ...operation.data, |
| | | id: operation.id, |
| | | user: this.user |
| | | }); |
| | | // console.log('Sending data: ', { |
| | | // ...operation.data, |
| | | // id: operation.id, |
| | | // user: this.user |
| | | // }); |
| | | operation.headers['Content-Type'] = 'application/json'; |
| | | } |
| | | |
| | | |
| | | |
| | | const response = await fetch(url, { |
| | | method: operation.method, |
| | | headers: operation.headers, |
| | |
| | | }); |
| | | |
| | | const result = await response.json(); |
| | | |
| | | if (skip) { |
| | | operation.data = {}; |
| | | } |
| | | 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); |
| | | } |
| | | operation = await this.handleServerMerge(operation, result); |
| | | } else { |
| | | // Normal processing - no merge |
| | | operation.status = 'pending'; |
| | | operation.status = result.status || 'pending'; |
| | | operation.serverData = result; |
| | | this.updateOperationStatus(operation.id, 'pending'); |
| | | this.setQueue(operation); |
| | | this.updateOperationStatus(operation.id, operation.status); |
| | | } |
| | | |
| | | this.a11y.announce(`${operation.title} sent to server for processing.`); |
| | |
| | | } |
| | | } |
| | | |
| | | async handleServerMerge(operation, result) { |
| | | const existingOp = this.getQueue(result.id); |
| | | |
| | | if (existingOp) { |
| | | // Merge with existing local operation |
| | | existingOp.data = window.deepMerge(existingOp.data, operation.data); |
| | | existingOp.status = result.status || 'pending'; |
| | | existingOp.serverData = result; |
| | | this.updateOperationStatus(existingOp.id, existingOp.status); |
| | | this.removeOperationFromUI(operation.id); |
| | | this.clearQueue(operation.id); |
| | | return existingOp; |
| | | } else { |
| | | // Server merged with unknown operation |
| | | this.clearQueue(operation.id); |
| | | operation.id = result.id; |
| | | operation.status = result.status || 'pending'; |
| | | operation.serverData = result; |
| | | this.updateOperationStatus(operation.id, operation.status); |
| | | return operation; |
| | | } |
| | | } |
| | | |
| | | startPolling() { |
| | | if (this.isPolling) return; |
| | | |
| | |
| | | |
| | | this.pollTimer = setInterval(async () => { |
| | | try { |
| | | this.store.clearCache(); |
| | | await this.store.fetch(); // Fetches from server, updates store.data |
| | | |
| | | const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false); |
| | | if (incomplete.length === 0) { |
| | | if (!this.maybeStartPolling()) { |
| | | this.stopPolling(); |
| | | this.updateStatusPanel('synced'); |
| | | } |
| | |
| | | this.countdownTimer = null; |
| | | } |
| | | } |
| | | |
| | | getOperationIds(operations) { |
| | | return operations.map(op => op.id); |
| | | } |
| | | /*********************************************************** |
| | | USER ACTIONS |
| | | ***********************************************************/ |
| | |
| | | * @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) => { |
| | | 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 (ids.length === 0) return; |
| | | |
| | | if (['cancel', 'dismiss'].includes(action)) { |
| | | ids.forEach(id => { |
| | | this.removeOperationFromUI(id); |
| | | }); |
| | | // SINGLE place to handle UI removal |
| | | const shouldRemove = ['cancel', 'dismiss'].includes(action); |
| | | if (shouldRemove) { |
| | | 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}) |
| | | } |
| | | ); |
| | | const response = await fetch(`${this.config.apiBase}${this.config.endpoint}`, { |
| | | method: 'POST', |
| | | headers: { 'Content-Type': 'application/json', ...this.headers }, |
| | | body: JSON.stringify({ ids, action, user: window.auth.getUser() }) |
| | | }); |
| | | |
| | | if (!response.ok) { |
| | | const errorData = await response.json().catch(()=>{}); |
| | | throw new Error(errorData.message || `${action} failed: ${response.status}`); |
| | | throw new Error(`${action} failed: ${response.status}`); |
| | | } |
| | | |
| | | const result = await response.json(); |
| | |
| | | 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); |
| | | // SINGLE place to handle store updates |
| | | ids.forEach(id => { |
| | | let item = this.getQueue(id); |
| | | this.notify(`${action}-operation`, item); |
| | | |
| | | if (shouldRemove) { |
| | | this.clearQueue(id); |
| | | } else { |
| | | item.status = 'queued'; |
| | | item.retries = 0; |
| | | this.setQueue(item); |
| | | this.updateOperationStatus(item.id, item.status); |
| | | }); |
| | | } |
| | | }); |
| | | |
| | | if (action === 'retry') { |
| | | this.startActivityTracking(); |
| | | } |
| | | this.updateUI(); |
| | | |
| | | this.updateUI(); |
| | | return result; |
| | | |
| | | } catch (error) { |
| | | const result = await window.jvbError.log(error, { |
| | | // Log and let jvbError handle retry |
| | | await window.jvbError.log(error, { |
| | | component: 'QueueManager', |
| | | operation: 'performQueueAction', |
| | | action: action, |
| | | operationIds: ids, |
| | | itemCount: ids.length |
| | | }, () => this.updateServerOperations(ids, action)); // Retry callback |
| | | }, () => this.updateServerOperations(ids, action)); |
| | | |
| | | if (result.retried) { |
| | | return result; // Return successful retry result |
| | | } else { |
| | | throw error; // Re-throw if not retried |
| | | } |
| | | // Don't re-throw - error is logged and handled |
| | | return { success: false, error: error.message }; |
| | | } |
| | | } |
| | | |
| | |
| | | *********************************************/ |
| | | initListeners() { |
| | | this.clickHandler = this.handleClick.bind(this); |
| | | this.changeHandler = this.handleChange.bind(this); |
| | | |
| | | document.addEventListener('click', this.clickHandler); |
| | | this.ui.panel?.addEventListener('change', this.changeHandler); |
| | | |
| | | this.handleOnline = () => { |
| | | this.updateStatusPanel(); |
| | |
| | | }; |
| | | this.handleOffline = () => this.updateStatusPanel('offline'); |
| | | this.handleBeforeUnload = (e) => { |
| | | const hasPending = this.getOperationsByStatus(['queued', 'uploading']); |
| | | if (hasPending.length > 0) { |
| | | if (this.isPolling || this.isProcessing) { |
| | | e.preventDefault(); |
| | | return 'You have unsaved changes in the queue.'; |
| | | return 'You have unsaved changes in the queue. Proceed?'; |
| | | } |
| | | }; |
| | | |
| | |
| | | return; |
| | | } |
| | | if (e.target.closest(this.selectors.refreshButton)) { |
| | | this.store.clearCache(); |
| | | this.store.clearHttpHeaders(); // Clear cached headers first |
| | | this.store.fetch(); |
| | | } else if (e.target.closest(this.selectors.clearButton)) { |
| | | const completedOps = this.getOperationsByStatus('completed'); |
| | | const completedOps = this.getOperationIds(this.getOperationsByStatus('completed')); |
| | | if (completedOps.length > 0) { |
| | | const ids = completedOps.map(op => op.id); |
| | | this.updateServerOperations(ids, 'dismiss'); |
| | | this.updateServerOperations(completedOps, 'dismiss'); |
| | | } |
| | | } else if (e.target.closest(this.selectors.retryButton)) { |
| | | const failedOps = this.getOperationsByStatus('failed'); |
| | | const failedOps = this.getOperationIds(this.getOperationsByStatus('failed')); |
| | | if (failedOps.length > 0) { |
| | | const ids = failedOps.map(op => op.id); |
| | | this.updateServerOperations(ids, 'retry'); |
| | | this.updateServerOperations(failedOps, 'retry'); |
| | | } |
| | | } else if (e.target.closest('[data-action]')) { |
| | | const button = e.target.closest('[data-action]'); |
| | |
| | | |
| | | } |
| | | |
| | | handleChange(e) { |
| | | } |
| | | |
| | | /********************************************* |
| | | UI |
| | | *********************************************/ |
| | |
| | | } |
| | | }; |
| | | |
| | | 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), |
| | | }; |
| | | this.ui = window.uiFromSelectors(this.selectors); |
| | | 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); |
| | | } |
| | | } |
| | | } |
| | | |
| | | updateUI() { |
| | | _updateUI() { |
| | | if (!this.canUpdateUI) { |
| | | return; |
| | | } |
| | | const stats = this.getQueueStats(); |
| | | |
| | | // Get current operations from store |
| | | const operations = Array.from(this.store.data.values()); |
| | | |
| | | // Get stats from last fetch response (server-provided) |
| | | const stats = this.store.lastResponse?.queue_stats || { |
| | | queued: 0, |
| | | localProcessing: 0, |
| | | uploading: 0, |
| | | pending: 0, |
| | | processing: 0, |
| | | completed: 0, |
| | | failed: 0, |
| | | failed_permanent: 0 |
| | | }; |
| | | |
| | | // 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'; |
| | | const activeCount = operations.length - stats.completed; |
| | | this.ui.count.textContent = activeCount > 0 ? activeCount : ''; |
| | | this.ui.count.style.display = activeCount > 0 ? '' : 'none'; |
| | | } |
| | | |
| | | // Update indicator |
| | |
| | | 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; |
| | | |
| | | // Update filter counts |
| | | // Update button states |
| | | this.ui.clearButton.disabled = this.getOperationsByStatus('completed').length === 0; |
| | | this.ui.retryButton.disabled = this.getOperationsByStatus('failed').length === 0 && this.getOperationsByStatus('failed_permanent').length === 0; |
| | | |
| | | // Update filter counts (from server stats) |
| | | Object.entries(this.ui.filters).forEach(([status, button]) => { |
| | | const count = status === 'all' ? stats.total : stats[status] || 0; |
| | | const count = status === 'all' |
| | | ? operations.length |
| | | : stats[status] || 0; |
| | | const countEl = button.querySelector('.count'); |
| | | if (countEl) { |
| | | countEl.textContent = count > 0 ? count : ''; |
| | |
| | | button.setAttribute('data-count', count); |
| | | }); |
| | | |
| | | // Update operation list |
| | | // Render current operations |
| | | this.renderOperations(); |
| | | } |
| | | |
| | |
| | | return statusProgress[item.status] || 0; |
| | | } |
| | | |
| | | getQueueStats() { |
| | | const stats = {}; |
| | | this.statuses.forEach(status => { |
| | | stats[status] = 0; |
| | | }); |
| | | |
| | | Array.from(this.store.data.values()) // Change items to data |
| | | .forEach(op => { |
| | | if (stats.hasOwnProperty(op.status)) { |
| | | stats[op.status]++; |
| | | } |
| | | }); |
| | | |
| | | stats.total = Array.from(this.store.data.values()).length; // Change items to data |
| | | |
| | | return stats; |
| | | } |
| | | |
| | | renderOperations() { |
| | | if (!this.ui.itemsContainer) return; |
| | | |
| | | const activeFilter = this.getActiveFilter(); |
| | | const operations = this.getFilteredOperations(activeFilter); |
| | | const operations = this.store.getFiltered(); |
| | | |
| | | // Clear container |
| | | window.removeChildren(this.ui.itemsContainer); |
| | | |
| | | // Render each operation |
| | | // Render operations or empty state |
| | | 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); |
| | | this.ui.itemsContainer.append(element); |
| | | }); |
| | | } |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | 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); |
| | | } |
| | | } |
| | | }, 1000); |
| | | } |
| | | |
| | | updateStatusPanel(status) { |
| | | this.ui.panel?.classList.remove(...this.classes); |
| | | if (!this.classes.includes(status)) { |
| | |
| | | FILTERS |
| | | **************************************************/ |
| | | setFilter(filter) { |
| | | // Update active button |
| | | 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.data.values()); // Change items to data |
| | | |
| | | if (filter === 'all') { |
| | | return operations; |
| | | this.store.clearFilters(); |
| | | } else { |
| | | this.store.setFilter('status', filter); |
| | | } |
| | | |
| | | return operations.filter(op => op.status === filter); |
| | | } |
| | | |
| | | /************************************************************************** |
| | | NOTIFICATIONS |
| | | **************************************************************************/ |
| | | showPopup(message, type = 'success') { |
| | | if (!this.ui.popup) return; |
| | | |
| | | const span = this.ui.popup.querySelector('span'); |
| | | if (span) { |
| | | span.textContent = message; |
| | | } |
| | | |
| | | this.ui.popup.className = `popup ${type} show`; |
| | | |
| | | setTimeout(() => { |
| | | this.ui.popup.classList.remove('show'); |
| | | }, 3000); |
| | | } |
| | | /************************************************************************** |
| | | HELPERS |
| | | **************************************************************************/ |
| | | getOperationsByStatus(status, include = true) { |
| | |
| | | ? Array.from(this.store.data.values()).filter((item) => status.includes(item.status)) |
| | | : Array.from(this.store.data.values()).filter((item) => !status.includes(item.status)); |
| | | } |
| | | async hasQueuedOperations() { |
| | | const queued = await this.store.query('status', 'queued'); |
| | | return queued.length > 0; |
| | | hasQueuedOperations() { |
| | | return this.getOperationsByStatus('queued').length > 0; |
| | | } |
| | | subscribe(callback) { |
| | | if (!this.subscribers) { |
| | | return; |
| | | } |
| | | this.subscribers.add(callback); |
| | | return () => this.subscribers.delete(callback); |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | 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(); |
| | | } |
| | | }); |
| | | }); |