Redesign Media Centre as a teletext broadcast information service

Replaces the neutral dark card-based theme with "Page 100": teletext's
locked eight colours at full saturation on pure black, numbered pages
reachable by typing the number (or the r/g/y/c fastext keys), and no
borders, boxes, or radius anywhere — grouping is carried by full-bleed
colour fields and row weight instead.

Chosen via the impeccable skill's replacement-world flow: seven visual
candidates derived from the audience's own world, a mandatory roll
assigned Ceefax/US-cable-guide teletext, six catalog challengers weighed
against it, and the user locked the assigned direction on the decision
page (seed aab4510e).

- New templates for all nine pages (home/watch/library/discover/
  requests/system/activity/login/settings) behind a shared _shell.html
  carrying the direction contract and _macros.html for the row/bar/tile
  vocabulary; deletes the old single-page dashboard.html and its
  dedicated stylesheets/script.
- static/css/base.css + teletext.css: the locked palette, a two-height
  type scale (Chivo Mono / DotGothic16), and the five disciplines the
  system is held to (no in-between tone, no enclosure, one shared bar
  ruler, trend only where real history exists, pages weighted by state).
- static/js/service.js: header clock, page-number jump, fastext keys,
  two-tap arm/confirm add, guarded live polling — replaces dashboard.js.
- app.py: nav_items now carry page numbers; fastext defaults added to
  the page-chrome context processor; version bumped to 2.0.0.
- DESIGN.md + .impeccable/design.json regenerated from the built world
  by the documenter pass; PRODUCT.md and CLAUDE.md updated to describe
  the new system in place of the retired one.
- Fixed a live bug found along the way: settings.html called
  url_for('dashboard'), an endpoint removed earlier in the branch, which
  would have 500'd the Settings page.
- Fixed a One-Ruler violation the finish review's recapture request
  surfaced: --bar-cells disagreed with the bar() macro's hardcoded cell
  count below the 760px breakpoint, so a fill could land mid-cell on a
  phone. Removed the mobile override; the ruler is now 20 cells at every
  width, matching the macro unconditionally.

Passed two inspection rounds (desktop + true-390px mobile, via an
iframe harness since macOS Chrome clamps --window-size) and a finish
review; QA screenshots and interview logs are gitignored as scratch,
not shipped in the repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBw1QodG8ZMYHrGeQfqyDS
This commit is contained in:
Barely Removable 2026-08-29 21:00:45 -07:00
parent 9a277b9031
commit 4b19cbdfb2
25 changed files with 3002 additions and 2343 deletions

View file

