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>
976 lines
49 KiB
HTML
976 lines
49 KiB
HTML
<!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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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>
|