announcer/index.html
Kent 04b3712ef5 Planlagte sendinger
«Send denne kl. 18:00.» Engangs, ikke gjentakende.

Dette er den første delen av appen som må ligge på disk. Kø og historikk har
alltid bare levd i minnet, og det har gått bra fordi en melding spilles med en
gang. En melding planlagt til 18:00 må overleve at containeren startes om
17:45, så schedule.py lagrer i data/scheduled.json – skrevet til side og
flyttet på plass, så fila aldri er halvskrevet.

Ingen database, med vilje: dette er en håndfull meldinger på et arrangement,
og en fil man kan lese med cat er verdt mer her enn indekser. En ødelagt fil
hindrer ikke oppstart; da er det bedre å miste planene og si det i loggen.

Modulen vet ingenting om Flask eller avspilling. Den lagrer, og den svarer på
«hva skal sendes nå?». _scheduler() i announcer.py eier tråden som spør hvert
20. sekund og legger forfalte meldinger i den vanlige køen – derfra oppfører de
seg nøyaktig som en melding sendt for hånd.

Gikk den glipp av, spilles den ikke. due() skiller på grace_seconds (120):
innenfor den sendes meldingen, utenfor markeres den missed og vises som det.
Nådefristen er slingringsmonn for at tråden våkner med mellomrom og for en
kjapp omstart, ikke en policy om å ta igjen etterslep – «turneringen starter om
ti minutter» skal ikke ut på anlegget en time etter at maskina kom tilbake.
due() endrer ingenting selv; markeringen skjer først etter at meldingen
faktisk er i kø, så ingenting kan stå som «sendt» uten å ha blitt det.

Klienten sender absolutt tidspunkt (epoch), så tidssoner spiller ingen rolle
for selve planleggingen. TZ settes likevel fra .env, ellers står loggen i UTC
mens man selv står i CEST.

Planlegging er låst til samme forhåndslytt-nøkkel som «Send ut»: man planlegger
bare det man har hørt. Det gir en bonus – forhåndslytt varmer cache/, så WAV-en
ligger ferdig på disk, og en planlagt edge-tts-melding spiller kl. 18:00 selv
om nettet er nede da.

Sine egne kan man alltid avbryte. Andres krever clear, samme rett som å tømme
køen, siden det er samme slags inngrep i noe andre har satt i gang.

23 nye tester. Verifisert i container: sending fyrer og spiller, overlever en
omstart av containeren, og en melding som forfalt mens maskina var av blir
markert missed uten å bli spilt.

Merk ved oppgradering: schedule er en ny handling, og et oppsett med eksplisitt
[roles] i config.toml får den ikke automatisk. Den må legges inn selv.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 16:29:16 +02:00

486 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.

