L'interface du serveur affichait un champ de jeton dans son en-tête, visible en permanence alors qu'il ne sert qu'une fois : il descend dans l'onglet Réglages, avec le nom du serveur, sa version et le nombre d'appareils appairés. Elle gagne surtout une page À propos sur le modèle des autres projets : cinq sections, contenu séparé du rendu, version lue de /api/health et journal servi depuis le CHANGELOG du dépôt — rien de recopié, donc rien qui puisse diverger. Au passage, le serveur annonçait 1.0.0 depuis six publications ; il lit maintenant le fichier VERSION comme le client.
443 lines
20 KiB
HTML
443 lines
20 KiB
HTML
<!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%; }
|
||
.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('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-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', '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 === '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`
|
||
: '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();
|
||
}
|
||
|
||
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) =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|