| | |
| | | |
| | | this.user = window.auth.getUser(); |
| | | |
| | | |
| | | this.canUpdateUI = true; |
| | | this.isProcessing = false; |
| | | this.isPolling = false; |
| | |
| | | this.api = jvbSettings.api; |
| | | this.endpoint = 'queue'; |
| | | |
| | | this.queueItems = new Map(); |
| | | |
| | | this.init(); |
| | | } |
| | | init() { |
| | |
| | | this.initElements(); |
| | | this.initListeners(); |
| | | this.initStore(); |
| | | if (this.canUpdateUI) { |
| | | if (this.canUpdateUI && this.ui.panel) { |
| | | this.popup = new window.jvbPopup({ |
| | | popup: this.ui.panel, |
| | | toggle: this.ui.toggle.button, |
| | | name: 'Queue Panel', |
| | | }); |
| | | } |
| | | this.defineTemplates(); |
| | | } |
| | | |
| | | initElements() { |
| | |
| | | actions: { |
| | | cancel: 'button.cancel', |
| | | retry: 'button.retry', |
| | | refresh: 'button.refresh', |
| | | dismiss: 'button.dismiss', |
| | | } |
| | | }, |
| | |
| | | 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.updatePanel('offline'); |
| | | } |
| | | 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 |
| | |
| | | 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(); |
| | | 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; |
| | | } |
| | | |
| | |
| | | {name: 'status', keyPath: 'status'}, |
| | | {name: 'type', keyPath: 'type'}, |
| | | ], |
| | | filters: { |
| | | user: window.auth.getUser() |
| | | }, |
| | | showLoading: false, |
| | | } |
| | | ) |
| | |
| | | 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.previousItem && data.previousItem.status !== data.item.status) { |
| | | this.updateOperationStatus(data.item.id, data.item.status); |
| | | 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; |
| | | default: |
| | | |
| | | break; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * 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 |
| | | ****************************************************************************/ |
| | |
| | | |
| | | const existingOps = Array.from(this.getAllQueue()).filter(op=> { |
| | | return op.status === 'queued' && |
| | | op.endpoint === item.endpoint && |
| | | op.canMerge |
| | | 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.trackActivity(); |
| | |
| | | } |
| | | if (statusOrId.length ===0) return; |
| | | if (!['cancel', 'dismiss', 'retry'].includes(action)) return; |
| | | |
| | | const shouldRemove = ['cancel', 'dismiss'].includes(action); |
| | | if (shouldRemove) { |
| | | statusOrId.forEach(id => this.removeOperationUI(id)); |
| | | statusOrId.forEach(id => { |
| | | this.removeOperationUI(id) |
| | | }); |
| | | } |
| | | |
| | | try { |
| | |
| | | } |
| | | statusOrId.forEach(id => { |
| | | let item = this.getQueue(id); |
| | | this.notify(`${action}-operation`, item); |
| | | if (item) { |
| | | this.notify(`${action}-operation`, item); |
| | | } |
| | | |
| | | if (shouldRemove) { |
| | | this.clearQueue(id); |
| | | } else { |
| | |
| | | let requestBody; |
| | | if (operation.data instanceof FormData) { |
| | | operation.data.append('id', operation.id); |
| | | operation.data.append('user', this.user); |
| | | operation.data.append('user', window.auth.getUser()); |
| | | requestBody = operation.data; |
| | | } else { |
| | | requestBody = JSON.stringify({ |
| | | ...operation.data, |
| | | id: operation.id, |
| | | user: this.user |
| | | user: window.auth.getUser() |
| | | }); |
| | | operation.headers['Content-Type'] = 'application/json'; |
| | | } |
| | |
| | | if (!this.isPolling) return; |
| | | |
| | | try { |
| | | 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'); |
| | |
| | | } |
| | | |
| | | 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; |
| | | |
| | |
| | | |
| | | if (sortedOps.length === 0) { |
| | | window.removeChildren(this.ui.items.container); |
| | | const empty = window.getTemplate('emptyQueue'); |
| | | const empty = window.jvbTemplates.create('emptyQueue'); |
| | | this.ui.items.container.append(empty); |
| | | this.a11y.announce('No items in queue'); |
| | | return; |
| | |
| | | } |
| | | |
| | | createOperationElement(op) { |
| | | const el = window.getTemplate('queueItem'); |
| | | el.dataset.id = op.id; |
| | | |
| | | const el = window.jvbTemplates.create('queueItem', op); |
| | | const item = { |
| | | element: el, |
| | | ui: window.uiFromSelectors(this.selectors.item, el) |
| | |
| | | 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'; |
| | | } |
| | | } |
| | | getProgress(op) { |
| | | if (op.progress) return op.progress; |
| | |
| | | } |
| | | |
| | | updatePanel(status = 'syncing') { |
| | | if (!this.panelStatuses.includes(status)) return; |
| | | if (!this.ui.panel || !this.panelStatuses.includes(status)) return; |
| | | this.ui.panel.classList.remove(...this.panelStatuses); |
| | | this.ui.panel.classList.add(status); |
| | | } |
| | |
| | | case 'processing': |
| | | return item.progress ? `${item.progress}% complete` : 'Processing...'; |
| | | case 'completed': |
| | | return 'Successfully completed'; |
| | | return 'Successfully completed. Refresh to see changes.'; |
| | | case 'failed': |
| | | return `Failed: ${item.lastError || 'Unknown error'} (Retry ${item.retries}/${this.config.maxRetries})`; |
| | | return `Failed: ${item.lastError || 'Unknown error'} (Retry ${item.retries}/${2})`; |
| | | case 'failed_permanent': |
| | | return `Failed: ${item.lastError || 'Unknown error'}`; |
| | | default: |
| | |
| | | } |
| | | } |
| | | toggleQueue(on = true) { |
| | | if (!this.ui.panel) return; |
| | | this.ui.panel.hidden = !on; |
| | | this.ui.toggle.button.hidden = !on; |
| | | } |
| | |
| | | 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) { |
| | | return { |
| | | ...localOp, |
| | | ...serverOp, |
| | | endpoint: localOp.endpoint, |
| | | method: localOp.method, |
| | | headers: localOp.headers, |
| | | }; |
| | | } |
| | | |
| | | // 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'; |
| | | |
| | | return { |
| | | ...serverOp, |
| | | endpoint: endpoint, |
| | | method: 'POST', |
| | | headers: { ...this.headers }, |
| | | }; |
| | | } |
| | | |
| | | /**************************************************************************** |
| | | SUBSCRIPTION |
| | | ****************************************************************************/ |