class LivePanel { constructor(element) { this.element = element; this.url = element.dataset.refreshUrl; const requestedInterval = Number(element.dataset.refreshInterval || 2000); this.baseInterval = Number.isFinite(requestedInterval) && requestedInterval > 0 ? requestedInterval : 2000; const minIntervalAttr = Number(element.dataset.refreshMinInterval); const maxIntervalAttr = Number(element.dataset.refreshMaxInterval); this.minInterval = Number.isFinite(minIntervalAttr) && minIntervalAttr > 0 ? minIntervalAttr : this.baseInterval; this.maxInterval = Number.isFinite(maxIntervalAttr) && maxIntervalAttr > 0 ? Math.max(maxIntervalAttr, this.minInterval) : this.baseInterval * 4; this.currentInterval = this.baseInterval; this.lastEtag = null; this.lastModified = null; this.timeoutId = null; this.isFetching = false; if (!this.url) { console.warn('LivePanel requires a data-refresh-url attribute.'); return; } this.renderLoadingState(); this.start(); LivePanel.instances.push(this); } renderLoadingState() { if (!this.element.querySelector('.panel-loading')) { this.element.innerHTML = `
Loading latest data…
`; } } start() { this.stop(); this.currentInterval = this.baseInterval; this.fetchData(); } stop() { if (this.timeoutId) { window.clearTimeout(this.timeoutId); this.timeoutId = null; } } scheduleNextFetch(delay) { if (this.baseInterval <= 0) { return; } if (this.timeoutId) { window.clearTimeout(this.timeoutId); } let nextDelay = typeof delay === 'number' ? delay : this.currentInterval; if (nextDelay !== 0) { nextDelay = Math.min(Math.max(nextDelay, this.minInterval), this.maxInterval); } this.timeoutId = window.setTimeout(() => this.fetchData(), nextDelay); } async fetchData() { if (this.isFetching) { return; } this.isFetching = true; try { const headers = { 'X-Requested-With': 'XMLHttpRequest' }; if (this.lastEtag) { headers['If-None-Match'] = this.lastEtag; } if (this.lastModified) { headers['If-Modified-Since'] = this.lastModified; } const response = await fetch(this.url, { headers, cache: 'no-cache' }); if (response.status === 304) { this.currentInterval = Math.min(this.currentInterval * 1.25, this.maxInterval); return; } if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const etag = response.headers.get('ETag'); if (etag) { this.lastEtag = etag; } const lastModified = response.headers.get('Last-Modified'); if (lastModified) { this.lastModified = lastModified; } const html = await response.text(); if (html.trim().length) { this.element.innerHTML = html; } const updateEvent = new CustomEvent('livepanel:updated', { detail: { panel: this, html } }); this.element.dispatchEvent(updateEvent); this.currentInterval = this.baseInterval; } catch (error) { console.error('Failed to refresh panel', error); this.element.innerHTML = ` `; this.lastEtag = null; this.lastModified = null; this.currentInterval = Math.min( Math.max(this.currentInterval * 1.5, this.minInterval), this.maxInterval ); } finally { this.isFetching = false; if (this.baseInterval > 0) { this.scheduleNextFetch(this.currentInterval); } } } static pauseAll() { LivePanel.instances.forEach((panel) => panel.stop()); } static resumeAll() { LivePanel.instances.forEach((panel) => panel.start()); } } LivePanel.instances = []; function initialiseLivePanels() { const panels = document.querySelectorAll('[data-refresh-url]'); panels.forEach((panel) => { if (!panel.dataset.livePanelInitialised) { panel.dataset.livePanelInitialised = 'true'; new LivePanel(panel); } }); } function setupBackToTop() { const backToTop = document.querySelector('[data-back-to-top]'); if (!backToTop) { return; } const toggleVisibility = () => { if (window.scrollY > 320) { backToTop.classList.add('is-visible'); } else { backToTop.classList.remove('is-visible'); } }; window.addEventListener('scroll', toggleVisibility, { passive: true }); backToTop.addEventListener('click', (event) => { event.preventDefault(); window.scrollTo({ top: 0, behavior: 'smooth' }); }); } function setupMobileNav() { const toggle = document.querySelector('[data-menu-toggle]'); const sidebar = document.querySelector('.app-sidebar'); if (!toggle || !sidebar) { return; } toggle.addEventListener('click', () => { const expanded = toggle.getAttribute('aria-expanded') === 'true'; toggle.setAttribute('aria-expanded', expanded ? 'false' : 'true'); sidebar.classList.toggle('is-open'); }); } window.popupCenter = function popupCenter(pageURL, title = 'Details', width = 900, height = 640) { const screenLeft = window.screenLeft !== undefined ? window.screenLeft : window.screenX; const screenTop = window.screenTop !== undefined ? window.screenTop : window.screenY; const dualScreenWidth = window.innerWidth || document.documentElement.clientWidth || screen.width; const dualScreenHeight = window.innerHeight || document.documentElement.clientHeight || screen.height; const left = screenLeft + Math.max(0, (dualScreenWidth - width) / 2); const top = screenTop + Math.max(0, (dualScreenHeight - height) / 2); const features = [ 'toolbar=no', 'location=no', 'directories=no', 'status=no', 'menubar=no', 'scrollbars=yes', 'resizable=yes', `width=${width}`, `height=${height}`, `top=${top}`, `left=${left}` ].join(','); const newWindow = window.open(pageURL, title, features); if (newWindow && newWindow.focus) { newWindow.focus(); } return newWindow; }; window.popitup = function popitup(url, title = 'Details') { const popup = window.open(url, title, 'width=420,height=520,scrollbars=yes'); if (popup && popup.focus) { popup.focus(); } return false; }; window.addEventListener('DOMContentLoaded', () => { initialiseLivePanels(); setupBackToTop(); setupMobileNav(); document.addEventListener('visibilitychange', () => { if (document.hidden) { LivePanel.pauseAll(); } else { LivePanel.resumeAll(); } }); });