Files
cc2-spoolman-bridge/app/templates/status.html
T
Jonas MenzelandClaude Opus 5.5 ae44441cfb
CI / test (push) Successful in 3m44s
CI / image (push) Successful in 44s
New web UI with iPhone web app
Redesigned status, log, tag generator and settings pages: spool rings in
filament colour, live job card (progress and time left read from CC2
Control's /api/printer while a job runs), log page with filters, iOS-style
settings with a write/dry-run switch. On phones: bottom tab bar, printer
switcher, slot details in a bottom sheet. Dark mode follows the system.
Web app manifest and home screen icons for "Add to Home Screen".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
2026-10-25 19:55:07 +01:00

359 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}CC2 Bridge – Status{% endblock %}
{% block heading %}Status{% endblock %}
{% block head %}
<style>
.layout { display: flex; flex-wrap: wrap; gap: 28px; align-items: flex-start; }
.printers { flex: 999 1 640px; min-width: 0; display: flex; flex-direction: column; gap: 32px; }
.aside { flex: 1 1 300px; min-width: 0; padding: 18px 18px 8px; }
.phead { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.phead h2 { font-size: 24px; letter-spacing: -.02em; }
.phead .chip { margin-left: auto; font-size: 13px; padding: 5px 12px; }
.printer { display: flex; flex-direction: column; gap: 14px; }
.job { background: var(--hero); color: var(--hero-ink); border-radius: var(--radius); padding: 18px 20px; display: flex; flex-direction: column; gap: 12px; }
.job .k { font-size: 12px; color: var(--hero-muted); }
.job .v { font-family: var(--mono); font-size: 20px; }
.job .file { font-weight: 700; font-size: 16px; overflow-wrap: anywhere; }
.job .stats { display: flex; gap: 28px; flex-wrap: wrap; }
.job .bar { height: 6px; border-radius: 99px; background: var(--hero-track); overflow: hidden; }
.job .bar div { height: 6px; border-radius: 99px; background: var(--hero-accent); }
.job .note { font-size: 13px; color: var(--hero-muted); }
.job .label { color: var(--hero-muted); }
.slots { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; }
.slot { display: flex; flex-direction: column; gap: 14px; min-height: 236px; }
.slot .sh { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.slot .sh b { font-size: 14px; font-weight: 800; }
.spool { display: flex; gap: 14px; align-items: center; text-align: left; border: 0; background: none; padding: 0; min-height: 0;
border-radius: 12px; font-weight: inherit; color: inherit; width: 100%; }
.spool:hover .nm { text-decoration: underline; }
.spool .nm { font-weight: 800; font-size: 16px; overflow-wrap: anywhere; }
.facts { display: flex; flex-direction: column; gap: 6px; font-size: 13px; }
.facts div { display: flex; justify-content: space-between; gap: 8px; }
.facts span:first-child { color: var(--muted); }
.facts span:last-child { text-align: right; }
.slot .acts { margin-top: auto; display: flex; gap: 8px; }
.slot .acts button { flex: 1; font-size: 13px; min-height: 36px; padding: 0 8px; }
.emptybox { flex: 1; border: 2px dashed var(--line); border-radius: 12px; display: flex; align-items: center; justify-content: center;
color: var(--muted); font-size: 13px; font-weight: 600; min-height: 100px; }
.dashed { border: 3px dashed var(--faint); }
.hint { margin: 0; font-size: 13px; color: var(--muted); }
.hint.warn { color: var(--warn); }
.seg { display: grid; gap: 4px; background: var(--line); padding: 4px; border-radius: 12px; }
.seg button { border: 0; min-height: 36px; border-radius: 9px; background: transparent; color: var(--muted); font-weight: 600; }
.seg button[aria-selected=true] { background: var(--surface); color: var(--ink); font-weight: 700; box-shadow: 0 1px 2px rgba(0,0,0,.08); }
.seg button::before { content: ""; width: 7px; height: 7px; border-radius: 99px; background: var(--dot, var(--faint)); }
.logrow { display: flex; gap: 12px; padding: 12px 0; border-top: 1px solid var(--line2); }
.logrow .msg { font-size: 14px; overflow-wrap: anywhere; }
.logrow .meta { font-size: 12px; color: var(--muted); }
.lastbook { display: flex; gap: 12px; align-items: center; }
@media (max-width: 759px) {
.printer:not(.sel) { display: none; }
.printers { gap: 14px; }
.phead { display: none; }
.single .phead { display: flex; }
.job { padding: 16px; }
.job .stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.job .v { font-size: 17px; }
}
</style>
{% endblock %}
{% block content %}
<div id="banners" class="stack" style="margin-bottom:14px"></div>
<div id="seg" class="seg phone-only" role="tablist" aria-label="Drucker" style="margin-bottom:14px"></div>
<div class="layout">
<div id="printers" class="printers"><div class="card muted">Lade …</div></div>
<aside class="card aside desktop-only" aria-label="Protokoll">
<div class="row" style="justify-content:space-between;margin-bottom:6px"><h2 style="font-size:17px">Protokoll</h2><a href="/log" class="small" style="font-weight:700;text-decoration:none">Alle</a></div>
<div id="log"></div>
</aside>
</div>
<dialog id="sheet" class="sheet" aria-labelledby="sheet-title"><div class="sheet-in" id="sheet-in"></div></dialog>
{% endblock %}
{% block script %}
<script>
const QUESTION = { inserted: 'eingelegt', changed: 'geändert' };
const TRAY = { 0: ['leer', ''], 1: ['eingelegt', 'ok'], 2: ['fördert', 'accent'] };
let ST = null, NAMES = {}, SEL = null, SHEET = null;
try { SEL = localStorage.getItem('printer'); } catch (e) {}
function hex(c) { c = String(c || '').replace(/^0x|^#/i, ''); return /^[0-9a-f]{6}$/i.test(c) ? '#' + c : null; }
function kindOf(s) {
if (s.mapping) return 'managed';
if (s.takeout) return 'takeout';
if (s.tray_status === 1 || s.tray_status === 2) return 'unmanaged';
return 'empty';
}
function info(s) {
const m = s.mapping, sm = s.spoolman, cc = s.cc2_spool, ch = s.record.channel || {};
const kind = kindOf(s);
const tagColor = hex(ch.color);
const out = { kind, tray: TRAY[s.tray_status] };
if (kind === 'managed') {
const rest = cc ? Number(cc.remaining) : (sm && sm.remaining != null ? Number(sm.remaining) : null);
const initial = (sm && sm.initial) || 1000;
out.color = (sm && hex(sm.color)) || tagColor || '#9AA3B0';
out.rest = rest; out.initial = initial;
out.pct = rest == null ? 0 : Math.max(0, Math.min(100, rest / initial * 100));
out.title = m.name || ('Spule ' + m.spool);
out.sub = sm ? [sm.vendor, sm.material].filter(Boolean).join(' ') : '';
if (s.cc2_matches) out.cc2 = ['zugewiesen', 'ok'];
else out.cc2 = m.cc2 ? ['nicht zugewiesen', 'warn'] : ['noch nicht angelegt', 'warn'];
if (s.cc2_question) out.question = 'CC2 fragt: Spule ' + (QUESTION[s.cc2_question] || s.cc2_question);
} else if (kind === 'takeout') {
out.title = s.takeout.name; out.color = tagColor || '#9AA3B0';
out.sub = 'In CC2 Control herausgenommen';
out.hint = `${s.takeout.name} steht in Spoolman im Lager, steckt aber noch. Was dieser Slot druckt, bucht die Bridge trotzdem auf ${s.takeout.name}, bis die Rolle entnommen wird. Wieder zuordnen: Rolle herausnehmen und neu einlegen.`;
} else if (kind === 'unmanaged') {
out.color = tagColor;
out.sub = s.record.occupied ? [ch.manufacturer, ch.detailed_type || ch.type].filter(Boolean).join(' ') : 'Rolle ohne Tag-Daten';
if (s.record.spool_id != null) {
out.title = 'Tag ' + s.record.spool_id + ' ohne Zuordnung';
out.hint = `Tag ${s.record.spool_id} steht noch im Slot, ist aber keiner Rolle zugeordnet. Liegt diese Rolle hier, sie herausnehmen und neu einlegen (oder in CC2 Control auswählen).`;
} else {
out.title = 'Ohne Spoolman-Tag';
out.hint = 'Verbrauch wird nicht gebucht. Rolle taggen und scannen, um sie zu verwalten.';
}
}
return out;
}
function ringHtml(i, size, hole, inner) {
const bg = i.kind === 'managed'
? `conic-gradient(${i.color} 0 ${i.pct}%, var(--track) ${i.pct}% 100%)`
: (i.kind === 'empty' ? 'var(--bg)' : 'var(--surface)');
const cls = i.kind === 'unmanaged' || i.kind === 'takeout' ? ' dashed' : '';
return `<div class="ring${cls}" style="width:${size}px;height:${size}px;background:${bg}" aria-hidden="true">
<div style="width:${hole}px;height:${hole}px">${inner}</div></div>`;
}
function stateOf(p) {
if (!p.enabled) return ['Pausiert', ''];
if (Object.values(p.errors || {}).some(Boolean)) return ['Fehler', 'err'];
if (p.cc2_enabled === false) return ['Verwaltung aus', 'warn'];
if (p.job_active) return ['Druckt', 'accent'];
return ['Bereit', 'ok'];
}
function fmtLeft(sec) {
if (sec == null || sec < 0) return '–';
const h = Math.floor(sec / 3600), m = Math.round((sec % 3600) / 60);
return h ? `${h}:${String(m).padStart(2, '0')} h` : `${m} min`;
}
function jobCard(p) {
const j = p.job;
if (!j) return '';
const prog = j.progress != null ? Math.round(j.progress) : null;
return `<div class="job">
<div class="row" style="justify-content:space-between;gap:8px">
<span class="label">Druck läuft</span>
${j.layer ? `<span class="mono small" style="color:var(--hero-muted)">Schicht ${j.layer}/${j.layers || '?'}</span>` : ''}
</div>
<div class="file">${esc(j.file || 'Unbekannte Datei')}</div>
${prog != null ? `<div class="bar"><div style="width:${prog}%"></div></div>` : ''}
<div class="stats">
<div><div class="k">Fortschritt</div><div class="v">${prog != null ? prog + '%' : '–'}</div></div>
<div><div class="k">Restzeit</div><div class="v">${fmtLeft(j.remaining)}</div></div>
<div><div class="k">Verbraucht</div><div class="v">${fmtNum(j.grams, 1)} g</div></div>
</div>
<div class="note">Wird nach Druckende auf die Spoolman-Spulen gebucht.</div>
</div>`;
}
function slotCard(p, s) {
const i = info(s), lb = s.last_booking;
const tray = i.tray ? `<span class="chip ${i.tray[1]}">${i.tray[0]}</span>` : '';
let body;
if (i.kind === 'managed') {
body = `<button class="spool" onclick="openSheet('${p.id}', ${s.slot})">
${ringHtml(i, 76, 54, `<span class="mono" style="font-size:14px;line-height:1">${i.rest == null ? '?' : Math.round(i.rest)}</span><span style="font-size:10px;color:var(--muted)">g</span>`)}
<span style="min-width:0"><span class="nm" style="display:block">${esc(i.title)} <span class="muted small" style="font-weight:500">#${s.mapping.spool}</span></span>
<span class="small muted" style="display:block">${esc(i.sub)}</span>
<span class="small muted" style="display:block">von ${Math.round(i.initial)} g</span></span>
</button>
<div class="facts">
<div><span>CC2 Control</span><span style="font-weight:700;color:var(--${i.cc2[1]})">${i.cc2[0]}</span></div>
${i.question ? `<div><span></span><span class="chip warn">${esc(i.question)}</span></div>` : ''}
<div><span>Zuletzt gebucht</span><span class="mono">${lb ? fmtNum(lb.grams, 2) + ' g · ' + fmtClock(lb.time) : '–'}</span></div>
</div>
<div class="acts">
<button onclick="slotAction('${p.id}', ${s.slot}, 'reassign')">Erneut senden</button>
<button class="danger" onclick="slotAction('${p.id}', ${s.slot}, 'release')">Aufheben</button>
</div>`;
} else if (i.kind === 'empty') {
body = '<div class="emptybox">Kein Filament</div>';
} else {
body = `<button class="spool" onclick="openSheet('${p.id}', ${s.slot})">
${ringHtml(i, 76, 54, i.color ? `<span class="swatch" style="width:22px;height:22px;background:${i.color}"></span>` : '')}
<span style="min-width:0"><span class="nm" style="display:block;font-size:15px">${esc(i.title)}</span>
<span class="small muted" style="display:block">${esc(i.sub)}</span></span>
</button>
<p class="hint${i.kind === 'takeout' ? ' warn' : ''}" style="margin-top:auto">${esc(i.hint)}</p>`;
}
return `<article class="card slot"><div class="sh"><b>Slot ${s.slot + 1}</b>${tray}</div>${body}</article>`;
}
function slotRow(p, s) {
const i = info(s);
const inner = `<span style="font-weight:800;font-size:13px">${s.slot + 1}</span>`;
let title = i.title, sub = i.sub, rest = '';
if (i.kind === 'empty') { title = 'Leer'; sub = 'Kein Filament'; }
if (i.kind === 'managed') {
rest = i.rest == null ? '?' : Math.round(i.rest) + ' g';
sub = [i.sub, '#' + s.mapping.spool, i.question || i.cc2[0]].filter(Boolean).join(' · ');
}
const tray = i.tray || ['', ''];
const trayColor = { ok: 'var(--ok)', accent: 'var(--accent)' }[tray[1]] || 'var(--muted)';
const tag = i.kind === 'empty' ? 'div' : 'button';
return `<${tag} class="item" ${tag === 'button' ? `onclick="openSheet('${p.id}', ${s.slot})"` : ''}>
${ringHtml(i, 48, 32, inner)}
<span class="grow"><span class="title" style="display:block">${esc(title)}</span><span class="sub" style="display:block">${esc(sub)}</span></span>
<span style="text-align:right"><span class="mono" style="display:block">${rest}</span><span style="display:block;font-size:12px;font-weight:700;color:${trayColor}">${tray[0]}</span></span>
${tag === 'button' ? CHEV : '<span style="width:8px"></span>'}
</${tag}>`;
}
function lastBooking(p) {
const b = p.slots.map((s) => s.last_booking && { ...s.last_booking, name: s.mapping && s.mapping.name, spool: s.mapping && s.mapping.spool })
.filter(Boolean).sort((a, b) => b.time - a.time)[0];
if (!b) return '';
return `<div class="card lastbook phone-only">
<div class="icon-badge" style="background:var(--ok-bg);color:var(--ok)">g</div>
<div style="min-width:0"><div class="small muted">Letzte Buchung · ${fmtClock(b.time)}</div>
<div style="font-weight:700;font-size:14px">${fmtNum(b.grams, 2)} g auf ${esc(b.name || '')}${b.spool ? ` (Spoolman ${b.spool})` : ''}</div>
${b.details ? `<div class="small muted" style="overflow-wrap:anywhere">${esc(b.details)}</div>` : ''}</div>
</div>`;
}
function printerSection(p) {
const st = stateOf(p), pb = [];
if (!p.enabled) pb.push('<div class="banner warn">Abfrage pausiert (in den Einstellungen deaktiviert).</div>');
else if (ST.ssh_key && !p.host_key) pb.push('<div class="banner warn">Host-Key noch nicht bestätigt. <a href="/settings">Verbindung testen</a></div>');
if (p.cc2_enabled === false) pb.push(`<div class="banner err"><span style="flex:1 1 220px">Spulenverwaltung in CC2 Control ist ausgeschaltet. Verbrauch wird nicht gezählt.</span><button class="primary" onclick="enableCC2('${p.id}')">Einschalten …</button></div>`);
for (const v of Object.values(p.errors || {})) if (v) pb.push(`<div class="banner err">${esc(v)}</div>`);
return `<section class="printer${p.id === SEL ? ' sel' : ''}" aria-label="${esc(p.name)}">
<div class="phead"><h2>${esc(p.name)}</h2><span class="mono small muted host">${esc(p.host)}</span><span class="chip dot ${st[1]}">${st[0]}</span></div>
${pb.join('')}
${jobCard(p)}
<div class="slots desktop-only">${p.slots.map((s) => slotCard(p, s)).join('')}</div>
<div class="group phone-only">${p.slots.map((s) => slotRow(p, s)).join('')}</div>
${lastBooking(p)}
</section>`;
}
function renderSeg(printers) {
const seg = $('seg');
if (printers.length < 2) { seg.style.display = 'none'; return; }
seg.style.display = '';
seg.style.gridTemplateColumns = `repeat(${printers.length}, minmax(0, 1fr))`;
const dot = { ok: 'var(--ok)', accent: 'var(--accent)', err: 'var(--err)', warn: 'var(--warn)' };
seg.innerHTML = printers.map((p) => `<button role="tab" aria-selected="${p.id === SEL}" style="--dot:${dot[stateOf(p)[1]] || 'var(--faint)'}"
onclick="selectPrinter('${p.id}')">${esc(p.name)}</button>`).join('');
}
function selectPrinter(id) {
SEL = id;
try { localStorage.setItem('printer', id); } catch (e) {}
render();
}
function sheetHtml(p, s) {
const i = info(s), m = s.mapping, sm = s.spoolman, cc = s.cc2_spool, ch = s.record.channel || {}, lb = s.last_booking;
const rows = [];
rows.push(['Tag', s.record.occupied ? `${hex(ch.color) ? `<span class="swatch" style="background:${hex(ch.color)}"></span> ` : ''}${esc(ch.detailed_type || ch.type)} <span class="muted small">${esc(ch.manufacturer)}</span>` : '<span class="muted">kein Tag</span>']);
rows.push(['Tag-ID', s.record.spool_id ?? '<span class="muted">–</span>']);
if (m) rows.push(['Spoolman', `${sm && sm.color ? `<span class="swatch" style="background:#${sm.color}"></span> ` : ''}<b>${esc(m.name || m.spool)}</b> <span class="muted">#${m.spool}</span>` +
(sm ? `<br><span class="small muted">${esc(sm.vendor)} ${esc(sm.material)}${sm.name ? ' · ' + esc(sm.name) : ''}, Rest ${sm.remaining != null ? Math.round(sm.remaining) + ' g' : '?'}</span>` : '')]);
rows.push(['CC2 Control', (cc ? `${esc(cc.name)} <span class="small muted">Rest ${fmtNum(cc.remaining, 1)} g</span><br>` : '') +
(i.cc2 ? `<span class="chip ${i.cc2[1]}">${i.cc2[0]}</span>` : '<span class="muted">–</span>') + (i.question ? ` <span class="chip warn">${esc(i.question)}</span>` : '')]);
rows.push(['Ort in Spoolman', esc(s.location)]);
rows.push(['Buchung', lb ? `${fmtNum(lb.grams, 2)} g <span class="small muted">${fmtTime(lb.time)}</span>${lb.details ? `<br><span class="small muted">${esc(lb.details)}</span>` : ''}` : '<span class="muted">–</span>']);
const inner = i.kind === 'managed' ? `<span class="mono" style="font-size:15px;line-height:1">${i.rest == null ? '?' : Math.round(i.rest)}</span><span style="font-size:10px;color:var(--muted)">g</span>` : '';
return `<div class="grip"></div>
<div class="row" style="flex-wrap:nowrap;gap:14px">
${ringHtml(i, 64, 44, inner)}
<div style="flex:1;min-width:0"><div class="small muted">${esc(p.name)} · Slot ${s.slot + 1}${i.tray ? ' · ' + i.tray[0] : ''}</div>
<h2 id="sheet-title" style="overflow-wrap:anywhere">${esc(i.title || 'Leer')}</h2>${i.sub ? `<div class="small muted">${esc(i.sub)}</div>` : ''}</div>
<button aria-label="Schließen" onclick="closeSheet()" style="min-width:40px;padding:0;border-radius:99px">✕</button>
</div>
${i.hint ? `<p class="hint${i.kind === 'takeout' ? ' warn' : ''}">${esc(i.hint)}</p>` : ''}
<dl class="kv">${rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('')}</dl>
${m ? `<div class="row" style="flex-wrap:nowrap"><button style="flex:1;min-height:48px" onclick="slotAction('${p.id}', ${s.slot}, 'reassign')">Erneut senden</button>
<button class="danger" style="flex:1;min-height:48px" onclick="slotAction('${p.id}', ${s.slot}, 'release')">Aufheben</button></div>` : ''}`;
}
function openSheet(pid, slot) {
SHEET = { pid, slot };
renderSheet();
if (!$('sheet').open) $('sheet').showModal();
}
function renderSheet() {
if (!SHEET || !ST) return;
const p = ST.printers.find((x) => x.id === SHEET.pid);
if (!p) { closeSheet(); return; }
$('sheet-in').innerHTML = sheetHtml(p, p.slots[SHEET.slot]);
}
function closeSheet() { SHEET = null; if ($('sheet').open) $('sheet').close(); }
$('sheet').addEventListener('close', () => { SHEET = null; });
$('sheet').addEventListener('click', (ev) => { if (ev.target === $('sheet')) closeSheet(); });
async function slotAction(pid, slot, action) {
const name = NAMES[pid] || pid;
const text = action === 'release'
? `${name}: Zuordnung von Slot ${slot + 1} aufheben? Die Spule wird in Spoolman ins Lager gebucht.`
: `${name}: Zuordnung von Slot ${slot + 1} erneut an CC2 Control senden?`;
if (!confirm(text)) return;
try { await api('POST', `/api/printers/${pid}/slots/${slot}/${action}`); toast('Eingereiht'); setTimeout(load, 800); }
catch (e) { toast(e.message); }
}
async function enableCC2(pid) {
const name = NAMES[pid] || pid;
if (!confirm(`Spulenverwaltung in CC2 Control von ${name} einschalten?\n\nAb dann zählt CC2 Control den Verbrauch je Slot.`)) return;
try {
const r = await api('POST', `/api/printers/${pid}/cc2/enable`, { confirm: true });
toast(r.dry_run ? 'Dry-Run: nur protokolliert' : 'Wird eingeschaltet');
setTimeout(load, 1500);
} catch (e) { toast(e.message); }
}
function render() {
const st = ST, printers = st.printers || [];
if (!printers.some((p) => p.id === SEL)) SEL = printers.length ? printers[0].id : null;
const b = [];
if (st.dry_run) b.push('<div class="banner dry">Dry-Run aktiv: Schreibaktionen werden nur protokolliert („würde …“). Umschalten unter Einstellungen.</div>');
if (!st.ssh_key) b.push('<div class="banner warn">Kein SSH-Schlüssel hinterlegt. <a href="/settings">Einstellungen</a></div>');
if (st.spoolman_error) b.push(`<div class="banner err">${esc(st.spoolman_error)}</div>`);
$('banners').innerHTML = b.join('');
$('banners').style.display = b.length ? '' : 'none';
renderSeg(printers);
$('printers').classList.toggle('single', printers.length < 2);
$('printers').innerHTML = printers.map(printerSection).join('')
|| '<div class="card muted">Kein Drucker eingerichtet. <a href="/settings">Einstellungen</a></div>';
$('log').innerHTML = (st.log || []).slice(0, 8).map((e) => {
const [ic, bg, fg] = logIcon(e), [pn, msg] = splitPrinter(e.msg, Object.values(NAMES));
return `<div class="logrow"><div class="icon-badge" style="background:${bg};color:${fg}">${ic}</div>
<div style="min-width:0"><div class="msg">${esc(msg)}</div><div class="meta">${esc(pn)} · ${fmtClock(e.time)}</div></div></div>`;
}).join('') || '<p class="muted small">Noch keine Einträge</p>';
renderSheet();
}
async function load() {
try { ST = await api('GET', '/api/status'); }
catch (e) { $('banners').style.display = ''; $('banners').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; return; }
setMode(ST.dry_run);
setPoll(ST.last_poll);
NAMES = Object.fromEntries((ST.printers || []).map((p) => [p.id, p.name]));
render();
}
load();
setInterval(() => { if (!document.hidden) load(); }, 3000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
</script>
{% endblock %}