Files
christianandClaude Opus 5 0db5e9b07d Bible App 1.4.34 — full source
First commit on the local Gitea server, made after moving the tree off the
Windows machine. Build outputs, node_modules and the AI models stay out
(see .gitignore); everything the installer build needs is tracked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:05:20 -04:00

976 lines
49 KiB
HTML
Raw Permalink 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="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>