Files
serverAppConfigs/shared/homepage/app.js
T
Frank bad78079d7 feat(homepage): the not-open-yet apps get their own section, below AI
Calibre-Web, RomM and Seerr sat inside "Media Library Apps" carrying a WIP
badge and a "not open to family users yet" sentence each, so the household
grid mixed doors they can use with doors they cannot. They now render under
their own "Work in progress" heading, placed after AI on the user page, and
the copy drops the restated "Work in progress:" for the ask-Frank line the
section heading no longer needs to repeat.

- app.js: SECTION_ORDER.user gains "Work in progress" last.
- services.json: the three cards' section, plus one-line copy trim.
- homepage_config.bats / homepage_pages.bats: the section contract now
  includes the new heading and pins the new order (the old test pinned AI
  last, which this change deliberately overturns).
- Homepage.md: the classification list and the SECTION_ORDER/wip contract
  lines catch up (the user order in the page was already stale, and "down"
  was an undocumented wip value).

The admin page is untouched: its WIP cards (Gluetun, mariadb, valkey,
Postgres) are plumbing inside Infrastructure, not applications.
2026-11-04 13:09:16 -08:00

250 lines
10 KiB
JavaScript

// The homepage front end. It reads status.json and services.json once on load
// (no auto-refresh, Q41), renders the services for this page's category as
// sectioned grids of TUI panels, offers the styling toggle (Q27), and shows
// the last master deployment datetime (Q43). It never alters the user's
// cursor (Q28).
const CATEGORY = window.location.pathname.indexOf('/admin') !== -1 ? 'admin' : 'user';
// Sections and panels are shared components: both pages render through the
// same renderSection()/renderPanel() below. SECTION_ORDER fixes each page's
// section order; a section with no services renders nothing.
const SECTION_ORDER = {
user: ['Web Apps', 'Media Library Apps', 'AI', 'Work in progress'],
admin: ['Media Library Apps', 'Observability', 'AI', 'Infrastructure'],
};
const BADGE_LABEL = {
up: 'UP',
down: 'DOWN',
'wip-open': 'WIP: OPEN',
'wip-closed': 'WIP: CLOSED',
'wip-down': 'WIP: DOWN',
};
function load(url) {
return fetch(url, { cache: 'no-store' }).then(function (r) { return r.json(); });
}
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
// The plain description for ordinary panels. (Model cards render through
// renderModelInfo below, not through descHTML.)
function descHTML(svc) {
const d = svc.details;
if (d && (d.role || d.spec || d.inputs || d.use)) {
const lines = [];
if (d.role) lines.push('<div class="desc-role">' + esc(d.role) + '</div>');
if (d.spec) lines.push('<div class="desc-spec">' + esc(d.spec) + '</div>');
if (d.inputs && d.inputs.length) lines.push('<div class="desc-inputs">Inputs: ' + esc(d.inputs.join(' · ')) + '</div>');
if (d.use) lines.push('<div class="desc-use">Use: ' + esc(d.use) + '</div>');
return lines.join('');
}
return esc(svc.description || '');
}
// The Model Information component: one panel fusing every fleet model card.
// Each model is an informational sub-card (smaller type, Protoss unit art,
// unit-coloured flair); the quoted lines are buttons that play the matching
// StarCraft unit voice from /sfx/. Audio files are sequential: a click plays
// the whole queue (the Dragoon tagline is two lines spliced).
let sfxQueue = [];
let sfxPlaying = false;
function playSfx(files, el) {
sfxQueue = sfxQueue.concat(files);
if (sfxPlaying) return;
sfxPlaying = true;
el.classList.add('mi-speaking');
const next = function () {
const f = sfxQueue.shift();
if (!f) { sfxPlaying = false; el.classList.remove('mi-speaking'); return; }
const a = new Audio('/sfx/' + f);
a.volume = 0.5;
a.onended = next;
a.onerror = next;
a.play().then(function () {}, next);
};
next();
}
function voiceBtn(text, files) {
return '<button type="button" class="mi-voice" title="play voice" ' +
'data-sfx="' + esc(files.join(',')) + '">&#9654; ' + esc(text) + '</button>';
}
function miCard(svc, state) {
const m = svc.model;
const parts = ['<div class="mi-top">' +
'<img class="mi-art" src="/img/unit-' + esc(m.unit) + '.jpg" alt="' + esc(m.unit) + '">' +
'<div class="mi-head"><div class="mi-id"><span class="badge ' + state + '">' + BADGE_LABEL[state] + '</span>' +
'<span class="mi-name">' + esc(svc.name) + '</span></div>' +
'<div class="mi-role">' + esc(m.role) + '</div>' +
(m.hf ? '<a class="mi-hf" href="' + esc(m.hf) +
'" target="_blank" rel="noopener">model on huggingface &#8599;</a>' : '') +
'</div></div>',
voiceBtn(m.tagline, m.sfx)
];
if (m.equiv) parts.push('<div class="mi-equiv">' + esc(m.equiv) + '</div>');
if (m.spec) parts.push('<div class="mi-spec">' + esc(m.spec) + '</div>');
if (m.inputs && m.inputs.length) parts.push('<div class="mi-inputs">Inputs: ' + esc(m.inputs.join(' · ')) + '</div>');
if (m.use) parts.push('<div class="mi-use">Use: ' + esc(m.use) + '</div>');
if (m.compaction) parts.push('<div class="mi-comp">&#9889; ' + esc(m.compaction) + '</div>');
if (m.supervision) parts.push('<div class="mi-super">' + esc(m.supervision) + '</div>');
if (m.permission) parts.push('<div class="mi-perm">&#9888; ' + esc(m.permission) + '</div>');
if (m.warn) parts.push('<details class="mi-warn"><summary>&#9888; privacy note</summary>' +
'<div class="warn-body">' + esc(m.warn) + '</div></details>');
const card = document.createElement('article');
card.className = 'mi-card mi-' + esc(m.unit);
card.innerHTML = parts.join('');
return card;
}
function renderModelInfo(models) {
const panel = document.createElement('div');
panel.className = 'panel model-info';
const head = document.createElement('div');
head.className = 'mi-title';
const title = document.createElement('span');
title.textContent = 'Model Information';
head.appendChild(title);
const docsLink = document.createElement('a');
docsLink.className = 'mi-docs-link';
docsLink.href = '/docs#models';
docsLink.textContent = 'AI guide \u2192';
head.appendChild(docsLink);
panel.appendChild(head);
const grid = document.createElement('div');
grid.className = 'mi-grid';
for (const m of models) grid.appendChild(m.el);
panel.appendChild(grid);
panel.addEventListener('click', function (ev) {
const btn = ev.target.closest('.mi-voice');
if (btn) playSfx(btn.getAttribute('data-sfx').split(','), btn);
});
return panel;
}
// Shared panel component: one TUI panel for a service in a given state.
function renderPanel(svc, state) {
const panel = document.createElement('div');
panel.className = 'panel ' + state;
const links = [];
if (svc.showLink && svc.url) links.push('<a class="open" href="' + esc(svc.url) + '">open</a>');
if (svc.docs) links.push('<a class="docs" href="' + esc(svc.docs) + '">docs</a>');
const art = svc.art ? '<img class="app-art" src="' + esc(svc.art) + '" alt=""' +
(svc.art_bg ? ' style="background:' + esc(svc.art_bg) + ';border-color:' + esc(svc.art_bg) + '"' : '') + '>' : '';
const warn = svc.warn
? '<details class="svc-warn"><summary>&#9888; privacy note</summary>' +
'<div class="warn-body">' + esc(svc.warn) + '</div></details>'
: '';
panel.innerHTML =
'<div class="panel-head">' + art +
' <span class="badge ' + state + '">' + BADGE_LABEL[state] + '</span>' +
' <span class="name">' + esc(svc.name) + '</span>' +
'</div>' +
'<div class="desc">' + descHTML(svc) + '</div>' +
'<div class="links">' + links.join('') + '</div>' + warn;
if (svc.showLink && svc.url) {
panel.classList.add('clickable');
panel.addEventListener('click', function (ev) {
if (ev.target.closest('a, .svc-warn')) return;
});
}
return panel;
}
// Shared section component: a heading over a grid of panels.
function renderSection(title, panels) {
const section = document.createElement('section');
section.className = 'section';
const heading = document.createElement('h2');
heading.className = 'section-title';
heading.textContent = title;
const grid = document.createElement('div');
grid.className = 'grid';
for (const panel of panels) grid.appendChild(panel);
section.appendChild(heading);
section.appendChild(grid);
return section;
}
function renderPanels(services, status) {
const stateByName = {};
for (const s of (status.services || [])) stateByName[s.name] = s.state;
const root = document.getElementById('grid');
root.innerHTML = '';
let up = 0, down = 0, wip = 0;
const bySection = {};
const modelsBySection = {};
for (const svc of services) {
if (svc.category !== CATEGORY) continue;
const state = svc.wip ? 'wip-' + svc.wip : (stateByName[svc.name] || 'down');
if (state === 'up') up++; else if (state === 'down') down++; else wip++;
if (svc.model) {
(modelsBySection[svc.section] = modelsBySection[svc.section] || []).push({ svc: svc, el: miCard(svc, state) });
continue;
}
(bySection[svc.section] = bySection[svc.section] || []).push(renderPanel(svc, state));
}
for (const title of SECTION_ORDER[CATEGORY]) {
const panels = bySection[title] || [];
if (modelsBySection[title]) panels.push(renderModelInfo(modelsBySection[title]));
if (panels.length) root.appendChild(renderSection(title, panels));
}
document.getElementById('summary').textContent = 'UP ' + up + ' DOWN ' + down + ' WIP ' + wip;
}
// The styling toggle (Q27): ?bare=1 strips the page to bare, unstyled HTML.
function applyBare() {
const bare = new URLSearchParams(window.location.search).get('bare') === '1';
document.documentElement.setAttribute('data-bare', bare ? '1' : '0');
const t = document.getElementById('bare-toggle');
if (t) {
t.textContent = bare ? 'styled' : 'bare';
t.href = bare ? '?bare=0' : '?bare=1';
}
}
// The last master deployment datetime (Q43), baked in at deploy time.
function fillDeployed() {
const el = document.getElementById('deployed-at');
if (el && window.DEPLOYED_AT) el.textContent = window.DEPLOYED_AT;
}
// Show the admin button only to members of the authentik homepage-admins
// group (the same group the /admin* server gate enforces). The data comes
// from Caddy's /whoami, which echoes the outpost-injected identity headers.
function gateAdminLink() {
const link = document.getElementById('admin-link');
if (!link) return;
fetch('/whoami', { headers: { accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (me) {
if (me && /homepage-admins/i.test(me.groups || '')) link.hidden = false;
})
.catch(function () {});
}
function main() {
applyBare();
fillDeployed();
gateAdminLink();
Promise.all([load('services.json'), load('status.json')])
.then(function (pair) { renderPanels(pair[0], pair[1]); })
.catch(function (e) {
document.getElementById('summary').textContent = 'status unavailable: ' + e;
});
}
main();