239 lines
12 KiB
HTML
239 lines
12 KiB
HTML
<!doctype html>
|
||
<html lang="no">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>SG 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; }
|
||
.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; }
|
||
.check { display: flex; align-items: center; gap: 8px; padding: 10px 0; font-size: 15px; }
|
||
.check input { width: 18px; height: 18px; accent-color: var(--accent); }
|
||
.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; }
|
||
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); }
|
||
.now.idle::before { content: "Stille · ingenting i kø"; }
|
||
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.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 } }
|
||
#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%; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<h1>SG <span>Announcer</span></h1>
|
||
<p class="sub">Skriv meldingen, forhåndslytt, og send den ut på anlegget.</p>
|
||
|
||
<div class="card" id="pinbox">
|
||
<label for="pin">PIN</label>
|
||
<input id="pin" type="password" inputmode="numeric" placeholder="Spør i crew-rommet">
|
||
</div>
|
||
|
||
<div class="card">
|
||
<label for="text">Melding</label>
|
||
<textarea id="text" placeholder="F.eks. «Pizzaen er kommet. Hent i kantina!»" 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>
|
||
<audio id="player" controls></audio>
|
||
<div class="msg" id="msg"></div>
|
||
</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 = {}, previewedKey = null;
|
||
|
||
const QUICK = [
|
||
'Hei alle sammen! Pizzaen er kommet, hent i kantina.',
|
||
'Turneringen starter om ti minutter. Møt opp ved scenen.',
|
||
'Husk å rydde rundt plassen din. Takk!',
|
||
'Natta! Vi senker lyset, hold volumet nede.',
|
||
];
|
||
|
||
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() {
|
||
const h = { 'Content-Type': 'application/json' };
|
||
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 updateAnnounce() {
|
||
btnAnnounce.disabled = !(previewedKey && previewedKey === key());
|
||
btnAnnounce.title = btnAnnounce.disabled ? 'Forhåndslytt først' : '';
|
||
}
|
||
async function api(path, body) {
|
||
const r = await fetch(path, { method: 'POST', headers: headers(), body: JSON.stringify(body || {}) });
|
||
if (!r.ok) {
|
||
let e = r.statusText; try { e = (await r.json()).error || e; } catch {}
|
||
throw new Error(e);
|
||
}
|
||
return r;
|
||
}
|
||
|
||
async function init() {
|
||
cfg = await (await fetch('/api/config')).json();
|
||
const groups = {};
|
||
for (const v of cfg.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') || cfg.default_voice;
|
||
if (!voice.value) voice.value = cfg.default_voice;
|
||
for (const c of cfg.chimes) {
|
||
const o = document.createElement('option'); o.value = c.id; o.textContent = c.name; chime.append(o);
|
||
}
|
||
chime.value = localStorage.getItem('chime') || cfg.default_chime;
|
||
if (!chime.value) chime.value = cfg.default_chime;
|
||
$('#max').textContent = cfg.max_chars; text.maxLength = cfg.max_chars;
|
||
if (cfg.pin_required) { $('#pinbox').style.display = 'block'; $('#pin').value = localStorage.getItem('pin') || ''; }
|
||
for (const q of QUICK) {
|
||
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(); };
|
||
$('#chips').append(b);
|
||
}
|
||
poll(); setInterval(poll, 2000);
|
||
}
|
||
|
||
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(); });
|
||
$('#pin').addEventListener('input', e => localStorage.setItem('pin', e.target.value));
|
||
|
||
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); updateAnnounce();
|
||
say('Hør gjennom. Fornøyd? Send den ut.', 'ok');
|
||
} catch (e) { say(e.message, 'err'); }
|
||
finally { btnPreview.disabled = false; }
|
||
});
|
||
|
||
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) { say(e.message, 'err'); updateAnnounce(); }
|
||
});
|
||
|
||
$('#btnStop').addEventListener('click', () => api('/api/stop').then(poll).catch(e => say(e.message, 'err')));
|
||
$('#btnClear').addEventListener('click', () => api('/api/clear').then(poll).catch(e => say(e.message, 'err')));
|
||
|
||
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">${hhmm(it.created)}</span><span class="x"></span><span class="s">${STATUS[it.status] || it.status}${it.error ? ' · ' + it.error : ''}</span>`;
|
||
l.querySelector('.x').textContent = it.text;
|
||
return l;
|
||
}
|
||
async function poll() {
|
||
try {
|
||
const s = await (await fetch('/api/status')).json();
|
||
const now = $('#now');
|
||
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 = ''; }
|
||
$('#queue').replaceChildren(...s.queued.map(li));
|
||
$('#history').replaceChildren(...s.history.map(li));
|
||
} catch {}
|
||
}
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|