<!doctype html>
<html lang="no">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Announcer</title>
<style>
:root {
--bg: #0e1116; --panel: #171c24; --panel2: #1f2631; --line: #2a3340;
--fg: #e6ebf2; --muted: #8a96a8; --accent: #4fd1c5; --accent-fg: #06201d;
--danger: #f06565; --ok: #7bd88f; --warn: #f5c451;
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--fg);
font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px 48px; }
h1 { font-size: 22px; margin: 0 0 4px; letter-spacing: .02em; }
h1 span { color: var(--accent); }
.sub { color: var(--muted); margin: 0 0 20px; font-size: 14px; }
.top { display: flex; gap: 12px; align-items: flex-start; justify-content: space-between; }
.who { font-size: 13px; color: var(--muted); text-align: right; white-space: nowrap; padding-top: 2px; }
.who b { color: var(--fg); font-weight: 600; }
.who a { color: var(--accent); text-decoration: none; }
.who a:hover { text-decoration: underline; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin-bottom: 16px; }
label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: .06em; }
textarea { width: 100%; min-height: 120px; resize: vertical; background: var(--panel2); color: var(--fg);
border: 1px solid var(--line); border-radius: 8px; padding: 12px; font: inherit; font-size: 18px; }
textarea:focus, select:focus { outline: 2px solid var(--accent); outline-offset: 0; border-color: transparent; }
.counter { text-align: right; font-size: 12px; color: var(--muted); margin-top: 4px; }
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; margin-top: 12px; }
.row > div { flex: 1 1 160px; }
select, input[type=range] { width: 100%; }
select { background: var(--panel2); color: var(--fg); border: 1px solid var(--line); border-radius: 8px; padding: 10px; font: inherit; }
.chips { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.chip { background: var(--panel2); border: 1px solid var(--line); color: var(--fg); border-radius: 999px;
padding: 6px 12px; font-size: 13px; cursor: pointer; }
.chip:hover { border-color: var(--accent); }
.actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px; }
button.btn { flex: 1 1 200px; padding: 14px 18px; font: inherit; font-weight: 600; font-size: 16px;
border-radius: 10px; border: 1px solid var(--line); background: var(--panel2); color: var(--fg); cursor: pointer; }
button.btn:disabled { opacity: .5; cursor: default; }
button.primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
button.danger { background: transparent; color: var(--danger); border-color: var(--danger); flex: 0 1 auto; }
a.btn { text-decoration: none; display: inline-block; text-align: center; }
audio { width: 100%; margin-top: 12px; display: none; }
audio.show { display: block; }
.msg { margin-top: 10px; font-size: 14px; min-height: 1.4em; }
.msg.err { color: var(--danger); } .msg.ok { color: var(--ok); }
.status-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.status-head h2 { font-size: 14px; margin: 0; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
.now { padding: 12px; border-radius: 8px; background: var(--panel2); border-left: 4px solid var(--ok); }
.now.idle { border-left-color: var(--line); color: var(--muted); }
ul.list { list-style: none; padding: 0; margin: 8px 0 0; }
ul.list li { padding: 8px 0; border-top: 1px solid var(--line); font-size: 14px; display: flex; gap: 10px; }
ul.list li .t { color: var(--muted); min-width: 48px; font-variant-numeric: tabular-nums; }
ul.list li .s { margin-left: auto; color: var(--muted); font-size: 12px; white-space: nowrap; }
ul.list li .by { color: var(--muted); font-size: 12px; }
ul.list li.failed .s { color: var(--danger); } ul.list li.stopped .s { color: var(--warn); }
.pulse { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--ok); margin-right: 8px;
animation: pulse 1.2s infinite; vertical-align: middle; }
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .25 } }
#loginbox, #pinbox { display: none; }
#pinbox input { background: var(--panel2); color: var(--fg); border: 1px solid var(--line); border-radius: 8px; padding: 10px; font: inherit; width: 100%; }
.hint { color: var(--muted); font-size: 12px; margin-top: 6px; }
.hidden { display: none !important; }
.when { display: flex; gap: 8px; align-items: center; margin-top: 12px; flex-wrap: wrap; }
.when input[type=datetime-local] { background: var(--panel2); color: var(--fg); border: 1px solid var(--line);
border-radius: 8px; padding: 10px; font: inherit; }
.when button { flex: 0 1 auto; padding: 10px 14px; font-size: 14px; }
.when .q { display: flex; gap: 6px; }
.when .q button { background: var(--panel2); border: 1px solid var(--line); color: var(--fg);
border-radius: 999px; padding: 6px 10px; font: inherit; font-size: 13px; cursor: pointer; }
.when .q button:hover { border-color: var(--accent); }
ul.list li button.x { background: none; border: none; color: var(--muted); cursor: pointer;
font: inherit; font-size: 13px; padding: 0 4px; }
ul.list li button.x:hover { color: var(--danger); }
ul.list li.cancelled .s, ul.list li.missed .s { color: var(--warn); }
</style>
</head>
<body>
<main>
<div class="top">
<div>
<h1 id="title"></h1>
<p class="sub" id="subtitle"></p>
</div>
<div class="who" id="who"></div>
</div>
<div class="card" id="loginbox">
<div id="loginbtns" class="actions" style="margin-top:0"></div>
</div>
<div class="card" id="pinbox">
<label for="pin" id="pinlabel">PIN</label>
<input id="pin" type="password" inputmode="numeric" autocomplete="current-password">
<div class="hint" id="pinhint"></div>
</div>
<div class="card">
<label for="text">Melding</label>
<textarea id="text" autofocus></textarea>
<div class="counter"><span id="count">0</span> / <span id="max">600</span></div>
<div class="chips" id="chips"></div>
<div class="row">
<div>
<label for="voice">Stemme</label>
<select id="voice"></select>
</div>
<div>
<label for="speed">Hastighet <span id="speedval">1.0×</span></label>
<input id="speed" type="range" min="0.7" max="1.4" step="0.05" value="1.0">
</div>
<div>
<label for="chime">Jingle først</label>
<select id="chime"></select>
</div>
</div>
<div class="actions">
<button class="btn" id="btnPreview">▶ Forhåndslytt</button>
<button class="btn primary" id="btnAnnounce" disabled>📢 Send ut på anlegget</button>
</div>
<div class="when" id="whenbox">
<input id="at" type="datetime-local" step="60">
<div class="q" id="atquick"></div>
<button class="btn" id="btnSchedule" disabled>🕑 Planlegg</button>
</div>
<audio id="player" controls></audio>
<div class="msg" id="msg"></div>
</div>
<div class="card hidden" id="schedcard">
<div class="status-head"><h2>Planlagt</h2></div>
<ul class="list" id="sched"></ul>
</div>
<div class="card">
<div class="status-head">
<h2>Anlegget</h2>
<div>
<button class="btn danger" id="btnStop" style="padding:6px 12px;font-size:13px">■ Stopp</button>
<button class="btn danger" id="btnClear" style="padding:6px 12px;font-size:13px">Tøm kø</button>
</div>
</div>
<div class="now idle" id="now"></div>
<ul class="list" id="queue"></ul>
</div>
<div class="card">
<div class="status-head"><h2>Siste meldinger</h2></div>
<ul class="list" id="history"></ul>
</div>
</main>
<script>
const $ = s => document.querySelector(s);
const text = $('#text'), voice = $('#voice'), speed = $('#speed'), chime = $('#chime');
const msg = $('#msg'), audio = $('#player'), btnPreview = $('#btnPreview'), btnAnnounce = $('#btnAnnounce');
let cfg = {}, can = [], previewedKey = null, pollTimer = null, me = null, skew = 0;
function key() { return JSON.stringify(payload()); }
function payload() {
return { text: text.value.trim(), voice: voice.value, speed: parseFloat(speed.value), chime: chime.value };
}
function headers(json) {
const h = json ? { 'Content-Type': 'application/json' } : {};
// PIN-en er den eneste legitimasjonen som ligger i nettleseren; OIDC og
// token-lenka bruker sesjonscookien, som følger med av seg selv.
const pin = localStorage.getItem('pin');
if (pin) h['X-Pin'] = pin;
return h;
}
function say(t, cls) { msg.textContent = t; msg.className = 'msg ' + (cls || ''); }
function may(action) { return can.includes(action); }
function updateAnnounce() {
const ready = previewedKey && previewedKey === key();
btnAnnounce.disabled = !ready || !may('announce');
btnAnnounce.title = !may('announce') ? 'Du har ikke lov til å sende ut' :
(btnAnnounce.disabled ? 'Forhåndslytt først' : '');
btnPreview.disabled = !may('preview');
btnPreview.title = may('preview') ? '' : 'Du har ikke lov til å forhåndslytte';
// Planlegging er låst til forhåndslytten på samme måte som «Send ut»:
// ingen skal legge noe i kalenderen de ikke har hørt.
const at = $('#at').value;
$('#whenbox').classList.toggle('hidden', !may('schedule'));
$('#btnSchedule').disabled = !ready || !at || !may('schedule');
$('#btnSchedule').title = !at ? 'Velg tidspunkt' :
(!ready ? 'Forhåndslytt først' : '');
}
// Serverens klokke gjelder. Containeren kan stå i UTC mens nettleseren står
// i CEST, og da ville «om 20 minutter» blitt to timer feil.
function serverNow() { return Date.now() / 1000 + skew; }
function atEpoch() {
const v = $('#at').value;
if (!v) return null;
const t = new Date(v).getTime(); // datetime-local tolkes lokalt
return Number.isFinite(t) ? t / 1000 : null;
}
function omTid(ts) {
const s = ts - serverNow();
if (s < 0) return 'nå';
if (s < 90) return 'om ' + Math.round(s) + ' s';
if (s < 5400) return 'om ' + Math.round(s / 60) + ' min';
if (s < 86400) return 'om ' + (s / 3600).toFixed(1).replace('.0', '') + ' t';
return 'om ' + Math.round(s / 86400) + ' d';
}
function setAtIn(minutes) {
const d = new Date(Date.now() + minutes * 60000);
d.setSeconds(0, 0);
// datetime-local vil ha lokal tid uten sone, ikke ISO i UTC.
const pad = v => String(v).padStart(2, '0');
$('#at').value = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}`;
updateAnnounce();
}
async function req(path, opts) {
const r = await fetch(path, opts);
if (!r.ok) {
let e = r.statusText, login = false;
try { const j = await r.json(); e = j.error || e; login = !!j.login; } catch {}
const err = new Error(e);
err.status = r.status; err.login = login;
throw err;
}
return r;
}
const api = (path, body) =>
req(path, { method: 'POST', headers: headers(true), body: JSON.stringify(body || {}) });
function onError(e) {
say(e.message, 'err');
// Rettighetene kan ha endret seg (riktig PIN skrevet, sesjon utløpt).
if (e.status === 401 || e.status === 403) refreshAuth();
}
// ---------------------------------------------------------------- branding
function applyConfig(c) {
cfg = c;
const a = c.app || {};
document.title = a.title || 'Announcer';
// Siste ord i tittelen får aksentfargen; ett ord alene står som det er.
const parts = (a.title || 'Announcer').split(' ');
$('#title').textContent = '';
if (parts.length > 1) {
$('#title').append(document.createTextNode(parts.slice(0, -1).join(' ') + ' '));
const sp = document.createElement('span'); sp.textContent = parts.at(-1); $('#title').append(sp);
} else {
$('#title').textContent = parts[0];
}
$('#subtitle').textContent = a.subtitle || '';
if (a.accent) document.documentElement.style.setProperty('--accent', a.accent);
text.placeholder = a.message_placeholder || '';
$('#max').textContent = c.max_chars; text.maxLength = c.max_chars;
$('#chips').replaceChildren(...(a.quick_phrases || []).map(q => {
const b = document.createElement('button');
b.className = 'chip'; b.textContent = q.slice(0, 32) + (q.length > 32 ? '…' : ''); b.title = q;
b.onclick = () => { text.value = q; text.dispatchEvent(new Event('input')); text.focus(); };
return b;
}));
}
// -------------------------------------------------------------------- auth
function applyAuth(auth) {
can = auth.can || [];
const methods = auth.methods || [];
const who = $('#who');
if (auth.user) {
who.textContent = '';
who.append(document.createTextNode('Innlogget som '));
const b = document.createElement('b'); b.textContent = auth.user.name; who.append(b);
if (auth.user.roles.length) who.append(document.createTextNode(' (' + auth.user.roles.join(', ') + ')'));
if (auth.logout_url) {
who.append(document.createTextNode(' · '));
const a = document.createElement('a'); a.href = auth.logout_url; a.textContent = 'Logg ut'; who.append(a);
}
} else if (auth.enabled) {
who.textContent = 'Ikke innlogget';
} else {
who.textContent = '';
}
// OIDC-knapper
const oidc = methods.filter(m => m.login_url);
$('#loginbox').style.display = (oidc.length && !auth.user) ? 'block' : 'none';
$('#loginbtns').replaceChildren(...oidc.map(m => {
const a = document.createElement('a');
a.className = 'btn primary'; a.href = m.login_url; a.textContent = m.label || 'Logg inn';
return a;
}));
// PIN-feltet, bare når PIN er en av metodene og vi ikke alt er innlogget
const pin = methods.find(m => m.method === 'pin');
$('#pinbox').style.display = (pin && !auth.user) ? 'block' : 'none';
if (pin) {
$('#pinlabel').textContent = pin.label || 'PIN';
$('#pinhint').textContent = auth.error || pin.hint || '';
$('#pinhint').style.color = auth.error ? 'var(--danger)' : 'var(--muted)';
if (!$('#pin').value) $('#pin').value = localStorage.getItem('pin') || '';
}
me = auth.user;
$('#btnStop').classList.toggle('hidden', !may('stop'));
$('#btnClear').classList.toggle('hidden', !may('clear'));
updateAnnounce();
}
async function refreshAuth() {
try {
const c = await (await fetch('/api/config', { headers: headers(false) })).json();
applyAuth(c.auth || {});
} catch {}
}
// -------------------------------------------------------------------- init
async function init() {
const c = await (await fetch('/api/config', { headers: headers(false) })).json();
applyConfig(c);
const groups = {};
for (const v of c.voices) {
if (!groups[v.group]) { groups[v.group] = document.createElement('optgroup'); groups[v.group].label = v.group; voice.append(groups[v.group]); }
const o = document.createElement('option'); o.value = v.id; o.textContent = v.name; groups[v.group].append(o);
}
voice.value = localStorage.getItem('voice') || c.default_voice;
if (!voice.value) voice.value = c.default_voice;
for (const ch of c.chimes) {
const o = document.createElement('option'); o.value = ch.id; o.textContent = ch.name; chime.append(o);
}
chime.value = localStorage.getItem('chime') || c.default_chime;
if (!chime.value) chime.value = c.default_chime;
applyAuth(c.auth || {});
$('#atquick').replaceChildren(...[
['+5 min', 5], ['+15 min', 15], ['+1 t', 60], ['+3 t', 180],
].map(([label, min]) => {
const b = document.createElement('button');
b.textContent = label; b.onclick = () => setAtIn(min);
return b;
}));
poll();
pollTimer = setInterval(poll, 2000);
}
$('#at').addEventListener('input', updateAnnounce);
$('#btnSchedule').addEventListener('click', async () => {
const at = atEpoch();
if (!at) return say('Velg et tidspunkt først.', 'err');
if (at < serverNow()) return say('Tidspunktet er passert.', 'err');
$('#btnSchedule').disabled = true;
try {
const item = await (await api('/api/schedule', { ...payload(), at })).json();
say(`Planlagt ${new Date(item.at * 1000).toLocaleString('no', {
weekday: 'short', hour: '2-digit', minute: '2-digit' })}.`, 'ok');
text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null;
$('#at').value = '';
audio.pause(); audio.classList.remove('show');
poll();
} catch (e) { onError(e); }
finally { updateAnnounce(); }
});
async function cancelSched(id, what) {
if (!confirm(`Avbryte den planlagte meldingen?\n\n${what}`)) return;
try { await api('/api/schedule/cancel', { id }); say('Avbrutt.', 'ok'); poll(); }
catch (e) { onError(e); }
}
let pinDebounce = null;
$('#pin').addEventListener('input', e => {
localStorage.setItem('pin', e.target.value);
clearTimeout(pinDebounce);
// Vent til han er ferdig å skrive, så slipper vi en runde per tastetrykk.
pinDebounce = setTimeout(() => { refreshAuth(); poll(); }, 600);
});
text.addEventListener('input', () => { $('#count').textContent = text.value.length; updateAnnounce(); });
voice.addEventListener('change', () => { localStorage.setItem('voice', voice.value); updateAnnounce(); });
speed.addEventListener('input', () => { $('#speedval').textContent = parseFloat(speed.value).toFixed(2).replace(/0$/, '') + '×'; updateAnnounce(); });
chime.addEventListener('change', () => { localStorage.setItem('chime', chime.value); updateAnnounce(); });
btnPreview.addEventListener('click', async () => {
const p = payload();
if (!p.text) return say('Skriv en melding først.', 'err');
btnPreview.disabled = true; say('Lager lyd …');
try {
const r = await api('/api/preview', p);
const blob = await r.blob();
if (audio.src) URL.revokeObjectURL(audio.src);
audio.src = URL.createObjectURL(blob); audio.classList.add('show');
await audio.play().catch(() => {});
previewedKey = JSON.stringify(p);
say('Hør gjennom. Fornøyd? Send den ut.', 'ok');
} catch (e) { onError(e); }
finally { btnPreview.disabled = false; updateAnnounce(); }
});
btnAnnounce.addEventListener('click', async () => {
btnAnnounce.disabled = true;
try {
const item = await (await api('/api/announce', payload())).json();
say(`Lagt i kø som #${item.id}.`, 'ok');
text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null;
audio.pause(); audio.classList.remove('show');
poll();
} catch (e) { onError(e); }
finally { updateAnnounce(); }
});
$('#btnStop').addEventListener('click', () => api('/api/stop').then(poll).catch(onError));
$('#btnClear').addEventListener('click', () => api('/api/clear').then(poll).catch(onError));
const STATUS = { done: 'sendt', failed: 'feilet', stopped: 'stoppet', queued: 'i kø' };
function hhmm(ts) { return new Date(ts * 1000).toLocaleTimeString('no', { hour: '2-digit', minute: '2-digit' }); }
function li(it) {
const l = document.createElement('li'); l.className = it.status;
l.innerHTML = `<span class="t"></span><span class="x"></span><span class="by"></span><span class="s"></span>`;
l.querySelector('.t').textContent = hhmm(it.created);
l.querySelector('.x').textContent = it.text;
// Navnet står her, ikke IP-en. IP-en kommer bare med for en admin.
l.querySelector('.by').textContent = [it.user, it.sender].filter(Boolean).join(' · ');
l.querySelector('.s').textContent = (STATUS[it.status] || it.status) + (it.error ? ' · ' + it.error : '');
return l;
}
const SCHED_STATUS = { sent: 'sendt', missed: 'gikk glipp av', cancelled: 'avbrutt' };
function schedLi(it) {
const l = document.createElement('li');
l.className = it.status;
l.innerHTML = '<span class="t"></span><span class="x"></span><span class="by"></span><span class="s"></span>';
const when = new Date(it.at * 1000);
l.querySelector('.t').textContent = when.toLocaleTimeString('no', { hour: '2-digit', minute: '2-digit' });
l.querySelector('.x').textContent = it.text;
l.querySelector('.by').textContent = it.user || '';
const s = l.querySelector('.s');
if (it.status === 'pending') {
s.textContent = omTid(it.at) + ' ';
// Egne kan man alltid avbryte; andres krever samme rett som å tømme kø.
if (may('schedule') && (!me || !it.user || may('clear') || it.user === me.name)) {
const b = document.createElement('button');
b.className = 'x'; b.textContent = '✕'; b.title = 'Avbryt';
b.onclick = () => cancelSched(it.id, it.text);
s.append(b);
}
} else {
s.textContent = (SCHED_STATUS[it.status] || it.status) + (it.note ? ' · ' + it.note : '');
}
return l;
}
async function poll() {
const now = $('#now');
try {
const s = await (await req('/api/status', { headers: headers(false) })).json();
if (s.now) skew = s.now - Date.now() / 1000;
const sc = s.scheduled || { pending: [], done: [] };
const rows = [...sc.pending, ...sc.done];
$('#schedcard').classList.toggle('hidden', rows.length === 0);
$('#sched').replaceChildren(...rows.map(schedLi));
if (s.current) { now.className = 'now'; now.innerHTML = '<span class="pulse"></span>'; now.append(document.createTextNode(s.current.text)); }
else { now.className = 'now idle'; now.textContent = (cfg.app && cfg.app.idle_text) || ''; }
$('#queue').replaceChildren(...s.queued.map(li));
$('#history').replaceChildren(...s.history.map(li));
} catch (e) {
if (e.status === 401 || e.status === 403) {
now.className = 'now idle';
now.textContent = 'Logg inn for å se køen.';
$('#queue').replaceChildren(); $('#history').replaceChildren();
}
}
}
init();
</script>
</body>
</html>