Files
bible-app/admin.html
T

976 lines
49 KiB
HTML
Raw Normal View History

2026-09-24 13:05:20 -04:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Admin — Bible App</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
body{font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
background:#141414;color:#e6e6e6;padding-bottom:104px}
.muted{color:#8a8a8a}
/* Offstage identity. Amber and permanent: nothing here reaches the TV until it is pushed. */
header{position:sticky;top:0;z-index:40;background:#b26a00;color:#fff;
padding:8px 12px;padding-top:calc(8px + env(safe-area-inset-top));
display:flex;align-items:center;gap:10px}
header .t{font-weight:700;font-size:13px;letter-spacing:.06em;text-transform:uppercase}
header .sp{flex:1}
header button{background:rgba(0,0,0,.28);color:#fff;border:1px solid rgba(255,255,255,.35);
border-radius:6px;padding:6px 12px;font-size:13px;font-weight:600;cursor:pointer}
.dot{width:9px;height:9px;border-radius:50%;background:#666;flex:0 0 auto}
.dot.on{background:#39d353}.dot.stale{background:#e5a50a}
/* Login */
#gate{display:flex;align-items:center;justify-content:center;padding:48px 24px}
#gate .box{width:100%;max-width:340px;text-align:center}
#gate h1{font-size:20px;margin-bottom:6px}
#gate p{font-size:13px;margin-bottom:18px}
#pin{width:100%;font-size:28px;text-align:center;letter-spacing:.35em;padding:14px;
border-radius:10px;border:1px solid #3a3a3a;background:#1f1f1f;color:#fff;margin-bottom:12px}
#gate>.box>button{width:100%;padding:14px;font-size:16px;font-weight:600;border:0;
border-radius:10px;background:#0090ff;color:#fff;cursor:pointer}
#gate label{display:flex;align-items:center;gap:8px;justify-content:center;font-size:13px;margin-top:14px}
.err{color:#ff6b6b;font-size:13px;min-height:20px;margin-top:10px}
#main{display:none;max-width:820px;margin:0 auto;padding:12px}
#main.show{display:block}
/* Live strip */
.live{background:#16241a;border:1px solid #2f5236;border-radius:9px;padding:10px 12px;margin-bottom:12px}
.live h2{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:#8fd18f;margin-bottom:7px}
.live .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:4px 14px}
.live .kv{font-size:13px;display:flex;gap:6px}
.live .kv b{color:#7d9a83;font-weight:600;min-width:64px}
/* Sections — native <details> so they behave like real dropdowns on touch with no JS */
details{background:#1e1e1e;border:1px solid #303030;border-radius:9px;margin-bottom:8px}
details[open]{border-color:#3d3d3d}
summary{cursor:pointer;padding:13px 14px;font-weight:600;font-size:15px;
display:flex;align-items:center;gap:9px;list-style:none;user-select:none}
summary::-webkit-details-marker{display:none}
summary::after{content:'▸';margin-left:auto;color:#8a8a8a;font-size:12px;transition:transform .15s}
details[open]>summary::after{transform:rotate(90deg)}
summary .cnt{font-size:11px;color:#7a7a7a;font-weight:500}
.body{padding:0 14px 14px}
/* Rows */
.row{display:flex;align-items:center;gap:9px;padding:8px 0;border-top:1px solid #2a2a2a;flex-wrap:wrap}
.row:first-child{border-top:0}
.row .lab{flex:1 1 150px;min-width:120px;font-size:14px}
.row .lab small{display:block;color:#7a7a7a;font-size:11px;line-height:1.35}
.q{background:#0090ff;color:#fff;border:0;border-radius:6px;padding:8px 14px;
font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap}
.q:active{background:#0074cc}
.q.sec{background:#2c2c2c;border:1px solid #3d3d3d;color:#ddd}
.q.danger{background:#5a2222;border:1px solid #7a3030;color:#ffc9c9}
select,input[type=text],input[type=number]{background:#252525;color:#f0f0f0;border:1px solid #3a3a3a;
border-radius:6px;padding:8px;font-size:14px;max-width:100%}
select{min-width:96px}
input[type=text]{flex:1 1 130px;min-width:110px}
.chips{display:flex;flex-wrap:wrap;gap:6px;width:100%;padding:2px 0 6px}
.chip{display:flex;align-items:center;gap:6px;background:#242424;border:1px solid #383838;
border-radius:14px;padding:5px 11px;font-size:13px;cursor:pointer}
.chip input{margin:0}
.chip.on{background:#123049;border-color:#1d5b8a}
/* Content staging */
.stage{border:1px solid #333;border-radius:8px;padding:10px;margin-top:8px;background:#1a1a1a}
.stage h4{font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:#9a9a9a;margin-bottom:7px}
.files{display:flex;flex-direction:column;gap:5px;margin-bottom:8px}
.file{display:flex;align-items:center;gap:8px;background:#232323;border:1px solid #343434;
border-radius:6px;padding:6px 9px;font-size:13px}
.file .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.file .sz{color:#7a7a7a;font-size:11px;white-space:nowrap}
.file .x{background:none;border:0;color:#ff8a8a;cursor:pointer;font-size:16px;padding:0 4px}
.file.isdef{border-color:#1d5b8a;background:#132b3f}
.empty{color:#6f6f6f;font-size:13px;padding:4px 0}
.drop{border:1.5px dashed #3d3d3d;border-radius:8px;padding:14px;text-align:center;
color:#8a8a8a;font-size:13px;cursor:pointer}
.drop.over{border-color:#0090ff;color:#cfe8ff;background:#11242f}
.note{font-size:12px;color:#8a8a8a;margin-top:7px}
.warn{font-size:12px;color:#e5b95a;margin-top:7px}
/* Queue tray */
#tray{position:fixed;left:0;right:0;bottom:0;z-index:50;background:#101010;
border-top:1px solid #333;padding:9px 12px;padding-bottom:calc(9px + env(safe-area-inset-bottom));
display:none}
#tray.show{display:block}
#tray .bar{display:flex;align-items:center;gap:9px;max-width:820px;margin:0 auto}
#tray .n{flex:1;font-size:14px;font-weight:600}
#tray button{border:0;border-radius:7px;padding:11px 15px;font-size:14px;font-weight:700;cursor:pointer}
#push{background:#1f9d3d;color:#fff}
#push:disabled{background:#2c2c2c;color:#777}
#clearq{background:#2c2c2c;color:#ddd;font-weight:600}
#qlist{max-width:820px;margin:0 auto 8px;max-height:34vh;overflow:auto;display:none}
#qlist.show{display:block}
#qlist .qi{display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid #262626;font-size:13px}
#qlist .qi .t{flex:1}
#qlist .qi .x{background:none;border:0;color:#ff8a8a;cursor:pointer;font-size:15px}
#qlist .qi.done .t{color:#6f9a76;text-decoration:line-through}
#qlist .qi.failed .t{color:#ff8a8a}
#toast{position:fixed;left:50%;bottom:112px;transform:translateX(-50%) translateY(14px);
background:#0b3d17;color:#c9f5d4;border:1px solid #1d6b2c;padding:10px 18px;border-radius:8px;
font-size:14px;font-weight:600;opacity:0;transition:.18s;pointer-events:none;z-index:60;
max-width:92vw;text-align:center}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
#toast.bad{background:#4a1d1d;color:#ffc9c9;border-color:#7a2e2e}
</style>
</head>
<body>
<header>
<span class="dot" id="dot" title="Link to the live app"></span>
<span class="t">Admin · Offstage</span>
<span class="sp"></span>
<label id="immWrap" style="display:none;font-size:12px;align-items:center;gap:6px;">
<input type="checkbox" id="imm"> push instantly
</label>
<span class="muted" id="ver" style="font-size:11px"></span>
<button id="lock" style="display:none">Lock</button>
</header>
<div id="gate">
<div class="box">
<h1>Admin Console</h1>
<p class="muted">Prepare anything offstage. Nothing reaches the TV until you push it.</p>
<input id="pin" type="password" inputmode="numeric" autocomplete="off" placeholder="PIN" aria-label="Admin PIN">
<button id="unlock">Unlock</button>
<label><input type="checkbox" id="remember"> Remember on this device</label>
<div class="err" id="err"></div>
</div>
</div>
<div id="main"></div>
<div id="tray">
<div id="qlist"></div>
<div class="bar">
<span class="n" id="qn">0 queued</span>
<button id="clearq">Clear</button>
<button id="push">Push to TV</button>
</div>
</div>
<div id="toast"></div>
<!-- Same book table the app and the phone remote use, so names always agree. -->
<script src="book-aliases.js"></script>
<script>
(function () {
'use strict';
var PIN_KEY = 'bibleAdminPin';
var pin = '';
var live = null, liveMeta = { stale: true, ready: false };
var structure = null; // { Book: [versesInCh1, ...] }
var queue = []; // { id, label, kind:'push'|'commit', action?, payload?, body? }
var seq = 1;
var $ = function (id) { return document.getElementById(id); };
try { pin = sessionStorage.getItem(PIN_KEY) || localStorage.getItem(PIN_KEY) || ''; } catch (e) {}
function toast(m, bad) {
var t = $('toast'); t.textContent = m; t.className = bad ? 'show bad' : 'show';
clearTimeout(t._h); t._h = setTimeout(function () { t.className = bad ? 'bad' : ''; }, 2400);
}
function api(path, body) {
return fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.assign({ pin: pin }, body || {})) })
.then(function (r) { return r.json().catch(function () { return { ok: false, error: 'Bad response' }; })
.then(function (j) { j.__status = r.status; return j; }); });
}
var bootV = '';
function checkVer(v) {
if (!v) return;
if (!bootV) { bootV = v; $('ver').textContent = 'v' + v; return; }
if (v !== bootV) location.reload(); // a phone left open across a TV update must not run stale JS
}
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; });
}
function kb(n) {
n = Number(n) || 0;
return n > 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' KB';
}
// ---------------------------------------------------------------- the queue
/*
* Nothing fires on click. Every control appends to this queue, and one deliberate "Push to TV"
* sends the selected entries in order. That is the whole point of the console: the operator can
* line up ten changes while the congregation sees none of them, then land them together.
* "push instantly" in the header is the escape hatch for a single urgent change.
*/
function enqueue(item) {
if ($('imm').checked) { runOne(item).then(function (okd) { toast((okd ? 'Pushed: ' : 'Failed: ') + item.label, !okd); refreshLive(); }); return; }
item.id = 'q' + (seq++); item.sel = true;
queue.push(item); renderQueue(); toast('Queued: ' + item.label);
}
function renderQueue() {
$('qn').textContent = queue.length + ' queued';
$('tray').className = queue.length ? 'show' : '';
$('push').disabled = !queue.some(function (q) { return q.sel; });
var box = $('qlist'); box.className = queue.length ? 'show' : '';
box.innerHTML = '';
queue.forEach(function (q) {
var d = document.createElement('div');
d.className = 'qi' + (q.state ? ' ' + q.state : '');
var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = q.sel !== false;
cb.addEventListener('change', function () { q.sel = cb.checked; renderQueue(); });
var t = document.createElement('span'); t.className = 't'; t.textContent = q.label;
var x = document.createElement('button'); x.className = 'x'; x.textContent = '×';
x.addEventListener('click', function () { queue = queue.filter(function (o) { return o !== q; }); renderQueue(); });
d.appendChild(cb); d.appendChild(t); d.appendChild(x); box.appendChild(d);
});
}
function runOne(item) {
/*
* Two kinds of queued work:
* 'api' — a server-side change (staging commit, which live file is on screen, removing one).
* These do NOT need the Electron renderer; the app picks the change up from disk.
* 'push' — an action for the running stage, which does.
*/
if (item.kind === 'api' || item.kind === 'commit') {
var path = item.path || '/api/admin/stage/commit';
return api(path, item.body).then(function (j) {
if (j && j.ok) return true;
toast((j && j.error) || 'Failed: ' + item.label, true); return false;
}).catch(function () { return false; });
}
if (!liveMeta.ready) { toast('The app on the TV is not connected', true); return Promise.resolve(false); }
return api('/api/admin/push', { action: item.action, payload: item.payload || {} })
.then(function (j) { if (j && j.ok) return true; toast((j && j.error) || 'Push failed', true); return false; })
.catch(function () { return false; });
}
$('push').addEventListener('click', async function () {
var sel = queue.filter(function (q) { return q.sel !== false; });
if (!sel.length) return;
$('push').disabled = true;
var okN = 0, failN = 0;
for (var i = 0; i < sel.length; i++) {
var okd = await runOne(sel[i]);
sel[i].state = okd ? 'done' : 'failed';
okd ? okN++ : failN++;
renderQueue();
await new Promise(function (r) { setTimeout(r, 160); }); // let the stage settle between changes
}
toast(failN ? (okN + ' pushed, ' + failN + ' failed') : ('Pushed ' + okN + ' change' + (okN === 1 ? '' : 's')), !!failN);
/*
* Drop only what actually landed. Anything that failed stays so it can be retried, and
* anything the operator DESELECTED was never attempted — it has to stay queued. Filtering
* for `state === 'failed'` threw those away silently, so unticking a row deleted it.
*/
queue = queue.filter(function (q) { return q.state !== 'done'; });
queue.forEach(function (q) { q.state = null; });
renderQueue(); refreshLive(); refreshAllContent();
});
$('clearq').addEventListener('click', function () { queue = []; renderQueue(); });
/*
* Support/diagnostic handle, same idea as window.__interlinear and window.__tvDebug in the app:
* shows exactly what a queued row will send, which is the one thing you cannot tell from the
* label when a push misbehaves.
*/
try {
window.__adminQueue = function () {
return queue.map(function (q) {
return { label: q.label, selected: q.sel !== false, kind: q.kind,
action: q.action, payload: q.payload, path: q.path, body: q.body };
});
};
} catch (_) {}
// ---------------------------------------------------------------- row builders
function row(label, sub, controls) {
var d = document.createElement('div'); d.className = 'row';
var l = document.createElement('div'); l.className = 'lab';
l.innerHTML = esc(label) + (sub ? '<small>' + esc(sub) + '</small>' : '');
d.appendChild(l);
(controls || []).forEach(function (c) { d.appendChild(c); });
return d;
}
function btn(text, cls, fn) {
var b = document.createElement('button'); b.className = 'q' + (cls ? ' ' + cls : '');
b.textContent = text; b.addEventListener('click', fn); return b;
}
function sel(options, value) {
var s = document.createElement('select');
options.forEach(function (o) {
var op = document.createElement('option');
op.value = (o && o.v !== undefined) ? o.v : o;
op.textContent = (o && o.t !== undefined) ? o.t : o;
s.appendChild(op);
});
if (value !== undefined) s.value = value;
return s;
}
// A checkbox chip set that pushes ONE object-valued setting (tabVisibility, toolVisibility…).
function chipSet(items, current, onPush, pushLabel) {
var wrap = document.createElement('div'); wrap.style.width = '100%';
var chips = document.createElement('div'); chips.className = 'chips';
var state = {};
items.forEach(function (it) {
state[it.k] = current && (it.k in current) ? !!current[it.k] : (it.def !== false);
var lab = document.createElement('label'); lab.className = 'chip' + (state[it.k] ? ' on' : '');
var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = state[it.k];
cb.addEventListener('change', function () {
state[it.k] = cb.checked; lab.className = 'chip' + (cb.checked ? ' on' : '');
});
lab.appendChild(cb);
lab.appendChild(document.createTextNode(it.t));
lab.title = it.d || '';
chips.appendChild(lab);
});
wrap.appendChild(chips);
var b = btn(pushLabel || 'Queue these', '', function () { onPush(Object.assign({}, state)); });
b.style.marginTop = '2px';
wrap.appendChild(b);
return wrap;
}
function pushAction(label, action, payload) {
enqueue({ label: label, kind: 'push', action: action, payload: payload || {} });
}
function pushSetting(label, key, value, reload) {
enqueue({ label: label, kind: 'push', action: 'setSetting',
payload: { key: key, value: value, reload: !!reload } });
}
function section(title, hint) {
var d = document.createElement('details');
var s = document.createElement('summary');
s.innerHTML = esc(title) + (hint ? ' <span class="cnt">' + esc(hint) + '</span>' : '');
var b = document.createElement('div'); b.className = 'body';
d.appendChild(s); d.appendChild(b); d._body = b;
/*
* Re-read a content section's file lists whenever it is opened.
*
* Without this the lists were only fetched when the page was built, so anything staged
* afterwards — from a phone, from another browser, or simply before this section was expanded —
* showed as "Nothing staged", and the Add button then refused to queue because it counts the
* rows it can see.
*/
d.addEventListener('toggle', function () {
if (!d.open) return;
var block = b.querySelector('[data-ctype]');
if (block) refreshContent(block.dataset.ctype);
});
return d;
}
// ---------------------------------------------------------------- catalogue data
var TAB_MODES = [
{ m: 'bible', t: 'Bible' }, { m: 'announcements', t: 'Announcements' },
{ m: 'graphics', t: 'Graphics' }, { m: 'maps', t: 'Maps & Timelines' },
{ m: 'whiteboard', t: 'Whiteboard' }, { m: 'notes', t: 'Notes' },
{ m: 'prayer', t: 'Prayer' }, { m: 'ask', t: 'Ask (AI)' }, { m: 'ppsync', t: 'ProPresenter Sync' },
{ m: 'media', t: 'Media' }, { m: 'alpha', t: 'Alpha' }
];
var TRANSLATIONS = ['ESV','NIV','NLT','KJV','NKJV','MSG','AMP','NASB','ASV','AUV','BSB','CEB','CEVD',
'CJB','CSB17','DRB','ERV','GNT','GNTD','GNV','ISV','LBP','LSB','LSV','LXXE','MEV','NABRE','NET',
'NIV2011','NJB1985','NLV','NRSVCE','RSV','RSV2CE','SPE','TLV','TS2009','WEB','YLT'];
var DEFAULT_TRANSLATIONS = ['ESV','NIV','NLT','KJV','NKJV','MSG','AMP','NASB'];
var TOOLS = [
{ k: 'pen', t: 'Pen' }, { k: 'highlight', t: 'Highlighter' }, { k: 'erase', t: 'Eraser' },
{ k: 'laser', t: 'Laser' }, { k: 'spotlight', t: 'Spotlight' }, { k: 'undo', t: 'Undo' },
{ k: 'redo', t: 'Redo' }, { k: 'clear', t: 'Clear' }
];
// Word-popup card fields, mirroring Settings > Word Popup Visibility.
var CARD_FIELDS = [
{ k: 'title', t: 'Title', def: true },
{ k: 'ot_il', t: 'Hebrew card (master)', def: true },
{ k: 'ot_il_word', t: 'Heb: word', def: true },
{ k: 'ot_il_translit', t: 'Heb: pronunciation', def: true },
{ k: 'ot_il_definition', t: 'Heb: definition', def: true },
{ k: 'ot_il_origin', t: 'Heb: origin', def: true },
{ k: 'ot_il_usage', t: 'Heb: usage count', def: true },
{ k: 'ot_il_gloss', t: 'Heb: meaning here', def: false },
{ k: 'ot_il_lemma', t: 'Heb: dictionary form', def: false },
{ k: 'ot_il_grammar', t: 'Heb: grammar', def: false },
{ k: 'ot_il_morph_code', t: 'Heb: grammar code', def: false },
{ k: 'ot_il_strongs', t: "Heb: Strong's", def: false },
{ k: 'nt_il', t: 'Greek card (master)', def: true },
{ k: 'nt_il_word', t: 'Grk: word', def: true },
{ k: 'nt_il_translit', t: 'Grk: pronunciation', def: true },
{ k: 'nt_il_definition', t: 'Grk: definition', def: true },
{ k: 'nt_il_origin', t: 'Grk: origin', def: true },
{ k: 'nt_il_usage', t: 'Grk: usage count', def: true },
{ k: 'nt_il_gloss', t: 'Grk: meaning here', def: false },
{ k: 'nt_il_lemma', t: 'Grk: dictionary form', def: false },
{ k: 'nt_il_grammar', t: 'Grk: grammar', def: false },
{ k: 'nt_il_morph_code', t: 'Grk: grammar code', def: false },
{ k: 'nt_il_strongs', t: "Grk: Strong's", def: false },
{ k: 'ot_wlca', t: 'OT: Hebrew source verse', def: false },
{ k: 'ot_bdbt', t: 'OT: BDBT dictionary', def: false },
{ k: 'nt_ntgt', t: 'NT: Greek source verse', def: false },
{ k: 'nt_strongs_greek', t: "NT: Strong's Greek block", def: false }
];
var CONTENT_TYPES = [
{ k: 'graphics', t: 'Graphics', mode: 'graphics' },
{ k: 'maps', t: 'Maps & Timelines', mode: 'maps' },
{ k: 'announcements', t: 'Announcements', mode: 'announcements' },
{ k: 'splash', t: 'Splash screens', mode: null },
{ k: 'notes', t: 'Notes', mode: 'notes' }
];
// ---------------------------------------------------------------- build the UI
function buildUI() {
var main = $('main'); main.innerHTML = '';
// ---- live strip
var lv = document.createElement('div'); lv.className = 'live';
lv.innerHTML = '<h2>On the TV right now <span id="lvage" class="muted" style="font-weight:600"></span></h2>' +
'<div class="grid">' +
'<div class="kv"><b>Showing</b><span id="lvTab">—</span></div>' +
'<div class="kv"><b>Tool</b><span id="lvTool">—</span></div>' +
'<div class="kv"><b>Splash</b><span id="lvSplash">—</span></div>' +
'<div class="kv"><b>Red letter</b><span id="lvRed">—</span></div>' +
'<div class="kv" style="grid-column:1/-1"><b>Tabs</b><span id="lvTabs">—</span></div></div>';
main.appendChild(lv);
// ---- Passage
var s1 = section('Passage', 'go to a reference, change chapter, scroll');
(function () {
var books = (window.BibleBooks && window.BibleBooks.books) ? window.BibleBooks.books : [];
var bSel = sel(books, 'John');
var cSel = sel([1]); var vSel = sel([{ v: '', t: 'whole chapter' }]);
function fillCh() {
var chs = (structure && structure[bSel.value]) || [];
var n = chs.length || 150;
cSel.innerHTML = '';
for (var i = 1; i <= n; i++) cSel.appendChild(new Option(i, i));
fillV();
}
function fillV() {
var chs = (structure && structure[bSel.value]) || [];
var n = chs[Number(cSel.value) - 1] || 0;
vSel.innerHTML = ''; vSel.appendChild(new Option('whole chapter', ''));
for (var i = 1; i <= n; i++) vSel.appendChild(new Option(i, i));
}
bSel.addEventListener('change', fillCh);
cSel.addEventListener('change', fillV);
fillCh();
s1._body.appendChild(row('Open a passage', 'Book, chapter, and optionally an exact verse', [
bSel, cSel, vSel,
btn('Queue', '', function () {
var ref = bSel.value + ' ' + cSel.value + (vSel.value ? ':' + vSel.value : '');
pushAction('Open ' + ref, 'gotoPassage', { ref: ref });
})
]));
var free = document.createElement('input'); free.type = 'text'; free.placeholder = 'e.g. 1cor13:4-7';
s1._body.appendChild(row('Type a reference', 'Abbreviations and ranges work', [
free,
btn('Queue', '', function () {
var r = (free.value || '').trim();
if (!r) return toast('Type a reference first', true);
pushAction('Open ' + r, 'gotoPassage', { ref: r });
})
]));
s1._body.appendChild(row('Chapter', 'Step the passage that is already open', [
btn('◀ Previous', 'sec', function () { pushAction('Previous chapter', 'chapterPrev'); }),
btn('Next ▶', 'sec', function () { pushAction('Next chapter', 'chapterNext'); })
]));
s1._body.appendChild(row('Scroll', 'Move the text on screen', [
btn('Top', 'sec', function () { pushAction('Scroll to top', 'scroll_top'); }),
btn('Up', 'sec', function () { pushAction('Scroll up', 'scroll_up_5'); }),
btn('Down', 'sec', function () { pushAction('Scroll down', 'scroll_down_5'); }),
btn('End', 'sec', function () { pushAction('Scroll to end', 'scroll_bottom'); })
]));
})();
main.appendChild(s1);
// ---- Tabs
var s2 = section('Tabs', 'switch, open, close');
(function () {
var mSel = sel(TAB_MODES.map(function (m) { return { v: m.m, t: m.t }; }), 'bible');
s2._body.appendChild(row('Show a tab', 'Switches the TV to that tab', [
mSel, btn('Queue', '', function () {
var t = TAB_MODES.find(function (m) { return m.m === mSel.value; });
pushAction('Show ' + (t ? t.t : mSel.value) + ' tab', 'switchToMode', { mode: mSel.value });
})
]));
var liveSel = sel([{ v: '', t: '(reading live tabs…)' }]);
liveSel.id = 'liveTabSel';
s2._body.appendChild(row('Switch to a specific open tab', 'The actual tabs open on the TV now', [
liveSel, btn('Queue', '', function () {
if (!liveSel.value) return toast('No tab selected', true);
var t = liveSel.options[liveSel.selectedIndex];
pushAction('Switch to ' + t.textContent, 'switchTabId', { id: liveSel.value });
})
]));
s2._body.appendChild(row('Step through tabs', '', [
btn('◀ Previous tab', 'sec', function () { pushAction('Previous tab', 'switch_tab_left'); }),
btn('Next tab ▶', 'sec', function () { pushAction('Next tab', 'switch_tab_right'); })
]));
s2._body.appendChild(row('Open a new tab', 'Added to the end of the tab bar', [
btn('Bible', 'sec', function () { pushAction('New Bible tab', 'new_bible_tab'); }),
btn('Whiteboard', 'sec', function () { pushAction('New whiteboard tab', 'new_whiteboard_tab'); }),
btn('Notes', 'sec', function () { pushAction('New notes tab', 'new_notes_tab'); })
]));
// Closing a NAMED tab, not just whatever happens to be on screen — the admin is not looking
// at the TV, so "the active tab" is not something they can see.
var closeSel = sel([{ v: '', t: '(reading live tabs…)' }]);
closeSel.id = 'closeTabSel';
s2._body.appendChild(row('Close a specific tab', 'Pick from the tabs open on the TV now', [
closeSel, btn('Queue', 'danger', function () {
if (!closeSel.value) return toast('No tab selected', true);
var t = closeSel.options[closeSel.selectedIndex];
pushAction('Close tab "' + t.textContent + '"', 'close_tab', { id: closeSel.value });
})
]));
s2._body.appendChild(row('Close the active tab', 'Closes whatever is on screen', [
btn('Close active tab', 'danger', function () { pushAction('Close active tab', 'close_active_tab'); })
]));
})();
main.appendChild(s2);
// ---- Presentation
var s3 = section('Presentation', 'splash, laser, spotlight, red letter');
s3._body.appendChild(row('Splash screen', 'The holding image between segments', [
btn('Show', 'sec', function () { pushAction('Show splash', 'splash_show'); }),
btn('Hide', 'sec', function () { pushAction('Hide splash', 'splash_hide'); }),
btn('Toggle', 'sec', function () { pushAction('Toggle splash', 'splashToggle'); })
]));
s3._body.appendChild(row('Pointer overlays', 'Laser dot or a spotlight band', [
btn('Laser', 'sec', function () { pushAction('Laser on', 'laser'); }),
btn('Spotlight', 'sec', function () { pushAction('Spotlight on', 'spotlight'); }),
btn('Off', 'sec', function () { pushAction('Overlays off', 'presentOff'); })
]));
s3._body.appendChild(row('Red letter', "Words of Christ in red — toggles", [
btn('Toggle red letter', 'sec', function () { pushAction('Toggle red letter', 'redLetterToggle'); })
]));
s3._body.appendChild(row('Toolbar dock', 'Floating dock or the top rail', [
btn('Float', 'sec', function () { pushAction('Float the toolbar', 'dockFloat'); }),
btn('Dock to rail', 'sec', function () { pushAction('Dock the toolbar', 'dockRail'); })
]));
main.appendChild(s3);
// ---- Drawing
var s4 = section('Drawing', 'tool, colour, undo, clear');
(function () {
var tSel = sel([{ v: 'pen', t: 'Pen' }, { v: 'highlight', t: 'Highlighter' },
{ v: 'erase', t: 'Eraser' }, { v: 'select', t: 'Select' }], 'pen');
s4._body.appendChild(row('Active tool', '', [
tSel, btn('Queue', '', function () {
pushAction('Tool: ' + tSel.options[tSel.selectedIndex].textContent, 'tool', { name: tSel.value });
})
]));
var colors = ['#ffff00','#ff3b30','#34c759','#0a84ff','#ff9f0a','#ffffff','#000000','#af52de'];
var cWrap = document.createElement('div'); cWrap.className = 'chips';
colors.forEach(function (c) {
var b = document.createElement('button');
b.className = 'q sec'; b.style.background = c; b.style.width = '34px'; b.style.height = '30px';
b.style.padding = '0'; b.title = c;
b.addEventListener('click', function () { pushAction('Pen colour ' + c, 'pen_color', { color: c }); });
cWrap.appendChild(b);
});
s4._body.appendChild(row('Pen colour', 'Applies to the pen tool', [cWrap]));
s4._body.appendChild(row('History', '', [
btn('Undo', 'sec', function () { pushAction('Undo', 'undo'); }),
btn('Redo', 'sec', function () { pushAction('Redo', 'redo'); })
]));
s4._body.appendChild(row('Clear drawings', 'Cannot be undone from here', [
btn('This tab', 'danger', function () { pushAction('Clear drawings on the active tab', 'clear_current'); }),
btn('Every tab', 'danger', function () { pushAction('Clear drawings on EVERY tab', 'clear_all'); })
]));
})();
main.appendChild(s4);
// ---- Content (one section per type)
CONTENT_TYPES.forEach(function (ct) {
var s = section(ct.t, 'add files offstage, then push');
s._body.appendChild(buildContentBlock(ct));
main.appendChild(s);
});
// ---- Settings
var s6 = section('Settings', 'tabs, text size, translations, tools, word cards');
(function () {
var b = s6._body;
b.appendChild(row('Which tabs are visible', 'Hidden tabs disappear from the tab bar', [
chipSet(TAB_MODES.filter(function (m) { return m.m !== 'media' && m.m !== 'alpha'; })
.map(function (m) { return { k: m.m, t: m.t, def: true }; }),
null,
function (v) { pushSetting('Tab visibility', 'tabVisibility', v); },
'Queue tab visibility')
]));
var fs = document.createElement('input'); fs.type = 'number'; fs.min = 40; fs.max = 120; fs.value = 60;
fs.style.width = '90px';
b.appendChild(row('Bible text size', '40–120 px', [
fs, btn('Queue', '', function () {
var v = Math.max(40, Math.min(120, parseInt(fs.value, 10) || 60));
pushSetting('Bible text size ' + v + 'px', 'bibleFontSize', String(v));
})
]));
b.appendChild(row('Word popups', 'Double-tap a word to open its card', [
btn('On', 'sec', function () { pushSetting('Word popups ON', 'wordPopupsEnabled', 'true'); }),
btn('Off', 'sec', function () { pushSetting('Word popups OFF', 'wordPopupsEnabled', 'false'); })
]));
b.appendChild(row('What the word card shows', 'Each line of the popup card', [
chipSet(CARD_FIELDS, null,
function (v) { pushSetting('Word card fields', 'popupVisibility', v); },
'Queue word card fields')
]));
b.appendChild(row('Which translations are offered', 'In the app’s translation picker', [
chipSet(TRANSLATIONS.map(function (t) { return { k: t, t: t, def: DEFAULT_TRANSLATIONS.indexOf(t) >= 0 }; }),
null,
function (v) { pushSetting('Translation visibility', 'translationVisibility', v); },
'Queue translations')
]));
b.appendChild(row('Which tools are on the toolbar', '', [
chipSet(TOOLS, null,
function (v) { pushSetting('Tool visibility', 'toolVisibility', v); },
'Queue tool visibility')
]));
var cf = document.createElement('input'); cf.type = 'number'; cf.min = 0; cf.max = 5000; cf.step = 100; cf.value = 600;
cf.style.width = '100px';
b.appendChild(row('Carousel crossfade', 'Milliseconds between graphics/announcement slides', [
cf, btn('Queue', '', function () {
var v = Math.max(0, Math.min(5000, parseInt(cf.value, 10) || 0));
pushSetting('Crossfade ' + v + 'ms', 'carouselCrossfadeMs', String(v));
})
]));
b.appendChild(row('Red letter setting', 'Persisted on/off (separate from the toggle above)', [
btn('On', 'sec', function () { pushSetting('Red letter setting ON', 'redLetterEnabled', 'true'); }),
btn('Off', 'sec', function () { pushSetting('Red letter setting OFF', 'redLetterEnabled', 'false'); })
]));
var ro = document.createElement('input'); ro.type = 'text';
ro.placeholder = 'e.g. John 3:16-21, Matt 5:3-12';
b.appendChild(row('Red letter overrides', 'Extra ranges to mark as words of Christ', [
ro, btn('Queue', '', function () { pushSetting('Red letter overrides', 'redLetterOverrides', ro.value || ''); })
]));
})();
main.appendChild(s6);
// ---- Profiles
var s7 = section('Service profiles', 'apply a saved setup');
(function () {
var pSel = sel([{ v: '', t: '(loading…)' }]); pSel.id = 'profSel';
s7._body.appendChild(row('Apply a profile', 'Applies its tabs, media and settings, then reloads the app', [
pSel, btn('Queue', '', function () {
if (!pSel.value) return toast('No profile selected', true);
pushAction('Apply profile "' + pSel.value + '"', 'applyProfile', { name: pSel.value });
})
]));
s7._body.appendChild(row('', '', []));
fetch('/api/profiles/list').then(function (r) { return r.json(); }).then(function (j) {
var names = (j && j.profiles) ? j.profiles.map(function (p) { return p.name; }) : [];
pSel.innerHTML = '';
if (!names.length) { pSel.appendChild(new Option('(no profiles saved)', '')); return; }
names.forEach(function (n) { pSel.appendChild(new Option(n, n)); });
}).catch(function () { pSel.innerHTML = ''; pSel.appendChild(new Option('(could not load)', '')); });
})();
main.appendChild(s7);
// ---- Danger
var s8 = section('Restart', 'last resort');
s8._body.appendChild(row('Reload the app on the TV', 'Everything on screen is rebuilt. Drawings are restored from disk; unsaved state is not.', [
btn('Reload the TV app', 'danger', function () {
if (!confirm('Reload the app on the TV now? The screen will go blank for a moment.')) return;
pushAction('RELOAD the TV app', 'reset_app');
})
]));
main.appendChild(s8);
refreshAllContent();
}
// ---------------------------------------------------------------- content staging block
function buildContentBlock(ct) {
var wrap = document.createElement('div');
wrap.dataset.ctype = ct.k;
var drop = document.createElement('div'); drop.className = 'drop';
drop.textContent = 'Tap to choose files, or drop them here';
var file = document.createElement('input'); file.type = 'file'; file.multiple = true; file.style.display = 'none';
drop.addEventListener('click', function () { file.click(); });
file.addEventListener('change', function () { uploadFiles(ct.k, file.files); file.value = ''; });
['dragenter', 'dragover'].forEach(function (e) {
drop.addEventListener(e, function (ev) { ev.preventDefault(); drop.classList.add('over'); });
});
['dragleave', 'drop'].forEach(function (e) {
drop.addEventListener(e, function (ev) { ev.preventDefault(); drop.classList.remove('over'); });
});
drop.addEventListener('drop', function (ev) {
if (ev.dataTransfer && ev.dataTransfer.files) uploadFiles(ct.k, ev.dataTransfer.files);
});
wrap.appendChild(drop);
wrap.appendChild(file);
var staged = document.createElement('div'); staged.className = 'stage';
staged.innerHTML = '<h4>Staged — not on the TV</h4><div class="files" data-staged></div>';
wrap.appendChild(staged);
var actions = document.createElement('div'); actions.className = 'row';
actions.style.borderTop = '0';
actions.innerHTML = '<div class="lab">Push to the live ' + esc(ct.t) +
'<small>Add keeps what is already there. Replace swaps the whole set.</small></div>';
var addBtn = btn('Add to live', '', function () { queueCommit(ct, 'add'); });
var repBtn = btn('Replace live', 'danger', function () { queueCommit(ct, 'replace'); });
repBtn.dataset.replace = '1';
actions.appendChild(addBtn); actions.appendChild(repBtn);
wrap.appendChild(actions);
var lw = document.createElement('div'); lw.className = 'stage';
lw.innerHTML = '<h4>Already live on the TV</h4><div class="files" data-live></div>';
wrap.appendChild(lw);
if (ct.mode) {
var r = row('After pushing, show it', 'Queues a switch to the ' + ct.t + ' tab', [
btn('Queue "show ' + ct.t + '"', 'sec', function () {
pushAction('Show ' + ct.t + ' tab', 'switchToMode', { mode: ct.mode });
})
]);
wrap.appendChild(r);
}
var note = document.createElement('div'); note.className = 'note'; note.dataset.note = '1';
wrap.appendChild(note);
return wrap;
}
function uploadFiles(type, files) {
if (!files || !files.length) return;
var list = Array.prototype.slice.call(files);
var done = 0, failed = 0;
toast('Uploading ' + list.length + ' file' + (list.length === 1 ? '' : 's') + ' to staging…');
// Sequential: a phone on church Wi-Fi uploading five 8 MB photos in parallel just times out.
(function next() {
if (!list.length) {
refreshContent(type);
toast(failed ? (done + ' staged, ' + failed + ' failed') : (done + ' staged — not on the TV yet'), !!failed);
return;
}
var f = list.shift();
fetch('/api/admin/stage/upload', {
method: 'POST',
headers: { 'x-admin-pin': pin, 'x-stage-type': type, 'x-filename': encodeURIComponent(f.name) },
body: f
}).then(function (r) { return r.json().catch(function () { return { ok: false }; }); })
.then(function (j) { if (j && j.ok) done++; else { failed++; if (j && j.error) toast(j.error, true); } })
.catch(function () { failed++; })
.then(next);
})();
}
/*
* Ask the SERVER what is staged, not the DOM.
*
* Counting the rows on screen meant a list that had not been refreshed made the button refuse to
* queue anything ("Nothing staged") even though files were sitting in the staging folder. The
* server is the only thing that actually knows.
*/
function queueCommit(ct, mode) {
api('/api/admin/stage/list', { type: ct.k }).then(function (j) {
if (!j || !j.ok) return toast((j && j.error) || 'Could not read the staging folder', true);
var n = (j.staged || []).length;
refreshContent(ct.k); // repaint, so what they see matches
if (!n) return toast('Nothing staged for ' + ct.t + ' — add a file first', true);
if (mode === 'replace' && !j.canReplace) return toast(ct.t + ' is add-only', true);
if (mode === 'replace') {
if (!confirm('Replace the live ' + ct.t + ' with the ' + n + ' staged file' +
(n === 1 ? '' : 's') + '?\n\n' +
(j.live || []).length + ' file' + ((j.live || []).length === 1 ? '' : 's') +
' currently live will move to a recovery folder, not be deleted.')) return;
}
var wrap = document.querySelector('[data-ctype="' + ct.k + '"]');
var chosen = wrap ? wrap.querySelector('[data-staged] input[type=radio]:checked') : null;
// Only honour the radio if that file is genuinely still staged.
var def = '';
if (chosen && j.staged.some(function (f) { return f.name === chosen.value; })) def = chosen.value;
enqueue({
label: (mode === 'replace' ? 'REPLACE ' : 'Add to ') + ct.t +
' (' + n + ' file' + (n === 1 ? '' : 's') + (def ? ', show "' + def + '"' : '') + ')',
kind: 'commit',
body: { type: ct.k, mode: mode, setDefault: def }
});
}).catch(function () { toast('Could not reach the app', true); });
}
function refreshAllContent() { CONTENT_TYPES.forEach(function (c) { refreshContent(c.k); }); }
function refreshContent(type) {
var wrap = document.querySelector('[data-ctype="' + type + '"]');
if (!wrap) return;
var ct = CONTENT_TYPES.filter(function (c) { return c.k === type; })[0] || { k: type, t: type };
api('/api/admin/stage/list', { type: type }).then(function (j) {
if (!j || !j.ok) return;
var sBox = wrap.querySelector('[data-staged]');
var lBox = wrap.querySelector('[data-live]');
sBox.innerHTML = ''; lBox.innerHTML = '';
if (!j.staged.length) sBox.innerHTML = '<div class="empty">Nothing staged.</div>';
j.staged.forEach(function (f) {
var d = document.createElement('div'); d.className = 'file';
// A radio lets one staged file become the one actually on screen after the push.
var rb = document.createElement('input');
rb.type = 'radio'; rb.name = 'def_' + type; rb.value = f.name;
rb.title = 'Make this the one shown on the TV';
var nm = document.createElement('span'); nm.className = 'nm'; nm.textContent = f.name;
var sz = document.createElement('span'); sz.className = 'sz'; sz.textContent = kb(f.size);
var x = document.createElement('button'); x.className = 'x'; x.textContent = '×';
x.title = 'Remove from staging';
x.addEventListener('click', function () {
api('/api/admin/stage/delete', { type: type, name: f.name })
.then(function () { refreshContent(type); });
});
d.appendChild(rb); d.appendChild(nm); d.appendChild(sz); d.appendChild(x);
sBox.appendChild(d);
});
if (!j.live.length) lBox.innerHTML = '<div class="empty">Nothing live yet.</div>';
j.live.forEach(function (f) {
var d = document.createElement('div');
var isDef = j.liveDefault && f.name.toLowerCase() === String(j.liveDefault).toLowerCase();
d.className = 'file' + (isDef ? ' isdef' : '');
var nm = document.createElement('span'); nm.className = 'nm';
nm.textContent = f.name + (isDef ? ' ← on screen' : '');
var sz = document.createElement('span'); sz.className = 'sz'; sz.textContent = kb(f.size);
d.appendChild(nm); d.appendChild(sz);
/*
* Changing WHICH live file is on screen, and pulling one, are both ordinary things to need
* mid-service without uploading anything — so they belong here and not only in Content
* Manager. Both queue like everything else.
*/
if (!isDef) {
var showB = document.createElement('button');
showB.className = 'x'; showB.style.color = '#7fbfff'; showB.style.fontSize = '12px';
showB.style.fontWeight = '700'; showB.textContent = 'SHOW';
showB.title = 'Make this the one on screen';
showB.addEventListener('click', function () {
enqueue({ label: 'Show "' + f.name + '" in ' + ct.t, kind: 'api',
path: '/api/admin/stage/live-default', body: { type: type, name: f.name } });
});
d.appendChild(showB);
}
var delB = document.createElement('button');
delB.className = 'x'; delB.textContent = '×';
delB.title = 'Remove from the live set (recoverable)';
delB.addEventListener('click', function () {
if (!confirm('Remove "' + f.name + '" from the live ' + ct.t + '?\n\n' +
'It moves to the recovery folder, not deleted.')) return;
enqueue({ label: 'Remove "' + f.name + '" from ' + ct.t, kind: 'api',
path: '/api/admin/stage/live-delete', body: { type: type, name: f.name } });
});
d.appendChild(delB);
lBox.appendChild(d);
});
var rep = wrap.querySelector('[data-replace]');
var note = wrap.querySelector('[data-note]');
if (rep && !j.canReplace) {
rep.disabled = true; rep.style.opacity = '.4'; rep.style.cursor = 'not-allowed';
rep.title = 'Add-only for this type';
if (note) {
note.className = 'warn';
note.textContent = 'Add-only: ' + j.label + ' shares a folder with another content type and ' +
'both use image files, so a replace could not tell them apart. Remove files you no longer ' +
'want in Content Manager.';
}
} else if (note) {
note.className = 'note';
note.textContent = 'Replace moves the current files to a recovery folder first — nothing is deleted outright.';
}
}).catch(function () {});
}
// ---------------------------------------------------------------- live state
function refreshLive() {
return api('/api/admin/live-state').then(function (j) {
if (j && j.ok) {
checkVer(j.v);
live = j.state || null;
liveMeta = { stale: !!j.stale, ready: !!j.rendererReady };
paintLive();
} else if (j && (j.__status === 401 || j.__status === 429)) {
lock((j && j.error) || 'Session ended');
}
}).catch(function () {
$('dot').className = 'dot';
var a = $('lvage'); if (a) a.textContent = '· offline';
});
}
function paintLive() {
var act = null, tabs = (live && live.tabs) || [];
tabs.forEach(function (t) { if (t.active) act = t; });
var desc = '—';
if (act) desc = (act.mode === 'bible' && act.book) ? (act.book + ' ' + act.chapter) : (act.title || act.mode);
var set = function (id, v) { var e = $(id); if (e) e.textContent = v; };
set('lvTab', desc);
set('lvTool', (live && live.tool) || '—');
set('lvSplash', live ? (live.splash ? 'ON' : 'off') : '—');
set('lvRed', live ? (live.redLetter ? 'ON' : 'off') : '—');
set('lvTabs', tabs.length ? tabs.map(function (t) { return (t.active ? '▸' : '') + (t.title || t.mode); }).join(' · ') : '—');
$('dot').className = 'dot' + (liveMeta.ready && !liveMeta.stale ? ' on' : (live ? ' stale' : ''));
var a = $('lvage');
if (a) a.textContent = !liveMeta.ready ? '· app not connected' : (liveMeta.stale ? '· stale' : '· live');
// Keep both "specific open tab" dropdowns in step with the real tab bar. Rebuilt only when the
// tab list actually changes, so a selection is not wiped by the 2s poll while being read.
var sig = tabs.map(function (t) { return t.id + ':' + t.title; }).join('|');
['liveTabSel', 'closeTabSel'].forEach(function (id) {
var ls = $(id);
if (!ls || !tabs.length || ls._sig === sig) return;
var cur = ls.value;
ls._sig = sig; ls.innerHTML = '';
tabs.forEach(function (t) { ls.appendChild(new Option(t.title || t.mode, t.id)); });
if (cur) ls.value = cur;
});
}
// ---------------------------------------------------------------- gate
function openConsole() {
$('gate').style.display = 'none';
$('main').classList.add('show');
$('lock').style.display = ''; $('immWrap').style.display = 'flex';
// Chapter/verse counts for the passage dropdowns. bible-structure.js is an ES module, so the
// object literal is lifted out of the source rather than imported.
fetch('bible-structure.js').then(function (r) { return r.text(); }).then(function (src) {
try { structure = JSON.parse(src.slice(src.indexOf('{'), src.lastIndexOf('}') + 1)); } catch (_) {}
}).catch(function () {}).then(function () {
buildUI(); refreshLive();
if (!window._adminPoll) window._adminPoll = setInterval(refreshLive, 2000);
});
}
function lock(msg) {
pin = '';
try { sessionStorage.removeItem(PIN_KEY); localStorage.removeItem(PIN_KEY); } catch (e) {}
if (window._adminPoll) { clearInterval(window._adminPoll); window._adminPoll = 0; }
queue = []; renderQueue();
$('main').classList.remove('show'); $('main').innerHTML = '';
$('gate').style.display = 'flex';
$('lock').style.display = 'none'; $('immWrap').style.display = 'none';
$('pin').value = ''; $('err').textContent = msg || '';
}
function tryAuth(candidate) {
var prev = pin; pin = candidate;
return api('/api/admin/auth').then(function (j) {
if (j && j.ok) {
checkVer(j.v);
try {
if ($('remember').checked) localStorage.setItem(PIN_KEY, pin);
else sessionStorage.setItem(PIN_KEY, pin);
} catch (e) {}
$('err').textContent = ''; openConsole(); return true;
}
pin = prev; $('err').textContent = (j && j.error) || 'Wrong PIN'; return false;
}).catch(function () { pin = prev; $('err').textContent = 'Cannot reach the app'; return false; });
}
$('unlock').addEventListener('click', function () {
var v = ($('pin').value || '').trim();
if (!v) { $('err').textContent = 'Enter the admin PIN'; return; }
tryAuth(v);
});
$('pin').addEventListener('keydown', function (e) { if (e.key === 'Enter') $('unlock').click(); });
$('lock').addEventListener('click', function () { lock('Locked'); });
fetch('/api/admin/status').then(function (r) { return r.json(); }).then(function (j) {
if (j) checkVer(j.v);
if (!j || !j.ok || !j.enabled) { $('err').textContent = 'Admin access is turned off. Enable it in Settings on the TV.'; return; }
if (!j.hasPin) { $('err').textContent = 'No admin PIN set yet. Set one in Settings on the TV.'; return; }
if (pin) tryAuth(pin);
}).catch(function () { $('err').textContent = 'Cannot reach the app'; });
})();
</script>
</body>
</html>