603 lines
23 KiB
JavaScript
Executable file
603 lines
23 KiB
JavaScript
Executable file
// app.js — entry point: initialises all modules, wires scan flow and mode switching.
|
|
import CONFIG from './config.js';
|
|
import { getAsset, searchAssets, updateAsset } from './api/syncro.js';
|
|
import { initScanner, resetIdleTimer, focusScanInput, cancelIdleTimer, setTimerDuration, getTimerDuration, setKeyInterceptor, pause as pauseScanner, resume as resumeScanner } from './modules/scanner.js';
|
|
import { init as initCameraScanner, start as startCamera, stop as stopCamera, isActive as isCameraActive } from './modules/cameraScanner.js';
|
|
import { renderAssetCard } from './modules/assetCard.js';
|
|
import { initActions } from './modules/actions.js';
|
|
import { initTicketHistory } from './modules/ticketHistory.js';
|
|
import { showToast } from './modules/toast.js';
|
|
import { initAssetBrowser, setActiveAsset, setFiltersAndRender } from './modules/assetBrowser.js';
|
|
import { initSearchAutocomplete, handleAutocompleteKey } from './modules/searchAutocomplete.js';
|
|
import { initLabelCenter, closeLabelCenter, refreshBadge } from './modules/labelCenter.js';
|
|
import { renderClientDashboard } from './modules/clientDashboard.js';
|
|
|
|
// ── View management ───────────────────────────────────────────────────────────
|
|
|
|
const VIEWS = ['neutral', 'idle', 'loading', 'asset', 'search-results', 'quick-view', 'error'];
|
|
|
|
// ── Timer cycle ───────────────────────────────────────────────────────────────
|
|
const TIMER_STEPS = [30_000, 60_000, 300_000, 900_000, null];
|
|
const TIMER_LABELS = ['Timer: 30s', 'Timer: 1m', 'Timer: 5m', 'Timer: 15m', 'Timer: OFF'];
|
|
|
|
function _updateTimerButton(ms) {
|
|
const idx = TIMER_STEPS.indexOf(ms);
|
|
document.getElementById('timer-toggle-label').textContent = TIMER_LABELS[idx] ?? 'Timer: OFF';
|
|
document.getElementById('btn-timer-toggle')?.classList.toggle('active', ms !== null);
|
|
}
|
|
|
|
// Views worth returning to when closing an asset card
|
|
const _BACK_VIEWS = new Set(['neutral', 'idle', 'search-results', 'quick-view']);
|
|
let _returnView = 'neutral';
|
|
|
|
export function showView(name) {
|
|
// Capture where we came from so closeAssetView() can go back there
|
|
if (name === 'asset') {
|
|
const current = VIEWS.find(v => document.getElementById(`view-${v}`)?.classList.contains('active'));
|
|
if (current && _BACK_VIEWS.has(current)) _returnView = current;
|
|
}
|
|
VIEWS.forEach(v => {
|
|
const el = document.getElementById(`view-${v}`);
|
|
if (el) el.classList.toggle('active', v === name);
|
|
});
|
|
if (name === 'quick-view') {
|
|
document.getElementById('btn-quick-view')?.classList.add('active');
|
|
} else {
|
|
document.getElementById('btn-quick-view')?.classList.remove('active');
|
|
}
|
|
}
|
|
|
|
export function closeAssetView() {
|
|
setActiveAsset(null);
|
|
const card = document.querySelector('.asset-card');
|
|
if (card) {
|
|
card.classList.add('closing');
|
|
setTimeout(() => showView(_returnView), 180);
|
|
} else {
|
|
showView(_returnView);
|
|
}
|
|
}
|
|
|
|
// ── Scan mode state ───────────────────────────────────────────────────────────
|
|
|
|
let scanModeActive = false;
|
|
|
|
function setScanMode(active) {
|
|
scanModeActive = active;
|
|
setActiveMode(active ? 'scan' : null);
|
|
localStorage.setItem('appMode', active ? 'scan' : 'label');
|
|
}
|
|
|
|
// ── Scan flow ─────────────────────────────────────────────────────────────────
|
|
|
|
async function handleScan(rawValue) {
|
|
const value = rawValue.trim();
|
|
if (!value) return;
|
|
|
|
showView('loading');
|
|
|
|
try {
|
|
if (/^\d+$/.test(value)) {
|
|
await resolveById(parseInt(value, 10));
|
|
} else {
|
|
await resolveBySearch(value);
|
|
}
|
|
} catch (err) {
|
|
showView('error');
|
|
document.getElementById('error-container').innerHTML = errorCardHTML(
|
|
'Asset Not Found',
|
|
err.message,
|
|
value
|
|
);
|
|
}
|
|
}
|
|
|
|
async function resolveById(id) {
|
|
let asset;
|
|
try {
|
|
asset = await getAsset(id);
|
|
} catch (err) {
|
|
if (err.message.includes('404') || err.message.toLowerCase().includes('not found')) {
|
|
await resolveBySearch(String(id));
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
await showAsset(asset);
|
|
}
|
|
|
|
async function resolveBySearch(query) {
|
|
const assets = await searchAssets(query);
|
|
if (!assets.length) {
|
|
throw new Error(`No asset found matching "${query}"`);
|
|
}
|
|
if (assets.length === 1) {
|
|
await showAsset(assets[0]);
|
|
return;
|
|
}
|
|
showSearchResults(assets, query);
|
|
}
|
|
|
|
async function showAsset(asset) {
|
|
stampScan(asset);
|
|
renderAssetCard(asset);
|
|
showView('asset');
|
|
initActions(asset);
|
|
initTicketHistory(asset);
|
|
setActiveAsset(asset.id);
|
|
resetIdleTimer();
|
|
}
|
|
|
|
function stampScan(asset) {
|
|
if (window._currentUser?.role === 'client') return;
|
|
updateAsset(asset.id, {
|
|
properties: {
|
|
'Last Scan Date': new Date().toISOString().split('T')[0],
|
|
'Last Action': asset.properties?.['Last Action'] ?? 'Scanned',
|
|
},
|
|
}).catch(() => {});
|
|
}
|
|
|
|
// ── Search results picker ─────────────────────────────────────────────────────
|
|
|
|
function showSearchResults(assets, query) {
|
|
const container = document.getElementById('search-results-container');
|
|
container.innerHTML = `
|
|
<div class="search-results-wrap">
|
|
<h3>Multiple assets match "${esc(query)}" — select one:</h3>
|
|
<div class="search-result-list">
|
|
${assets.map(a => `
|
|
<div class="search-result-item" data-asset-id="${a.id}">
|
|
<div>
|
|
<div class="search-result-name">${esc(a.name)}</div>
|
|
<div class="search-result-meta">
|
|
${esc(a.asset_type ?? '')}
|
|
${a.serial ? ` · SN: ${esc(a.serial)}` : ''}
|
|
${a.customer?.name ? ` · ${esc(a.customer.name)}` : ''}
|
|
</div>
|
|
</div>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--gray-400)" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>`;
|
|
|
|
container.querySelectorAll('.search-result-item').forEach(el => {
|
|
el.addEventListener('click', async () => {
|
|
showView('loading');
|
|
try {
|
|
const asset = await getAsset(parseInt(el.dataset.assetId, 10));
|
|
await showAsset(asset);
|
|
} catch (err) {
|
|
showToast('Failed to load asset: ' + err.message, 'error');
|
|
showView('search-results');
|
|
}
|
|
});
|
|
});
|
|
|
|
showView('search-results');
|
|
}
|
|
|
|
// ── Error card HTML ───────────────────────────────────────────────────────────
|
|
|
|
function errorCardHTML(title, message) {
|
|
return `
|
|
<div class="error-card">
|
|
<div class="error-icon">⚠</div>
|
|
<h3>${esc(title)}</h3>
|
|
<p>${esc(message)}</p>
|
|
<button class="btn btn-ghost" id="err-retry-search">Try Manual Search</button>
|
|
</div>`;
|
|
}
|
|
|
|
// ── Idle ──────────────────────────────────────────────────────────────────────
|
|
|
|
function handleIdle() {
|
|
if (!scanModeActive) return; // don't hijack the view if scan mode is off
|
|
// Close label center overlay if it happens to be open
|
|
const lcOverlay = document.getElementById('label-center-overlay');
|
|
if (lcOverlay && !lcOverlay.hidden) closeLabelCenter();
|
|
setActiveMode('scan');
|
|
showView('idle');
|
|
focusScanInput();
|
|
}
|
|
|
|
// ── Mode switching ────────────────────────────────────────────────────────────
|
|
|
|
function setActiveMode(mode) {
|
|
document.querySelectorAll('.mode-btn').forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.mode === mode);
|
|
});
|
|
}
|
|
|
|
// ── User menu ─────────────────────────────────────────────────────────────────
|
|
|
|
async function initUserMenu() {
|
|
try {
|
|
const res = await fetch('/auth/me', { credentials: 'same-origin' });
|
|
if (!res.ok) {
|
|
window.location.href = '/login.html';
|
|
return;
|
|
}
|
|
const { user } = await res.json();
|
|
|
|
const nameEl = document.getElementById('user-name');
|
|
const roleEl = document.getElementById('user-role');
|
|
if (nameEl) nameEl.textContent = user.name;
|
|
if (roleEl) {
|
|
roleEl.textContent = user.role;
|
|
roleEl.dataset.role = user.role;
|
|
}
|
|
|
|
// Show admin portal link for admin and above
|
|
if (['admin', 'superduperadmin'].includes(user.role)) {
|
|
document.getElementById('menu-admin-link')?.removeAttribute('hidden');
|
|
}
|
|
|
|
// Store on window for use by other modules if needed
|
|
window._currentUser = user;
|
|
|
|
if (user.role === 'client') {
|
|
document.body.classList.add('role-client');
|
|
const scanInput = document.getElementById('scan-input');
|
|
if (scanInput) {
|
|
scanInput.placeholder = 'Search by name, serial, or assigned user…';
|
|
scanInput.removeAttribute('inputmode');
|
|
}
|
|
// Queue the dashboard render — showView('quick-view') happens after
|
|
// DOMContentLoaded finishes its localStorage restores (which call showView)
|
|
document.getElementById('btn-quick-view')?.classList.add('active');
|
|
const container = document.getElementById('quick-view-container');
|
|
if (container) renderClientDashboard(container, user, {
|
|
onFilterSelect: ({ lifecycle, possession }) => {
|
|
setFiltersAndRender({ lifecycle, possession });
|
|
},
|
|
});
|
|
}
|
|
} catch {
|
|
window.location.href = '/login.html';
|
|
}
|
|
}
|
|
|
|
// ── App menu toggle ────────────────────────────────────────────────────────────
|
|
|
|
function initAppMenu() {
|
|
const toggle = document.getElementById('btn-menu-toggle');
|
|
const menu = document.getElementById('app-menu');
|
|
if (!toggle || !menu) return;
|
|
|
|
// Open / close
|
|
toggle.addEventListener('click', e => {
|
|
e.stopPropagation();
|
|
const opening = menu.hidden;
|
|
menu.hidden = !opening;
|
|
toggle.setAttribute('aria-expanded', String(opening));
|
|
});
|
|
|
|
// Close on outside click
|
|
document.addEventListener('click', e => {
|
|
if (!menu.hidden && !menu.contains(e.target) && e.target !== toggle && !toggle.contains(e.target)) {
|
|
menu.hidden = true;
|
|
toggle.setAttribute('aria-expanded', 'false');
|
|
}
|
|
});
|
|
|
|
// Close on Escape
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape' && !menu.hidden) {
|
|
menu.hidden = true;
|
|
toggle.setAttribute('aria-expanded', 'false');
|
|
}
|
|
});
|
|
|
|
// Close menu when any item is clicked, except toggles (leave open so state is visible)
|
|
menu.addEventListener('click', e => {
|
|
const item = e.target.closest('.app-menu-item');
|
|
if (item && item.id !== 'btn-timer-toggle' && item.id !== 'btn-scan-mode-toggle') {
|
|
menu.hidden = true;
|
|
toggle.setAttribute('aria-expanded', 'false');
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
|
|
// ── Sidebar resize ───────────────────────────────────────────────────────────
|
|
(function initSidebarResize() {
|
|
const sidebar = document.getElementById('asset-sidebar');
|
|
const handle = document.getElementById('sidebar-resize-handle');
|
|
if (!sidebar || !handle) return;
|
|
|
|
const MIN_W = 160;
|
|
const MAX_W = 600;
|
|
|
|
const saved = parseInt(localStorage.getItem('sidebar_width'), 10);
|
|
if (saved >= MIN_W && saved <= MAX_W) sidebar.style.width = saved + 'px';
|
|
|
|
handle.addEventListener('mousedown', e => {
|
|
e.preventDefault();
|
|
handle.classList.add('dragging');
|
|
document.body.classList.add('sidebar-resizing');
|
|
|
|
const appBody = document.getElementById('app-body');
|
|
|
|
const onMove = e2 => {
|
|
const rect = appBody.getBoundingClientRect();
|
|
const newW = Math.min(MAX_W, Math.max(MIN_W, e2.clientX - rect.left));
|
|
sidebar.style.width = newW + 'px';
|
|
};
|
|
const onUp = () => {
|
|
handle.classList.remove('dragging');
|
|
document.body.classList.remove('sidebar-resizing');
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
localStorage.setItem('sidebar_width', Math.round(sidebar.getBoundingClientRect().width));
|
|
};
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
});
|
|
})();
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
// Verify session and populate user menu before rendering app
|
|
await initUserMenu();
|
|
initAppMenu();
|
|
|
|
// Camera scanner — pass handleScan as the barcode callback.
|
|
// onCameraStop resets the toggle UI whenever the overlay closes (via X or after a scan).
|
|
initCameraScanner(handleScan, () => {
|
|
resumeScanner();
|
|
document.getElementById('btn-scan-mode-toggle')?.classList.remove('active');
|
|
localStorage.setItem('scanMode', 'usb');
|
|
});
|
|
|
|
// Logout
|
|
document.getElementById('btn-logout')?.addEventListener('click', async () => {
|
|
await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin' });
|
|
window.location.href = '/login.html';
|
|
});
|
|
|
|
// ── Restore persisted state ──────────────────────────────────────────────
|
|
|
|
// Scan mode (clients always get quick-view; scan mode is staff-only)
|
|
if (window._currentUser?.role === 'client') {
|
|
showView('quick-view');
|
|
} else if (localStorage.getItem('appMode') === 'scan') {
|
|
scanModeActive = true;
|
|
setActiveMode('scan');
|
|
showView('idle');
|
|
focusScanInput();
|
|
} else {
|
|
showView('neutral');
|
|
}
|
|
|
|
// Timer
|
|
const savedTimer = localStorage.getItem('timerDuration');
|
|
const timerMs = savedTimer && savedTimer !== 'off' ? Number(savedTimer) : null;
|
|
setTimerDuration(timerMs);
|
|
_updateTimerButton(timerMs);
|
|
|
|
// Camera scan mode — restore button UI only (cannot auto-start camera without a user gesture)
|
|
if (localStorage.getItem('scanMode') === 'camera') {
|
|
document.getElementById('btn-scan-mode-toggle')?.classList.add('active');
|
|
}
|
|
|
|
// ── Mode nav ─────────────────────────────────────────────────────────────
|
|
|
|
document.getElementById('btn-scan-mode')?.addEventListener('click', () => {
|
|
if (scanModeActive) {
|
|
setScanMode(false);
|
|
showView('neutral');
|
|
cancelIdleTimer();
|
|
} else {
|
|
setScanMode(true);
|
|
showView('idle');
|
|
cancelIdleTimer();
|
|
focusScanInput();
|
|
}
|
|
});
|
|
|
|
document.getElementById('btn-label-center')?.addEventListener('click', () => {
|
|
// handled by labelCenter.js initLabelCenter() — just close the menu
|
|
const menu = document.getElementById('app-menu');
|
|
const toggle = document.getElementById('btn-menu-toggle');
|
|
if (menu) { menu.hidden = true; toggle?.setAttribute('aria-expanded', 'false'); }
|
|
});
|
|
|
|
// ── Timer toggle ─────────────────────────────────────────────────────────
|
|
|
|
document.getElementById('btn-timer-toggle')?.addEventListener('click', () => {
|
|
const cur = getTimerDuration();
|
|
const idx = TIMER_STEPS.indexOf(cur);
|
|
const next = TIMER_STEPS[(idx + 1) % TIMER_STEPS.length];
|
|
setTimerDuration(next);
|
|
_updateTimerButton(next);
|
|
localStorage.setItem('timerDuration', next ?? 'off');
|
|
});
|
|
|
|
// ── Camera scan mode toggle ───────────────────────────────────────────────
|
|
|
|
document.getElementById('btn-scan-mode-toggle')?.addEventListener('click', async () => {
|
|
const menu = document.getElementById('app-menu');
|
|
const toggle = document.getElementById('btn-menu-toggle');
|
|
if (isCameraActive()) {
|
|
// Switch back to USB
|
|
await stopCamera(); // triggers onCameraStop which resets UI
|
|
} else {
|
|
// Switch to camera — close menu, pause USB wedge, open camera overlay
|
|
menu.hidden = true;
|
|
toggle.setAttribute('aria-expanded', 'false');
|
|
document.getElementById('btn-scan-mode-toggle')?.classList.add('active');
|
|
localStorage.setItem('scanMode', 'camera');
|
|
pauseScanner();
|
|
await startCamera();
|
|
}
|
|
});
|
|
|
|
// Release camera tracks if the page is hidden/unloaded while overlay is open
|
|
window.addEventListener('pagehide', () => {
|
|
if (isCameraActive()) stopCamera();
|
|
});
|
|
|
|
// ── Scan input focus — show idle hint; blur — back to neutral if off ──────
|
|
|
|
const scanInput = document.getElementById('scan-input');
|
|
let _blurTimer;
|
|
|
|
scanInput?.addEventListener('focus', () => {
|
|
clearTimeout(_blurTimer);
|
|
if (window._currentUser?.role !== 'client' &&
|
|
document.getElementById('view-neutral')?.classList.contains('active')) {
|
|
showView('idle');
|
|
}
|
|
});
|
|
|
|
scanInput?.addEventListener('blur', () => {
|
|
if (!scanModeActive) {
|
|
_blurTimer = setTimeout(() => {
|
|
if (document.getElementById('view-idle')?.classList.contains('active')) {
|
|
showView('neutral');
|
|
}
|
|
}, 150);
|
|
}
|
|
});
|
|
|
|
// ── Error retry + asset card close ───────────────────────────────────────
|
|
|
|
document.addEventListener('click', e => {
|
|
if (e.target.id === 'err-retry-search') {
|
|
showView('idle');
|
|
if (scanModeActive) focusScanInput();
|
|
}
|
|
if (e.target.closest('#btn-close-asset')) {
|
|
closeAssetView();
|
|
}
|
|
});
|
|
|
|
// ── Quick View button (client role only) ──────────────────────────────────
|
|
|
|
document.getElementById('btn-quick-view')?.addEventListener('click', () => {
|
|
const btn = document.getElementById('btn-quick-view');
|
|
const isOpen = btn?.classList.contains('active');
|
|
if (isOpen) {
|
|
btn?.classList.remove('active');
|
|
showView('neutral');
|
|
} else {
|
|
btn?.classList.add('active');
|
|
const container = document.getElementById('quick-view-container');
|
|
if (container) renderClientDashboard(container, window._currentUser, {
|
|
onFilterSelect: ({ lifecycle, possession }) => {
|
|
setFiltersAndRender({ lifecycle, possession });
|
|
},
|
|
});
|
|
showView('quick-view');
|
|
}
|
|
});
|
|
|
|
// ── Init modules ──────────────────────────────────────────────────────────
|
|
|
|
initScanner({ onScan: handleScan, onIdle: handleIdle, canFocus: () => scanModeActive });
|
|
|
|
initSearchAutocomplete({ onLocalSelect: showAsset, onRemoteSearch: handleScan });
|
|
setKeyInterceptor(handleAutocompleteKey);
|
|
|
|
initAssetBrowser({
|
|
onAssetSelect: (asset) => { showAsset(asset); },
|
|
onAssetClose: () => { closeAssetView(); },
|
|
});
|
|
|
|
// ── Home button — returns to the role-appropriate landing view ────────────
|
|
document.getElementById('btn-home')?.addEventListener('click', () => {
|
|
const home = window._currentUser?.role === 'client' ? 'quick-view' : 'neutral';
|
|
showView(home);
|
|
});
|
|
|
|
initLabelCenter();
|
|
refreshBadge();
|
|
|
|
// ── Server-restart detection ──────────────────────────────────────────────
|
|
// Poll /api/ping every 30s. If the bootId changes (or the server was down
|
|
// and came back), reload so clients pick up any new static assets.
|
|
let _bootId = null;
|
|
let _serverWasDown = false;
|
|
|
|
async function _pollServer() {
|
|
try {
|
|
const res = await fetch('/api/ping', { credentials: 'same-origin' });
|
|
if (!res.ok) { _serverWasDown = true; return; }
|
|
const { bootId } = await res.json();
|
|
if (_bootId === null) {
|
|
_bootId = bootId; // baseline on first successful poll
|
|
} else if (bootId !== _bootId || _serverWasDown) {
|
|
_showRestartCountdown();
|
|
}
|
|
_serverWasDown = false;
|
|
} catch {
|
|
_serverWasDown = true;
|
|
}
|
|
}
|
|
|
|
_pollServer();
|
|
setInterval(_pollServer, 30_000);
|
|
});
|
|
|
|
function _showRestartCountdown() {
|
|
// Only show once
|
|
if (document.getElementById('restart-countdown-backdrop')) return;
|
|
|
|
const backdrop = document.createElement('div');
|
|
backdrop.id = 'restart-countdown-backdrop';
|
|
backdrop.style.cssText = [
|
|
'position:fixed', 'inset:0', 'z-index:9999',
|
|
'background:rgba(0,0,0,.55)',
|
|
'display:flex', 'align-items:center', 'justify-content:center',
|
|
'animation:fadeIn .2s ease',
|
|
].join(';');
|
|
|
|
const card = document.createElement('div');
|
|
card.style.cssText = [
|
|
'background:#fff', 'border-radius:12px',
|
|
'max-width:380px', 'width:calc(100vw - 40px)',
|
|
'overflow:hidden', 'box-shadow:0 8px 32px rgba(0,0,0,.25)',
|
|
].join(';');
|
|
|
|
let seconds = 5;
|
|
card.innerHTML = `
|
|
<div style="background:#1a3565;color:#fff;padding:16px 20px;display:flex;align-items:center;gap:12px;border-bottom:3px solid #C4622A;">
|
|
<img src="/assets/logo-swirl.png" style="width:36px;height:36px;border-radius:50%;background:#fff;padding:2px;flex-shrink:0;" alt="">
|
|
<div>
|
|
<div style="font-size:.95rem;font-weight:700;line-height:1.2;">Asset Browser</div>
|
|
<div style="font-size:.65rem;color:rgba(255,255,255,.55);text-transform:uppercase;letter-spacing:.05em;margin-top:2px;">deRenzy Business Technologies</div>
|
|
</div>
|
|
</div>
|
|
<div style="padding:24px 28px;text-align:center;">
|
|
<div style="margin-bottom:14px;">
|
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#1a3565" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/>
|
|
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
|
</svg>
|
|
</div>
|
|
<div style="font-size:1rem;font-weight:700;color:#1a3565;margin-bottom:6px;">Server Restarted</div>
|
|
<div style="font-size:.88rem;color:#6b7280;margin-bottom:20px;">Reloading in <strong id="rc-seconds">5</strong> second(s)…</div>
|
|
<button id="rc-reload-now" style="background:#C4622A;color:#fff;border:none;border-radius:6px;padding:8px 20px;font-size:.88rem;font-weight:600;cursor:pointer;font-family:inherit;">Reload now</button>
|
|
</div>`;
|
|
|
|
backdrop.appendChild(card);
|
|
document.body.appendChild(backdrop);
|
|
|
|
const interval = setInterval(() => {
|
|
seconds--;
|
|
const el = document.getElementById('rc-seconds');
|
|
if (el) el.textContent = seconds;
|
|
if (seconds <= 0) { clearInterval(interval); window.location.reload(); }
|
|
}, 1000);
|
|
|
|
document.getElementById('rc-reload-now')?.addEventListener('click', () => {
|
|
clearInterval(interval);
|
|
window.location.reload();
|
|
});
|
|
}
|
|
|
|
function esc(s) {
|
|
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|