262 lines
6.8 KiB
JavaScript
262 lines
6.8 KiB
JavaScript
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 = `
|
|
<div class="panel-loading" role="status" aria-live="polite">
|
|
<span class="spinner" aria-hidden="true"></span>
|
|
<span>Loading latest data…</span>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
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 = `
|
|
<div class="panel-error" role="alert">
|
|
<strong>Unable to load data.</strong>
|
|
<span>Please check your connection.</span>
|
|
</div>
|
|
`;
|
|
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();
|
|
}
|
|
});
|
|
});
|