Files
Noxely/server/noxely_server/web/index.html
T
tuxgyverandClaude Sonnet 5 7810bef8a3 Le serveur garde la trace de ce qu'il entend, et repère la rotation (2.4.0)
Le balayage ne servait qu'à trouver un morceau guetté, puis oubliait tout. Il
note désormais chaque titre annoncé par les stations, même sans guet en cours :
un onglet Écoutes montre l'historique, et la rotation — passages et stations
distinctes, par titre et par artiste — sur les 100 dernières remontées.

Pour que l'historique soit fiable, un tour va maintenant jusqu'au bout après une
trouvaille (l'enregistrement part aussitôt, le début du morceau n'est pas
perdu), et la liste des stations est mise en cache une demi-heure au lieu
d'interroger l'annuaire à chaque tour.

Essayé sur de vraies radios : une station alternait le vrai titre et un titre de
repli, ce qui doublait les passages. Un titre déjà relevé sur la même station
dans les dix minutes est donc ignoré.

Première moitié de la reconnaissance des morceaux ; l'écoute continue et
l'empreinte sonore suivront.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 17:59:32 +02:00

503 lines
24 KiB
HTML
Raw 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>
<!--
Noxely — collecte côté serveur, écoute hors connexion côté mobile.
Copyright © 2026 Johnny (tuxgyver)
Ce programme est un logiciel libre : vous pouvez le redistribuer et le modifier
selon les termes de la GNU General Public License, version 3 ou ultérieure,
telle que publiée par la Free Software Foundation.
Il est distribué dans l'espoir d'être utile, mais SANS AUCUNE GARANTIE.
Voir le fichier LICENSE à la racine du dépôt pour le texte complet.
-->
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Noxely — serveur</title>
<style>
:root { --bg:#faf7ff; --card:#fff; --ink:#1c1b22; --muted:#5c5670; --accent:#6b4eff;
--mint:#11a894; --line:#e6e0f0; }
@media (prefers-color-scheme: dark) {
:root { --bg:#120d1c; --card:#1b1033; --ink:#e8e0f0; --muted:#a79fbb; --line:#2f2743; }
}
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.5 system-ui,sans-serif; }
header { padding:18px 20px; border-bottom:1px solid var(--line); display:flex;
align-items:center; gap:12px; flex-wrap:wrap; }
h1 { font-size:19px; margin:0; }
main { max-width:920px; margin:0 auto; padding:20px; }
.tabs { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:18px; }
.tabs button { background:none; border:1px solid var(--line); border-radius:999px;
padding:7px 15px; color:var(--muted); cursor:pointer; font-size:14px; }
.tabs button[aria-selected="true"] { background:var(--accent); border-color:var(--accent); color:#fff; }
.card { background:var(--card); border:1px solid var(--line); border-radius:14px;
padding:12px 14px; margin-bottom:8px; display:flex; gap:12px; align-items:center; }
.card .grow { flex:1; min-width:0; }
.card b { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.card small { color:var(--muted); }
input, select { padding:9px 12px; border:1px solid var(--line); border-radius:10px;
background:var(--card); color:var(--ink); font-size:14px; }
input[type=text] { flex:1; min-width:160px; }
button.go { background:var(--accent); color:#fff; border:none; border-radius:10px;
padding:9px 16px; cursor:pointer; font-size:14px; }
button.ghost { background:none; border:1px solid var(--line); border-radius:10px;
padding:7px 12px; cursor:pointer; color:var(--muted); }
.row { display:flex; gap:8px; margin-bottom:14px; flex-wrap:wrap; }
.pill { font-size:12px; padding:3px 9px; border-radius:999px; background:var(--line); color:var(--muted); }
.pill.ok { background:rgba(17,168,148,.16); color:var(--mint); }
.pill.warn { background:rgba(224,98,155,.16); color:#e0629b; }
.empty { color:var(--muted); text-align:center; padding:40px 10px; }
/* Page « À propos » : en-tête d'identité figé, corps en sous-onglets. */
.about-head { display:flex; gap:16px; align-items:center; margin-bottom:16px; }
.about-logo { width:80px; height:80px; border-radius:22px; background:var(--accent);
display:grid; place-items:center; font-size:40px; flex:none; }
.about-head h2 { margin:0 0 2px; font-size:20px; }
.about-head p { margin:0 0 8px; color:var(--muted); font-size:14px; }
.pill.accent { background:var(--accent); color:#fff; }
.subtabs { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:14px; }
.subtabs button { background:none; border:1px solid var(--line); border-radius:999px;
padding:5px 12px; color:var(--muted); cursor:pointer; font-size:13px; }
.subtabs button[aria-selected="true"] { border-color:var(--accent); color:var(--accent); }
.about-body { max-height:none; }
.about-body .card { display:block; }
.about-body ul { margin:0; padding-left:20px; }
.about-body li { margin:5px 0; }
.tech { width:100%; border-collapse:collapse; }
.tech td { padding:9px 4px; border-bottom:1px solid var(--line); vertical-align:top; }
.tech td:first-child { color:var(--muted); width:38%; }
h3.section { font-size:15px; margin:20px 0 10px; }
.log h4 { margin:0 0 4px; font-size:15px; }
</style>
</head>
<body>
<header>
<h1>🎧 Noxely — serveur</h1>
<span id="status" class="pill">…</span>
<span class="grow" style="flex:1"></span>
<button class="go" onclick="openPairing()">📱 Appairer</button>
</header>
<main>
<div class="tabs">
<button onclick="show('search')" aria-selected="true" id="t-search">🔎 Rechercher</button>
<button onclick="show('watch')" aria-selected="false" id="t-watch">📡 Guets</button>
<button onclick="show('heard')" aria-selected="false" id="t-heard">📈 Écoutes</button>
<button onclick="show('queue')" aria-selected="false" id="t-queue">⏳ File</button>
<button onclick="show('library')" aria-selected="false" id="t-library">📚 Bibliothèque</button>
<button onclick="show('devices')" aria-selected="false" id="t-devices">📱 Appareils</button>
<button onclick="show('settings')" aria-selected="false" id="t-settings">⚙️ Réglages</button>
<button onclick="show('about')" aria-selected="false" id="t-about">ℹ️ À propos</button>
</div>
<section id="p-search">
<div class="row">
<select id="catalog"></select>
<input type="text" id="q" placeholder="Titre, artiste, album…" onkeydown="if(event.key==='Enter')doSearch()">
<button class="go" onclick="doSearch()">Chercher</button>
</div>
<div id="results"></div>
</section>
<section id="p-watch" hidden>
<div class="row">
<input type="text" id="watchQuery" placeholder="Morceau à guetter sur toutes les stations"
onkeydown="if(event.key==='Enter')addWatch()">
<button class="go" onclick="addWatch()">Guetter</button>
<button class="ghost" onclick="sweep()">Balayer maintenant</button>
</div>
<div id="watches"></div>
</section>
<section id="p-heard" hidden>
<div class="row">
<select id="rotationWindow" onchange="loadHeard()">
<option value="50">50 dernières remontées</option>
<option value="100" selected>100 dernières remontées</option>
<option value="200">200 dernières remontées</option>
<option value="500">500 dernières remontées</option>
</select>
<input type="text" id="heardQuery" placeholder="Chercher dans l'historique"
onkeydown="if(event.key==='Enter')loadHeard()">
<button class="ghost" onclick="loadHeard()">Actualiser</button>
</div>
<h3 class="section">🔥 En rotation</h3>
<div id="rotation"></div>
<h3 class="section">🕘 Historique</h3>
<div id="heard"></div>
</section>
<section id="p-queue" hidden><div id="queue"></div></section>
<section id="p-library" hidden><div id="library"></div></section>
<section id="p-settings" hidden>
<div class="card" style="display:block">
<b>Jeton d'accès</b>
<small>Rempli tout seul quand cette page est ouverte depuis la machine du serveur.
Depuis un autre poste, collez celui que donne <code>make server-token</code>.</small>
<div class="row" style="margin-top:12px">
<input type="password" id="token" placeholder="Jeton" style="flex:1">
<button class="go" onclick="saveToken()">Enregistrer</button>
</div>
</div>
<div id="serverInfo"></div>
</section>
<section id="p-about" hidden>
<div class="about-head">
<div class="about-logo">🎧</div>
<div>
<h2 id="aboutName">Noxely</h2>
<p id="aboutTagline"></p>
<div class="row" style="margin:0" id="aboutBadges"></div>
</div>
</div>
<div class="subtabs" id="aboutTabs"></div>
<div class="about-body" id="aboutBody"></div>
</section>
<section id="p-devices" hidden>
<div class="row"><button class="go" onclick="openPairing()">Appairer un appareil</button></div>
<div id="devices"></div>
</section>
</main>
<script src="/about.js"></script>
<script>
const $ = (id) => document.getElementById(id);
let token = localStorage.getItem('noxely-token') || '';
$('token').value = token;
function saveToken() {
token = $('token').value.trim();
localStorage.setItem('noxely-token', token);
refreshAll();
}
async function api(path, options = {}) {
const response = await fetch('/api' + path, {
...options,
headers: { 'X-Noxely-Token': token, 'Content-Type': 'application/json', ...(options.headers || {}) },
});
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).detail || response.status);
return response.json();
}
function show(name) {
for (const tab of ['search', 'watch', 'heard', 'queue', 'library', 'devices', 'settings', 'about']) {
$('p-' + tab).hidden = tab !== name;
$('t-' + tab).setAttribute('aria-selected', String(tab === name));
}
if (name === 'queue') loadQueue();
if (name === 'library') loadLibrary();
if (name === 'watch') loadWatches();
if (name === 'heard') loadHeard();
if (name === 'devices') loadDevices();
if (name === 'settings') loadServerInfo();
if (name === 'about') loadAbout();
}
async function refreshAll() {
try {
const health = await fetch('/api/health').then((r) => r.json());
$('status').textContent = health.name + ' ' + health.version;
$('status').className = 'pill ok';
} catch { $('status').textContent = 'injoignable'; $('status').className = 'pill warn'; }
if (!token) {
$('status').textContent = 'jeton requis → Réglages';
$('status').className = 'pill warn';
}
try {
const { catalogs } = await api('/catalogs');
$('catalog').innerHTML = Object.entries(catalogs)
.map(([id, label]) => `<option value="${id}">${label}</option>`).join('');
} catch {}
}
async function doSearch() {
const q = $('q').value.trim(); if (!q) return;
$('results').innerHTML = '<div class="empty">Recherche…</div>';
try {
const { results } = await api(`/search?catalog=${$('catalog').value}&q=${encodeURIComponent(q)}`);
$('results').innerHTML = results.length ? results.map((r, i) => `
<div class="card">
<div class="grow"><b>${esc(r.title)}</b><small>${
r.catalog ? '<span class="pill">' + esc(r.catalog) + '</span> ' : ''}${
esc(r.artist)} · ${r.kind}${
r.size ? ' · ~' + (r.size / 1e6).toFixed(0) + ' Mo' : ''}</small></div>
<button class="ghost" onclick='openTracks(${i})'>Pistes</button>
</div>`).join('') : '<div class="empty">Aucun résultat</div>';
window._results = results;
} catch (e) { $('results').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
}
async function openTracks(index) {
const item = window._results[index];
$('results').innerHTML = '<div class="empty">Lecture des pistes…</div>';
try {
// En recherche « toutes sources », c'est le catalogue du résultat qui compte, pas celui du menu.
const catalog = item.catalog || $('catalog').value;
const { tracks } = await api(`/tracks?catalog=${catalog}&id=${
encodeURIComponent(item.id)}&title=${encodeURIComponent(item.title)}&kind=${item.kind}`);
window._catalog = catalog;
window._tracks = tracks;
const total = tracks.reduce((sum, t) => sum + (t.size || 0), 0);
$('results').innerHTML = `
<div class="row"><button class="ghost" onclick="doSearch()">← Résultats</button>
<button class="go" onclick="queueAll()">Tout mettre en file${
total ? ' (' + (total / 1e6).toFixed(0) + ' Mo)' : ''}</button></div>` +
tracks.map((t, i) => `
<div class="card">
<div class="grow"><b>${esc(t.title)}</b><small>${
t.size ? (t.size / 1e6).toFixed(1) + ' Mo' : 'taille inconnue'}</small></div>
<button class="ghost" onclick="queueOne(${i})">Mettre en file</button>
</div>`).join('');
} catch (e) { $('results').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
}
const queueTracks = async (tracks) => {
const { queued } = await api('/queue', { method: 'POST',
body: JSON.stringify({ tracks, catalog: window._catalog || $('catalog').value }) });
alert(queued + ' piste(s) en file de téléchargement');
};
const queueOne = (i) => queueTracks([window._tracks[i]]);
const queueAll = () => queueTracks(window._tracks);
async function loadQueue() {
try {
const { items } = await api('/queue');
$('queue').innerHTML = items.length ? items.map((m) => `
<div class="card"><div class="grow"><b>${esc(m.title)}</b>
<small>${esc(m.artist)} · ${m.state}${m.error ? ' — ' + esc(m.error) : ''}</small></div></div>`).join('')
: '<div class="empty">File vide</div>';
} catch (e) { $('queue').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
}
async function loadLibrary() {
try {
const { items } = await api('/library?since=0');
$('library').innerHTML = items.length ? items.map((m) => `
<div class="card"><div class="grow"><b>${esc(m.title)}</b>
<small>${esc(m.artist)} · ${(m.size / 1e6).toFixed(1)} Mo · ${esc(m.source)}</small></div>
<button class="ghost" onclick="removeMedia('${m.id}')">Supprimer</button></div>`).join('')
: '<div class="empty">Rien de prêt pour le mobile</div>';
} catch (e) { $('library').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
}
async function removeMedia(id) {
if (!confirm('Supprimer définitivement ce média du serveur ?')) return;
await api('/media/' + id, { method: 'DELETE' });
loadLibrary();
}
async function loadWatches() {
try {
const { watches, recording, sweeps, last_sweep: last } = await api('/watches');
const scan = last && last.stations
? `${sweeps} tour(s) · dernier : ${last.stations} stations, ${last.titles} titres lus, ` +
`${last.heard || 0} nouveau(x) dans l'historique`
: 'en attente d\'un guet';
$('watches').innerHTML =
`<div class="card"><div class="grow"><b>Balayage continu</b><small>${esc(scan)}</small></div>
<span class="pill ok">actif</span></div>` +
(recording ? `<div class="card"><div class="grow">
<b>Enregistrement en cours</b><small>${esc(recording)}</small></div></div>` : '') +
(watches.length ? watches.map((w) => `
<div class="card"><div class="grow"><b>${esc(w.query)}</b><small>${
w.found_at ? 'capté sur ' + esc(w.found_on || '') + ' — ' + esc(w.found_title || '')
: w.scans + ' balayage(s)'}</small></div>
<span class="pill ${w.found_at ? 'ok' : ''}">${w.found_at ? 'trouvé' : 'en attente'}</span>
<button class="ghost" onclick="removeWatch('${w.id}')">×</button></div>`).join('')
: '<div class="empty">Aucun guet</div>');
} catch (e) { $('watches').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
}
async function addWatch() {
const query = $('watchQuery').value.trim(); if (!query) return;
await api('/watches', { method: 'POST', body: JSON.stringify({ query }) });
$('watchQuery').value = '';
loadWatches();
}
const removeWatch = async (id) => { await api('/watches/' + id, { method: 'DELETE' }); loadWatches(); };
async function sweep() {
$('watches').innerHTML = '<div class="empty">Balayage des stations…</div>';
try {
const r = await api('/watches/sweep', { method: 'POST' });
alert(r.matched ? `Trouvé sur ${r.station} : ${r.matched}`
: `${r.stations} stations écoutées, ${r.titles} titres lus, rien de guetté`);
} catch (e) { alert(String(e)); }
loadWatches();
}
const clock = (ms) => new Date(ms).toLocaleString('fr-FR',
{ day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
async function loadHeard() {
const last = $('rotationWindow').value;
const query = $('heardQuery').value.trim();
try {
const [rot, list] = await Promise.all([
api('/rotation?last=' + last + '&top=15'),
api('/heard?limit=100' + (query ? '&q=' + encodeURIComponent(query) : '')),
]);
const label = (t) => t.artist ? `${esc(t.artist)} — ${esc(t.title)}` : esc(t.title);
const stations = (n) => n.length > 2 ? `${n.length} stations` : esc(n.join(', '));
$('rotation').innerHTML = rot.tracks.length
? `<div class="empty" style="padding:0 0 10px;text-align:left">${rot.window} remontée(s) analysée(s)</div>` +
rot.tracks.map((t) => `
<div class="card"><div class="grow"><b>${label(t)}</b>
<small>${stations(t.stations)} · dernier passage ${clock(t.last_heard)}</small></div>
<span class="pill ${t.plays > 1 ? 'ok' : ''}">×${t.plays}</span></div>`).join('') +
(rot.artists.length ? `<h3 class="section">🎤 Artistes</h3>` + rot.artists.map((a) => `
<div class="card"><div class="grow"><b>${esc(a.artist)}</b>
<small>${a.tracks} titre(s) · ${a.stations.length} station(s)</small></div>
<span class="pill ${a.plays > 1 ? 'ok' : ''}">×${a.plays}</span></div>`).join('') : '')
: `<div class="empty">${list.history
? 'Rien d\'entendu pour l\'instant — le premier tour de balayage peut prendre une minute.'
: 'L\'historique est désactivé (NOXELY_HISTORY=0).'}</div>`;
$('heard').innerHTML = list.items.length
? `<div class="empty" style="padding:0 0 10px;text-align:left">${list.total} ligne(s) au total</div>` +
list.items.map((h) => `
<div class="card"><div class="grow"><b>${h.artist ? esc(h.artist) + ' — ' : ''}${esc(h.title)}</b>
<small>${esc(h.station)} · ${clock(h.heard_at)}</small></div></div>`).join('')
: '<div class="empty">Aucun résultat</div>';
} catch (e) {
$('rotation').innerHTML = `<div class="empty">${esc(String(e))}</div>`;
$('heard').innerHTML = '';
}
}
async function loadServerInfo() {
try {
const health = await fetch('/api/health').then((r) => r.json());
const { devices } = await api('/devices');
$('serverInfo').innerHTML = `
<div class="card"><div class="grow"><b>${esc(health.name)} ${esc(health.version)}</b>
<small>${devices.length} appareil(s) appairé(s)</small></div>
<span class="pill ok">en service</span></div>`;
} catch (e) {
$('serverInfo').innerHTML = `<div class="card"><div class="grow"><b>Jeton requis</b>
<small>${esc(String(e))}</small></div><span class="pill warn">non authentifié</span></div>`;
}
}
async function loadDevices() {
try {
const { devices } = await api('/devices');
$('devices').innerHTML = devices.length ? devices.map((d) => `
<div class="card"><div class="grow"><b>${esc(d.name)}</b><small>appairé le ${
new Date(d.created_at).toLocaleString('fr-FR')}${
d.last_seen ? ' · vu le ' + new Date(d.last_seen).toLocaleString('fr-FR')
: ' · jamais synchronisé'}</small></div>
<button class="ghost" onclick="revokeDevice('${d.id}','${esc(d.name)}')">Révoquer</button></div>`).join('')
: '<div class="empty">Aucun appareil appairé</div>';
} catch (e) { $('devices').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
}
async function revokeDevice(id, name) {
if (!confirm(`Révoquer « ${name} » ? Il ne pourra plus se synchroniser tant qu'il n'est pas réappairé.`)) return;
await api('/devices/' + id, { method: 'DELETE' });
loadDevices();
}
async function openPairing() {
try {
const { seconds } = await api('/pair/open', { method: 'POST' });
alert(`Appairage ouvert pour ${seconds} secondes.\n\n` +
`Sur le téléphone : Réglages ▸ Connexion ▸ Appairer.\n` +
`La fenêtre se referme dès qu'un appareil s'est servi.`);
} catch (e) { alert('Appairage impossible : ' + e); }
}
/* ── À propos ──────────────────────────────────────────────────────────────
Le contenu vient d'about.js, la version de /api/health et le journal du
CHANGELOG.md du dépôt : rien n'est recopié ici, donc rien ne peut diverger. */
let aboutTab = 'features';
const ABOUT_TABS = [
['features', '⭐ Fonctions'], ['guarantees', '🛡️ Garanties'],
['stack', '🧱 Technique'], ['log', '🕓 Journal'], ['author', '👤 Auteur'],
];
async function loadAbout() {
$('aboutName').textContent = ABOUT.name;
$('aboutTagline').textContent = ABOUT.tagline;
let version = '—', live = false;
try {
const health = await fetch('/api/health').then((r) => r.json());
version = health.version; live = true;
} catch {}
$('aboutBadges').innerHTML =
`<span class="pill accent">v${esc(version)}</span>` +
`<span class="pill">${esc(ABOUT.license)}</span>` +
`<span class="pill ${live ? 'ok' : 'warn'}">${live ? 'en service' : 'injoignable'}</span>` +
`<span class="pill ok">4 catalogues libres</span>`;
$('aboutTabs').innerHTML = ABOUT_TABS.map(([id, label]) =>
`<button onclick="showAbout('${id}')" aria-selected="${id === aboutTab}">${label}</button>`).join('');
renderAbout();
}
function showAbout(id) { aboutTab = id; loadAbout(); }
async function renderAbout() {
const body = $('aboutBody');
if (aboutTab === 'features') {
body.innerHTML = ABOUT.features.map(([title, text]) =>
`<div class="card"><b>${esc(title)}</b><small>${esc(text)}</small></div>`).join('');
} else if (aboutTab === 'guarantees') {
body.innerHTML = `<div class="card"><small>${esc(ABOUT.guaranteesIntro)}</small>
<ul style="margin-top:10px">${ABOUT.guarantees.map((g) => `<li>${esc(g)}</li>`).join('')}</ul></div>`;
} else if (aboutTab === 'stack') {
body.innerHTML = `<div class="card"><table class="tech">${ABOUT.stack.map(([layer, tools]) =>
`<tr><td>${esc(layer)}</td><td>${esc(tools)}</td></tr>`).join('')}</table></div>`;
} else if (aboutTab === 'author') {
body.innerHTML = `<div class="card" style="display:flex;align-items:center">
<div class="about-logo" style="width:52px;height:52px;border-radius:16px;font-size:24px">${
esc(ABOUT.author[0])}</div>
<div class="grow" style="margin-left:14px">
<b>${esc(ABOUT.author)}</b><small>${esc(ABOUT.authorRole)}</small>
<small style="user-select:text;display:block;margin-top:6px">${esc(ABOUT.repository)}</small>
</div></div>
<div class="card"><small>${esc(ABOUT.authorship)}</small>
<small style="display:block;margin-top:6px">${esc(ABOUT.copyright)} — ${esc(ABOUT.license)}</small></div>`;
} else {
body.innerHTML = '<div class="empty">Lecture du journal…</div>';
try {
const text = await fetch('/api/changelog').then((r) => r.text());
body.innerHTML = renderChangelog(text) || '<div class="empty">Journal vide</div>';
} catch { body.innerHTML = '<div class="empty">Journal indisponible</div>'; }
}
}
/* Le CHANGELOG n'emploie que des titres « ## », des puces « - » et du gras :
on n'interprète que cela, plutôt que d'embarquer un moteur Markdown. */
function renderChangelog(text) {
const bold = (s) => esc(s).replace(/\*\*(.+?)\*\*/g, '<b style="display:inline">$1</b>');
const entries = [];
for (const line of text.split('\n')) {
if (line.startsWith('## ')) entries.push({ title: line.slice(3).trim(), items: [] });
else if (line.startsWith('- ') && entries.length) entries[entries.length - 1].items.push(line.slice(2).trim());
}
return entries.map((e) => `<div class="card log"><h4>${bold(e.title)}</h4>
<ul>${e.items.map((i) => `<li>${bold(i)}</li>`).join('')}</ul></div>`).join('');
}
const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// Consultée depuis la machine du serveur, la page se remplit seule.
(async () => {
if (!token) {
try {
const { token: found } = await fetch('/api/bootstrap').then((r) => r.ok ? r.json() : {});
if (found) { token = found; $('token').value = found; localStorage.setItem('noxely-token', found); }
} catch {}
}
refreshAll();
})();
setInterval(() => {
if (!$('p-queue').hidden) loadQueue();
if (!$('p-watch').hidden) loadWatches();
}, 5000);
</script>
</body>
</html>