@ -1,406 +0,0 @@
/* Server-rendered config, read off <body data-*> (see dashboard.html).
URL_BASE carries the reverse-proxy prefix so /api/* fetches still
resolve when the app is mounted on a subpath via SCRIPT_NAME. */
const URL_BASE = document.body.dataset.urlBase || "";
const TIMEZONE = document.body.dataset.timezone || "UTC";
let sonarrSearchResult = null;
let radarrSearchResult = null;
/* ── Clock ── */
function updateClock() {
const el = document.getElementById('header-clock');
if (!el) return;
try {
el.textContent = new Date().toLocaleTimeString('en-US', {
timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit'
});
} catch (e) {
el.textContent = new Date().toLocaleTimeString();
}
}
updateClock();
setInterval(updateClock, 1000);
/* ── Discover: show more / show less ── */
function toggleMore(btn, listId) {
const list = document.getElementById(listId);
if (!list) return;
const items = Array.from(list.children);
const hidden = items.filter(el => el.classList.contains('is-hidden'));
if (hidden.length) {
hidden.forEach(el => el.classList.remove('is-hidden'));
btn.textContent = 'Show less';
btn.setAttribute('aria-expanded', 'true');
} else {
items.forEach((el, i) => {
if (i >= 5) el.classList.add('is-hidden');
});
const n = items.length - 5;
btn.textContent = 'Show ' + n + ' more';
btn.setAttribute('aria-expanded', 'false');
}
}
/* ── Add flow: first tap arms, second tap commits ──
Adding queues a real download, so a single stray tap shouldn't do it. */
let armedBtn = null;
let armedTimer = null;
function disarm() {
if (armedBtn) {
armedBtn.classList.remove('armed');
armedBtn.textContent = '+ Add';
armedBtn = null;
}
clearTimeout(armedTimer);
}
function armAdd(btn) {
if (armedBtn === btn) { disarm(); return doAdd(btn); }
disarm();
armedBtn = btn;
btn.classList.add('armed');
btn.textContent = 'Confirm?';
armedTimer = setTimeout(disarm, 4000);
}
function removeRtItem(btn) {
const item = btn.closest('.rt-item');
if (!item) return;
item.classList.add('removing');
setTimeout(() => {
const list = item.parentElement;
item.remove();
if (list) {
let n = 0;
list.querySelectorAll('.rt-item').forEach(el => {
el.querySelector('.rt-rank').textContent = ++n;
});
}
}, 320);
}
async function doAdd(btn) {
const title = btn.dataset.title;
const target = btn.dataset.target; // 'sonarr' | 'radarr'
btn.disabled = true;
btn.textContent = 'Adding…';
try {
const resp = await fetch(URL_BASE + '/add-to-' + target, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ title })
});
const data = await resp.json();
if ((resp.ok && data.success) || resp.status === 409) {
removeRtItem(btn);
} else {
btn.disabled = false;
btn.textContent = '+ Add';
alert('Failed to add "' + title + '": ' + (data.error || 'Unknown error'));
}
} catch (e) {
btn.disabled = false;
btn.textContent = '+ Add';
alert('Network error: ' + e.message);
}
}
// A tap anywhere else cancels a pending confirm.
document.addEventListener('click', e => {
if (armedBtn && !e.target.closest('.btn-add')) disarm();
});
/* ── SABnzbd live polling (never cached server-side) ── */
const sabSpeed = document.getElementById('sab-speed');
const sabQueue = document.getElementById('sab-queue');
const sabRemaining = document.getElementById('sab-remaining');
const sabBadge = document.getElementById('sab-badge');
const sabTimeleft = document.getElementById('sab-timeleft');
const sabTimeleftRow = document.getElementById('sab-timeleft-row');
async function pollSabnzbd() {
try {
const resp = await fetch(URL_BASE + '/api/sabnzbd', { cache: 'no-store', credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
if (d.status !== 'ok') return;
if (sabSpeed) sabSpeed.textContent = d.speed;
if (sabQueue) sabQueue.textContent = d.queue_count;
if (sabRemaining) sabRemaining.textContent = d.sizeleft;
if (sabBadge) {
const s = (d.sab_status || '').toLowerCase();
sabBadge.className = 'badge ' + (s === 'downloading' ? 'downloading' : s === 'paused' ? 'paused' : 'idle');
sabBadge.textContent = d.sab_status;
}
const active = (d.sab_status || '').toLowerCase() === 'downloading'
&& d.timeleft && d.timeleft !== '0:00:00';
if (sabTimeleftRow) sabTimeleftRow.style.display = active ? 'inline' : 'none';
if (sabTimeleft) sabTimeleft.textContent = d.timeleft;
const pill = document.getElementById('svc-stat-sabnzbd');
if (pill) pill.textContent = d.speed;
} catch (e) { /* transient network error — try again next tick */ }
}
pollSabnzbd();
setInterval(pollSabnzbd, 3000);
/* ── RT descriptions arrive asynchronously from IMDB ── */
async function loadRtDescriptions() {
try {
const resp = await fetch(URL_BASE + '/api/rt/descriptions', { credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
for (const [title, desc] of Object.entries(d.descriptions || {})) {
const item = document.querySelector(`.rt-item[data-rt-title="${CSS.escape(title)}"]`);
if (!item || !desc) continue;
const el = item.querySelector('.rt-desc');
if (!el || el.textContent.trim()) continue; // already filled
el.textContent = desc.length > 180 ? desc.slice(0, 180) + '…' : desc;
}
if (d.pending) setTimeout(loadRtDescriptions, 4000);
} catch (e) { /* silently skip */ }
}
setTimeout(loadRtDescriptions, 1500);
/* ── Prowlarr indexer polling ── */
function updateServicePill(name, isUp) {
const pill = document.getElementById('svc-' + name);
const light = document.getElementById('svc-light-' + name);
if (pill) { pill.classList.toggle('up', isUp); pill.classList.toggle('down', !isUp); }
if (light) { light.classList.toggle('up', isUp); light.classList.toggle('down', !isUp); }
}
async function pollProwlarr() {
try {
const resp = await fetch(URL_BASE + '/api/prowlarr', { credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
if (d.status !== 'ok') return;
const upEl = document.getElementById('prowlarr-up-count');
const totEl = document.getElementById('prowlarr-total');
if (upEl) upEl.textContent = d.up_count;
if (totEl) totEl.textContent = d.total;
d.indexers.forEach(idx => {
const row = document.getElementById('idx-' + idx.id);
const light = document.getElementById('idx-light-' + idx.id);
if (row) { row.classList.toggle('up', idx.up); row.classList.toggle('down', !idx.up); }
if (light) { light.classList.toggle('up', idx.up); light.classList.toggle('down', !idx.up); }
const badge = document.getElementById('idx-badge-' + idx.id);
if (badge) {
badge.classList.toggle('online', idx.up);
badge.classList.toggle('offline', !idx.up);
badge.textContent = idx.up ? 'Online' : 'Offline';
}
});
updateServicePill('prowlarr', d.up_count === d.total && d.total > 0);
const stat = document.getElementById('svc-stat-prowlarr');
if (stat) stat.textContent = d.up_count + '/' + d.total + ' up';
} catch (e) { /* silently skip */ }
}
pollProwlarr();
setInterval(pollProwlarr, 30000);
/* ── Header service lights ── */
async function pollServices() {
try {
const resp = await fetch(URL_BASE + '/api/services', { credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
for (const [name, isUp] of Object.entries(d)) updateServicePill(name, isUp);
} catch (e) { /* silently skip */ }
}
pollServices();
setInterval(pollServices, 60000);
/* ── Tautulli live streams ── */
async function pollTautulli() {
try {
const resp = await fetch(URL_BASE + '/api/tautulli', { credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
if (d.status !== 'ok') return;
const count = d.stream_count;
const sessions = d.sessions || [];
const stat = document.getElementById('svc-stat-tautulli');
if (stat) stat.textContent = count > 0 ? count + ' streaming' : '0 streams';
const countEl = document.getElementById('tautulli-count');
if (countEl) countEl.textContent = count + ' active';
const body = document.getElementById('now-playing');
if (!body) return;
if (sessions.length) {
body.innerHTML = sessions.map(s => {
const icon = s.media_type === 'movie' ? '🎬' : '📺';
const esc = t => String(t).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
return `<div class="stream">
<span class="live-dot"></span>
<span class="stream-icon" aria-hidden="true">${icon}</span>
<span class="stream-info">
<span class="stream-title">${esc(s.title)}</span>
<span class="stream-user">${esc(s.user)}</span>
</span>
</div>`;
}).join('');
} else {
body.innerHTML = '<p class="empty">Nothing streaming right now.</p>';
}
} catch (e) { /* silently skip */ }
}
pollTautulli();
setInterval(pollTautulli, 15000);
/* ── Clear search results when the field is emptied ── */
document.getElementById('sonarr-search-input').addEventListener('input', function () {
if (!this.value.trim()) {
document.getElementById('sonarr-search-result').classList.remove('visible');
document.getElementById('sonarr-search-msg').style.display = 'none';
sonarrSearchResult = null;
}
});
document.getElementById('radarr-search-input').addEventListener('input', function () {
if (!this.value.trim()) {
document.getElementById('radarr-search-result').classList.remove('visible');
document.getElementById('radarr-search-msg').style.display = 'none';
radarrSearchResult = null;
}
});
/* ── Sonarr search ── */
async function searchSonarr() {
const input = document.getElementById('sonarr-search-input');
const btn = document.getElementById('sonarr-search-btn');
const resultEl = document.getElementById('sonarr-search-result');
const msgEl = document.getElementById('sonarr-search-msg');
const title = input.value.trim();
if (!title) return;
btn.disabled = true; btn.textContent = 'Searching…';
resultEl.classList.remove('visible');
msgEl.style.display = 'none';
sonarrSearchResult = null;
try {
const resp = await fetch(URL_BASE + '/api/sonarr/lookup?q=' + encodeURIComponent(title), { credentials: 'include' });
if (resp.redirected || !resp.headers.get('content-type')?.includes('application/json')) {
msgEl.textContent = 'Session expired — please refresh the page.';
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
btn.disabled = false; btn.textContent = 'Search'; return;
}
const d = await resp.json();
if (!resp.ok || d.error) {
msgEl.textContent = d.error || 'Search failed.';
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
} else if (!d.title) {
msgEl.textContent = 'No results found.';
msgEl.className = 'search-msg'; msgEl.style.display = 'block';
} else {
sonarrSearchResult = d;
document.getElementById('sonarr-result-title').textContent = d.title;
document.getElementById('sonarr-result-meta').textContent =
(d.year || '') + (d.network ? ' · ' + d.network : '') + (d.status ? ' · ' + d.status : '');
const addBtn = document.getElementById('sonarr-add-btn');
if (d.exists) {
addBtn.textContent = '✓ In library'; addBtn.disabled = true; addBtn.className = 'btn-add done';
} else {
addBtn.textContent = '+ Add'; addBtn.disabled = false; addBtn.className = 'btn-add';
}
resultEl.classList.add('visible');
}
} catch (e) {
msgEl.textContent = 'Search failed: ' + e.message;
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
}
btn.disabled = false; btn.textContent = 'Search';
}
async function confirmAddSonarr() {
if (!sonarrSearchResult) return;
const addBtn = document.getElementById('sonarr-add-btn');
addBtn.disabled = true; addBtn.textContent = 'Adding…';
try {
const resp = await fetch(URL_BASE + '/add-to-sonarr', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
credentials: 'include', body: JSON.stringify({ title: sonarrSearchResult.title })
});
const d = await resp.json();
if ((resp.ok && d.success) || resp.status === 409) {
addBtn.textContent = '✓ Added'; addBtn.className = 'btn-add done';
} else {
addBtn.disabled = false; addBtn.textContent = '+ Add';
alert('Failed: ' + (d.error || 'Unknown error'));
}
} catch (e) { addBtn.disabled = false; addBtn.textContent = '+ Add'; }
}
/* ── Radarr search ── */
async function searchRadarr() {
const input = document.getElementById('radarr-search-input');
const btn = document.getElementById('radarr-search-btn');
const resultEl = document.getElementById('radarr-search-result');
const msgEl = document.getElementById('radarr-search-msg');
const title = input.value.trim();
if (!title) return;
btn.disabled = true; btn.textContent = 'Searching…';
resultEl.classList.remove('visible');
msgEl.style.display = 'none';
radarrSearchResult = null;
try {
const resp = await fetch(URL_BASE + '/api/radarr/lookup?q=' + encodeURIComponent(title), { credentials: 'include' });
if (resp.redirected || !resp.headers.get('content-type')?.includes('application/json')) {
msgEl.textContent = 'Session expired — please refresh the page.';
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
btn.disabled = false; btn.textContent = 'Search'; return;
}
const d = await resp.json();
if (!resp.ok || d.error) {
msgEl.textContent = d.error || 'Search failed.';
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
} else if (!d.title) {
msgEl.textContent = 'No results found.';
msgEl.className = 'search-msg'; msgEl.style.display = 'block';
} else {
radarrSearchResult = d;
document.getElementById('radarr-result-title').textContent = d.title;
document.getElementById('radarr-result-meta').textContent =
(d.year || '') + (d.studio ? ' · ' + d.studio : '') + (d.runtime ? ' · ' + d.runtime + ' min' : '');
const addBtn = document.getElementById('radarr-add-btn');
if (d.exists) {
addBtn.textContent = '✓ In library'; addBtn.disabled = true; addBtn.className = 'btn-add done';
} else {
addBtn.textContent = '+ Add'; addBtn.disabled = false; addBtn.className = 'btn-add';
}
resultEl.classList.add('visible');
}
} catch (e) {
msgEl.textContent = 'Search failed: ' + e.message;
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
}
btn.disabled = false; btn.textContent = 'Search';
}
async function confirmAddRadarr() {
if (!radarrSearchResult) return;
const addBtn = document.getElementById('radarr-add-btn');
addBtn.disabled = true; addBtn.textContent = 'Adding…';
try {
const resp = await fetch(URL_BASE + '/add-to-radarr', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
credentials: 'include', body: JSON.stringify({ title: radarrSearchResult.title })
});
const d = await resp.json();
if ((resp.ok && d.success) || resp.status === 409) {
addBtn.textContent = '✓ Added'; addBtn.className = 'btn-add done';
} else {
addBtn.disabled = false; addBtn.textContent = '+ Add';
alert('Failed: ' + (d.error || 'Unknown error'));
}
} catch (e) { addBtn.disabled = false; addBtn.textContent = '+ Add'; }
}

201
static/js/service.js Normal file
View file

@ -0,0 +1,201 @@
/* ═══════════════════════════════════════════════════════
Page 100 — service behaviour.
Three things: the header clock, the page-number jump (the
signature interaction), and the fastext colour keys. Plus
guarded live polling for the two figures that must be
current-second accurate.
Every element lookup is guarded. Nine pages share this file
and none of them carry all of its targets.
═══════════════════════════════════════════════════════ */
(function () {
'use strict';
var body = document.body;
var BASE = body.dataset.urlBase || '';
var TZ = body.dataset.timezone || 'UTC';
/* ── Clock ────────────────────────────────────────────
Teletext's header carried the transmission clock to the
second. It is the one thing on the page that is always
moving, and it is how you know the page is live. */
var clockEl = document.getElementById('head-clock');
function tick() {
if (!clockEl) return;
var now = new Date();
var d = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ, weekday: 'short', day: '2-digit', month: 'short'
}).format(now);
var t = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ, hour: '2-digit', minute: '2-digit',
second: '2-digit', hour12: false
}).format(now);
clockEl.textContent = d.toUpperCase() + ' ' + t;
}
tick();
setInterval(tick, 1000);
/* ── Page-number jump ─────────────────────────────────
Type three digits anywhere and the service goes to that
page, exactly as a teletext remote did. The digits fill in
the header as you type them, so the interaction shows its
own state instead of happening invisibly. */
var pages = {};
try {
(JSON.parse(body.dataset.pages || '[]')).forEach(function (p) {
pages[p[1]] = p[3]; // "100" -> "/"
});
} catch (e) { /* no nav data: the jump simply does nothing */ }
var entryEl = document.getElementById('page-entry');
var buf = '';
var clearTimer = null;
function paintEntry() {
if (!entryEl) return;
if (!buf) { entryEl.hidden = true; entryEl.textContent = ''; return; }
entryEl.hidden = false;
entryEl.textContent = 'P' + buf + '_'.repeat(3 - buf.length);
}
function resetEntry() {
buf = '';
paintEntry();
}
function typing(e) {
var t = e.target;
return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
t.tagName === 'SELECT' || t.isContentEditable);
}
document.addEventListener('keydown', function (e) {
if (typing(e) || e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === 'Escape') { resetEntry(); return; }
if (e.key >= '0' && e.key <= '9') {
buf += e.key;
paintEntry();
clearTimeout(clearTimer);
if (buf.length === 3) {
var dest = pages[buf];
if (dest) { window.location.href = BASE + dest; }
else {
// No such page. Teletext just sat on the number and waited.
if (entryEl) entryEl.textContent = 'P' + buf + ' ?';
clearTimer = setTimeout(resetEntry, 1200);
}
buf = '';
return;
}
// A half-typed number times out rather than lingering.
clearTimer = setTimeout(resetEntry, 2000);
return;
}
/* Fastext keys: r / g / y / c, the coloured buttons. */
var k = e.key.toLowerCase();
if ('rgyc'.indexOf(k) !== -1) {
var link = document.querySelector('.fastext a[data-key="' + k + '"]');
if (link) { window.location.href = link.href; }
}
});
/* ── Two-tap add ──────────────────────────────────────
Adding to the library is destructive enough to want a
confirming second press. The button states its own stage
rather than opening a dialog. */
var armed = null;
function disarm() {
if (!armed) return;
armed.classList.remove('armed');
armed.textContent = armed.dataset.label || '+ ADD';
armed = null;
}
window.armAdd = function (btn) {
if (btn.disabled) return;
if (armed && armed !== btn) disarm();
if (armed === btn) {
var title = btn.dataset.title;
var target = btn.dataset.target;
btn.disabled = true;
btn.classList.remove('armed');
btn.textContent = 'SENDING';
fetch(BASE + '/add-to-' + target, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title })
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.ok) {
btn.classList.add('done');
btn.textContent = 'ADDED';
} else {
btn.classList.add('armed');
btn.disabled = false;
btn.textContent = 'FAILED';
}
})
.catch(function () {
btn.disabled = false;
btn.classList.add('armed');
btn.textContent = 'FAILED';
});
armed = null;
return;
}
btn.dataset.label = btn.textContent.trim();
btn.classList.add('armed');
btn.textContent = 'CONFIRM?';
armed = btn;
};
document.addEventListener('click', function (e) {
if (armed && !armed.contains(e.target)) disarm();
});
/* ── Live figures ─────────────────────────────────────
The queue and the active streams are the only two numbers
that must be accurate to the second, so they are the only
two that poll. Everything else is cached server-side and
arrives with the page. */
function setText(id, value) {
var el = document.getElementById(id);
if (el) el.textContent = value;
}
function pollSab() {
if (!document.getElementById('sab-queue')) return;
fetch(BASE + '/api/sabnzbd')
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d || d.status !== 'ok') return;
setText('sab-queue', d.queue_count);
setText('sab-speed', d.speed || '0');
setText('sab-remaining', d.sizeleft || '—');
})
.catch(function () { /* offline is the dev baseline, not an error */ });
}
function pollTautulli() {
if (!document.getElementById('tt-streams')) return;
fetch(BASE + '/api/tautulli')
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d || d.status !== 'ok') return;
setText('tt-streams', d.active_streams);
})
.catch(function () { /* as above */ });
}
if (document.getElementById('sab-queue')) {
pollSab();
setInterval(pollSab, 5000);
}
if (document.getElementById('tt-streams')) {
pollTautulli();
setInterval(pollTautulli, 15000);
}
})();