Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53f5fc1148 |
No files matched your search
@@ -5,6 +5,32 @@ Toutes les évolutions notables de Noxely sont documentées ici.
|
|||||||
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/)
|
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/)
|
||||||
et le versionnage [SemVer](https://semver.org/lang/fr/).
|
et le versionnage [SemVer](https://semver.org/lang/fr/).
|
||||||
|
|
||||||
|
## [2.3.0] — 2026-09-13
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Page « À propos » du serveur.** Un onglet `ℹ️ À propos` avec en-tête d'identité et cinq
|
||||||
|
sections : ce que le serveur fait, ce qu'il garantit, avec quoi il est construit, ce qui a
|
||||||
|
changé, et qui en répond. Le contenu vit dans un module de constantes séparé du rendu.
|
||||||
|
- Le journal est **lu** et non recopié : une route `/api/changelog` sert le `CHANGELOG.md` du
|
||||||
|
dépôt, que la page interprète pour ce qu'il emploie réellement — titres, puces et gras.
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
|
||||||
|
- **Le champ « jeton » quitte l'en-tête du serveur**, où il n'avait rien à faire, pour l'onglet
|
||||||
|
`⚙️ Réglages`. Une carte y donne aussi le nom du serveur, sa version et le nombre d'appareils
|
||||||
|
appairés. Sans jeton connu, la pastille d'état indique où le saisir.
|
||||||
|
- **Le serveur annonce la version du dépôt.** Il lisait une constante figée à `1.0.0`, restée en
|
||||||
|
arrière de six publications ; il lit maintenant le fichier `VERSION`, comme le client.
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- `saveToken()` relançait l'amorçage du jeton au lieu de rafraîchir la page : un bloc y avait été
|
||||||
|
déplacé par erreur lors d'une édition antérieure.
|
||||||
|
- Une garantie annoncée à tort. La page affirmait que toute route hors `/api/health` exigeait un
|
||||||
|
jeton ; la page elle-même, ses ressources et le journal répondent sans. Les sept garanties ont
|
||||||
|
été confrontées au code une à une, celle-ci reformulée.
|
||||||
|
|
||||||
## [2.2.0] — 2026-09-13
|
## [2.2.0] — 2026-09-13
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
@@ -24,14 +24,17 @@ from contextlib import asynccontextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, Request
|
from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, Request
|
||||||
from fastapi.responses import FileResponse, HTMLResponse
|
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, Response
|
||||||
|
|
||||||
from . import config as config_module
|
from . import config as config_module
|
||||||
from .sources import catalogs, radio
|
from .sources import catalogs, radio
|
||||||
from .storage import Storage
|
from .storage import Storage
|
||||||
from .workers import Downloader, Watcher, safe_name
|
from .workers import Downloader, Watcher, safe_name
|
||||||
|
|
||||||
VERSION = "1.0.0"
|
# Client et serveur sortent du même dépôt, à la même version : on lit le fichier
|
||||||
|
# que la chaîne de publication incrémente, plutôt qu'un numéro à tenir à jour ici.
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
VERSION = (REPO_ROOT / "VERSION").read_text().strip() if (REPO_ROOT / "VERSION").exists() else "0.0.0"
|
||||||
PAIRING_WINDOW_SECONDS = 120
|
PAIRING_WINDOW_SECONDS = 120
|
||||||
WEB_DIR = Path(__file__).parent / "web"
|
WEB_DIR = Path(__file__).parent / "web"
|
||||||
|
|
||||||
@@ -278,6 +281,21 @@ def bootstrap(request: Request) -> dict:
|
|||||||
return {"token": config.token}
|
return {"token": config.token}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/changelog", response_class=PlainTextResponse)
|
||||||
|
def changelog() -> str:
|
||||||
|
"""Le journal du dépôt, lu à la demande plutôt que recopié dans la page."""
|
||||||
|
path = REPO_ROOT / "CHANGELOG.md"
|
||||||
|
return path.read_text(encoding="utf-8") if path.exists() else ""
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/about.js", response_class=PlainTextResponse)
|
||||||
|
def about_script() -> Response:
|
||||||
|
return Response(
|
||||||
|
(WEB_DIR / "about.js").read_text(encoding="utf-8"),
|
||||||
|
media_type="application/javascript; charset=utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index() -> str:
|
def index() -> str:
|
||||||
return (WEB_DIR / "index.html").read_text(encoding="utf-8")
|
return (WEB_DIR / "index.html").read_text(encoding="utf-8")
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
// Contenu de la page « À propos », séparé de son rendu.
|
||||||
|
// Tout ce qui est écrit ici se vérifie dans le dépôt : une fonction décrite existe, une garantie
|
||||||
|
// annoncée est tenue par du code. La version vient de /api/health, jamais recopiée.
|
||||||
|
const ABOUT = {
|
||||||
|
name: 'Noxely — serveur',
|
||||||
|
tagline: 'Il cherche, il guette, il enregistre. Le téléphone n\'a plus qu\'à écouter.',
|
||||||
|
repository: 'https://git.tuxtech.fr/tuxgyver/Noxely',
|
||||||
|
author: 'Johnny',
|
||||||
|
authorRole: 'Conception, direction et supervision',
|
||||||
|
authorship:
|
||||||
|
'Application développée avec l\'assistance de l\'IA, sous la direction et la supervision de Johnny.',
|
||||||
|
license: 'GPL v3',
|
||||||
|
copyright: 'Copyright © 2026 Johnny (tuxgyver)',
|
||||||
|
|
||||||
|
// Ce que fait le serveur, du point de vue de qui s'en sert.
|
||||||
|
features: [
|
||||||
|
['Chercher partout à la fois',
|
||||||
|
'Internet Archive, Underground (netlabels et Free Music Archive), LibriVox et Wikimedia ' +
|
||||||
|
'Commons sont interrogés en parallèle, les résultats fondus et les plus légers mis en tête.'],
|
||||||
|
['Choisir avant de télécharger',
|
||||||
|
'Un album ou un livre affiche ses pistes avec leur taille : on met en file ce qu\'on veut, ' +
|
||||||
|
'pas l\'ensemble.'],
|
||||||
|
['Guetter un morceau sur les radios',
|
||||||
|
'On nomme un titre sans désigner de station : les plus écoutées sont balayées en continu, ' +
|
||||||
|
'et l\'enregistrement part dès que l\'une le diffuse.'],
|
||||||
|
['Enregistrer un direct',
|
||||||
|
'La capture prend le nom du morceau annoncé par la station, et s\'arrête quand il cède la ' +
|
||||||
|
'place au suivant.'],
|
||||||
|
['Tenir la file',
|
||||||
|
'Les téléchargements s\'enchaînent seuls ; un échec est repris, son état reste visible.'],
|
||||||
|
['Servir le mobile',
|
||||||
|
'L\'application demande ce qui a changé depuis son dernier passage et récupère des fichiers ' +
|
||||||
|
'déjà prêts — c\'est ce qui lui épargne sa batterie.'],
|
||||||
|
],
|
||||||
|
|
||||||
|
guaranteesIntro:
|
||||||
|
'Noxely est un outil personnel : rien n\'y transite par un service tiers, et le serveur ne ' +
|
||||||
|
'répond qu\'à ce qui présente un jeton valide.',
|
||||||
|
|
||||||
|
// Chaque promesse est tenue par du code.
|
||||||
|
guarantees: [
|
||||||
|
'Aucun compte, aucune clé d\'API : les quatre catalogues et l\'annuaire radio sont publics.',
|
||||||
|
'Toute route de données exige un jeton — celui du serveur, ou celui d\'un appareil appairé ; ' +
|
||||||
|
'seuls l\'état de santé, la page et ses ressources répondent sans.',
|
||||||
|
'Chaque appareil reçoit son propre jeton : en révoquer un ne coupe pas les autres.',
|
||||||
|
'Les jetons d\'appareil ne sont jamais renvoyés par la route qui les liste.',
|
||||||
|
'L\'appairage n\'est possible que pendant une fenêtre ouverte depuis cette interface, deux ' +
|
||||||
|
'minutes durant, une seule fois, et depuis le réseau local uniquement.',
|
||||||
|
'Les catalogues ne proposent que des œuvres libres ou du domaine public ; les éléments en ' +
|
||||||
|
'accès restreint sont écartés avant même d\'être affichés.',
|
||||||
|
'Une synchronisation partielle ne fait pas avancer le curseur du client : rien n\'est perdu.',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Couche → outils.
|
||||||
|
stack: [
|
||||||
|
['Service', 'Python, FastAPI, uvicorn, unité systemd'],
|
||||||
|
['Données', 'SQLite pour l\'index, fichiers sur disque pour les médias'],
|
||||||
|
['Collecte', 'httpx asynchrone, flux ICY pour les titres de radio'],
|
||||||
|
['Catalogues', 'Internet Archive, netlabels, LibriVox, Wikimedia Commons'],
|
||||||
|
['Interface', 'HTML, CSS et JavaScript sans dépendance'],
|
||||||
|
['Synchronisation', 'HTTP avec jeton, curseur incrémental côté client'],
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -48,6 +48,25 @@
|
|||||||
.pill.ok { background:rgba(17,168,148,.16); color:var(--mint); }
|
.pill.ok { background:rgba(17,168,148,.16); color:var(--mint); }
|
||||||
.pill.warn { background:rgba(224,98,155,.16); color:#e0629b; }
|
.pill.warn { background:rgba(224,98,155,.16); color:#e0629b; }
|
||||||
.empty { color:var(--muted); text-align:center; padding:40px 10px; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -55,8 +74,6 @@
|
|||||||
<h1>🎧 Noxely — serveur</h1>
|
<h1>🎧 Noxely — serveur</h1>
|
||||||
<span id="status" class="pill">…</span>
|
<span id="status" class="pill">…</span>
|
||||||
<span class="grow" style="flex:1"></span>
|
<span class="grow" style="flex:1"></span>
|
||||||
<input type="password" id="token" placeholder="Jeton" style="width:190px">
|
|
||||||
<button class="ghost" onclick="saveToken()">Enregistrer</button>
|
|
||||||
<button class="go" onclick="openPairing()">📱 Appairer</button>
|
<button class="go" onclick="openPairing()">📱 Appairer</button>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
@@ -66,6 +83,8 @@
|
|||||||
<button onclick="show('queue')" aria-selected="false" id="t-queue">⏳ File</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('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('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>
|
</div>
|
||||||
|
|
||||||
<section id="p-search">
|
<section id="p-search">
|
||||||
@@ -89,12 +108,39 @@
|
|||||||
|
|
||||||
<section id="p-queue" hidden><div id="queue"></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-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>
|
<section id="p-devices" hidden>
|
||||||
<div class="row"><button class="go" onclick="openPairing()">Appairer un appareil</button></div>
|
<div class="row"><button class="go" onclick="openPairing()">Appairer un appareil</button></div>
|
||||||
<div id="devices"></div>
|
<div id="devices"></div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<script src="/about.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
let token = localStorage.getItem('noxely-token') || '';
|
let token = localStorage.getItem('noxely-token') || '';
|
||||||
@@ -103,16 +149,7 @@ $('token').value = token;
|
|||||||
function saveToken() {
|
function saveToken() {
|
||||||
token = $('token').value.trim();
|
token = $('token').value.trim();
|
||||||
localStorage.setItem('noxely-token', token);
|
localStorage.setItem('noxely-token', token);
|
||||||
// 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();
|
refreshAll();
|
||||||
})();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
@@ -125,7 +162,7 @@ async function api(path, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function show(name) {
|
function show(name) {
|
||||||
for (const tab of ['search', 'watch', 'queue', 'library', 'devices']) {
|
for (const tab of ['search', 'watch', 'queue', 'library', 'devices', 'settings', 'about']) {
|
||||||
$('p-' + tab).hidden = tab !== name;
|
$('p-' + tab).hidden = tab !== name;
|
||||||
$('t-' + tab).setAttribute('aria-selected', String(tab === name));
|
$('t-' + tab).setAttribute('aria-selected', String(tab === name));
|
||||||
}
|
}
|
||||||
@@ -133,6 +170,8 @@ function show(name) {
|
|||||||
if (name === 'library') loadLibrary();
|
if (name === 'library') loadLibrary();
|
||||||
if (name === 'watch') loadWatches();
|
if (name === 'watch') loadWatches();
|
||||||
if (name === 'devices') loadDevices();
|
if (name === 'devices') loadDevices();
|
||||||
|
if (name === 'settings') loadServerInfo();
|
||||||
|
if (name === 'about') loadAbout();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
@@ -141,6 +180,10 @@ async function refreshAll() {
|
|||||||
$('status').textContent = health.name + ' ' + health.version;
|
$('status').textContent = health.name + ' ' + health.version;
|
||||||
$('status').className = 'pill ok';
|
$('status').className = 'pill ok';
|
||||||
} catch { $('status').textContent = 'injoignable'; $('status').className = 'pill warn'; }
|
} catch { $('status').textContent = 'injoignable'; $('status').className = 'pill warn'; }
|
||||||
|
if (!token) {
|
||||||
|
$('status').textContent = 'jeton requis → Réglages';
|
||||||
|
$('status').className = 'pill warn';
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const { catalogs } = await api('/catalogs');
|
const { catalogs } = await api('/catalogs');
|
||||||
$('catalog').innerHTML = Object.entries(catalogs)
|
$('catalog').innerHTML = Object.entries(catalogs)
|
||||||
@@ -263,6 +306,20 @@ async function sweep() {
|
|||||||
loadWatches();
|
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() {
|
async function loadDevices() {
|
||||||
try {
|
try {
|
||||||
const { devices } = await api('/devices');
|
const { devices } = await api('/devices');
|
||||||
@@ -291,6 +348,78 @@ async function openPairing() {
|
|||||||
} catch (e) { alert('Appairage impossible : ' + e); }
|
} 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) =>
|
const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
|
||||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user