2 Commits
Author SHA1 Message Date
tuxgyver 53f5fc1148 Le serveur se présente, et range son jeton (2.3.0)
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.
2026-09-13 22:10:44 +02:00
tuxgyver 28cb545685 Un jeton par appareil, révocable seul (2.2.0)
Tous les appareils partageaient le jeton du serveur : perdre un téléphone
obligeait à changer ce jeton, donc à réappairer tout le monde. Chaque
appairage émet désormais le sien.

Révoquer devient une opération locale, vérifiée en appairant deux appareils
puis en coupant le premier : 401 pour lui, 200 pour l'autre, jeton maître
intact. L'interface du serveur les liste avec leur modèle, leur date
d'appairage et leur dernière synchronisation — sans jamais afficher les jetons
eux-mêmes, ce qui reviendrait à les distribuer.

La table s'ajoute sans toucher aux données existantes et le jeton maître reste
accepté : rien ne casse pour un appareil appairé avant cette version.
2026-09-13 21:23:19 +02:00
8 changed files with 389 additions and 21 deletions

No files matched your search

+52
View File
@@ -5,6 +5,58 @@ Toutes les évolutions notables de Noxely sont documentées ici.
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/)
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
### Ajouté
- **Un jeton par appareil.** Chaque appairage émet désormais sa propre valeur, au lieu de
distribuer le jeton maître. Révoquer un téléphone perdu n'atteint plus les autres : vérifié en
appairant deux appareils, en révoquant le premier, et en constatant que le second continue de
se synchroniser (401 pour l'un, 200 pour l'autre, jeton maître intact).
- **Onglet « Appareils »** dans l'interface du serveur : nom du modèle, date d'appairage,
dernière synchronisation — ou « jamais synchronisé » —, et révocation d'un clic.
- Le téléphone s'annonce par son modèle lors de l'appairage : un appareil qu'on ne sait pas
nommer ne se révoque pas.
### Sécurité
- Les jetons d'appareil ne sont **jamais renvoyés** par la route qui les liste : les afficher
reviendrait à les distribuer.
- La date de dernière utilisation est enregistrée à chaque appel authentifié, ce qui permet de
repérer un appareil oublié.
### Compatibilité
- La table s'ajoute sans toucher aux données existantes, et le jeton maître reste accepté : un
appareil appairé avant cette version garde l'accès. Le réappairer lui donnera un jeton propre,
et donc une révocation individuelle.
## [2.1.0] — 2026-09-13
### Ajouté
+2 -1
View File
@@ -7,7 +7,7 @@
[![Kotlin](https://img.shields.io/badge/Kotlin-2.1.0-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org)
[![Compose](https://img.shields.io/badge/Jetpack%20Compose-Material%203-4285F4?logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose)
[![Media3](https://img.shields.io/badge/Media3-1.11.1-FF6F00?logo=googleplay&logoColor=white)](https://developer.android.com/media/media3)
[![Version](https://img.shields.io/badge/Version-2.1.0-6B4EFF)](VERSION)
[![Version](https://img.shields.io/badge/Version-2.2.0-6B4EFF)](VERSION)
[![Licence](https://img.shields.io/badge/Licence-GPL%20v3-3FD8C0)](LICENSE)
[![Hors ligne](https://img.shields.io/badge/Hors%20ligne-100%25-3FD8C0)](#-comment-ça-marche)
@@ -60,6 +60,7 @@ synchronisations : c'est là qu'est l'économie.
|---|---|
| 🔄 **Synchronisation sobre** | Toutes les 6 h, en Wi-Fi non facturé et batterie suffisante |
| 📱 **Appairage** | Un bouton côté serveur, un bouton côté téléphone : aucun jeton à recopier |
| 🔑 **Un jeton par appareil** | Chacun le sien, révocable seul depuis l'onglet Appareils |
| 🎵 **Lecture** | Audio et vidéo, arrière-plan, casque, écran verrouillé, ±15 s, stop |
| 📚 **Bibliothèque** | Médias reçus du serveur et contenu déjà présent sur l'appareil |
| 📋 **Playlists** | Listes nommées, appui long pour y ajouter un titre |
+1 -1
View File
@@ -1 +1 @@
2.1.0
2.3.0
@@ -13,6 +13,7 @@
package fr.tuxgyver.noxely.sync
import android.content.Context
import android.os.Build
import fr.tuxgyver.noxely.library.ImportedStore
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.MediaKind
@@ -69,7 +70,10 @@ class SyncClient(
val connection = openConnection("$base/api/pair", readTimeoutMs = 20_000).apply {
requestMethod = "POST"
doOutput = true
outputStream.close()
setRequestProperty("Content-Type", "application/json")
// Le nom du modèle rend l'appareil reconnaissable dans la liste du serveur.
val name = (Build.MANUFACTURER + " " + Build.MODEL).trim()
outputStream.use { it.write(("{" + "\"name\":\"" + name + "\"}").toByteArray()) }
}
val body = try {
when (connection.responseCode) {
+49 -6
View File
@@ -18,19 +18,23 @@ from __future__ import annotations
import asyncio
import ipaddress
import secrets
import time
from contextlib import asynccontextmanager
from pathlib import Path
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 .sources import catalogs, radio
from .storage import Storage
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
WEB_DIR = Path(__file__).parent / "web"
@@ -41,9 +45,18 @@ watcher = Watcher(config, storage)
def authorised(x_noxely_token: str = Header(default="")) -> None:
"""Sans jeton valide, le serveur ne répond rien : il expose des fichiers."""
if x_noxely_token != config.token:
"""Sans jeton valide, le serveur ne répond rien : il expose des fichiers.
Deux formes valent : le jeton maître, qui pilote le serveur depuis le navigateur, et celui
d'un appareil appairé. Les distinguer est ce qui permet de révoquer un téléphone perdu sans
déranger les autres.
"""
if x_noxely_token and x_noxely_token == config.token:
return
device = storage.device_by_token(x_noxely_token) if x_noxely_token else None
if not device:
raise HTTPException(status_code=401, detail="Jeton invalide")
storage.touch_device(device["id"])
@asynccontextmanager
@@ -221,8 +234,19 @@ def pairing_state() -> dict:
return {"open": remaining > 0, "seconds": max(0, remaining)}
@app.get("/api/devices", dependencies=[Depends(authorised)])
def devices() -> dict:
return {"devices": storage.devices()}
@app.delete("/api/devices/{device_id}", dependencies=[Depends(authorised)])
def revoke_device(device_id: str) -> dict:
"""Révoque un appareil : son jeton cesse immédiatement d'être accepté."""
return {"revoked": storage.delete_device(device_id)}
@app.post("/api/pair")
def pair(request: Request) -> dict:
def pair(request: Request, payload: dict = Body(default={})) -> dict:
"""Livre le jeton à un appareil du réseau local, pendant la fenêtre d'appairage.
La fenêtre se referme dès qu'un appareil s'est servi : une ouverture, un appairage.
@@ -237,7 +261,11 @@ def pair(request: Request) -> dict:
if not _is_private(client):
raise HTTPException(status_code=403, detail="Appairage réservé au réseau local")
_pairing_until = None
return {"token": config.token, "name": "Noxely", "version": VERSION}
# Chaque appairage reçoit son propre jeton : révoquer l'un n'atteint pas les autres.
token = secrets.token_urlsafe(24)
name = str(payload.get("name") or "Appareil").strip()[:60]
storage.add_device(name, token)
return {"token": token, "name": "Noxely", "version": VERSION, "device": name}
@app.get("/api/bootstrap")
@@ -253,6 +281,21 @@ def bootstrap(request: Request) -> dict:
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)
def index() -> str:
return (WEB_DIR / "index.html").read_text(encoding="utf-8")
+41
View File
@@ -42,6 +42,15 @@ CREATE TABLE IF NOT EXISTS media (
);
CREATE INDEX IF NOT EXISTS media_updated ON media(updated_at);
CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
last_seen INTEGER
);
CREATE INDEX IF NOT EXISTS devices_token ON devices(token);
CREATE TABLE IF NOT EXISTS watches (
id TEXT PRIMARY KEY,
query TEXT NOT NULL,
@@ -150,6 +159,38 @@ class Storage:
self._db.commit()
return item
# --- Appareils ------------------------------------------------------
def add_device(self, name: str, token: str) -> str:
device_id = uuid.uuid4().hex
self._db.execute(
"INSERT INTO devices (id,name,token,created_at) VALUES (?,?,?,?)",
(device_id, name.strip() or "Appareil", token, now()),
)
self._db.commit()
return device_id
def device_by_token(self, token: str) -> dict | None:
row = self._db.execute("SELECT * FROM devices WHERE token = ?", (token,)).fetchone()
return dict(row) if row else None
def touch_device(self, device_id: str) -> None:
"""Dernière utilisation : c'est ce qui permet de reconnaître un appareil oublié."""
self._db.execute("UPDATE devices SET last_seen = ? WHERE id = ?", (now(), device_id))
self._db.commit()
def devices(self) -> list[dict]:
return [
# Le jeton n'est jamais renvoyé : le lister reviendrait à le distribuer.
{k: v for k, v in dict(row).items() if k != "token"}
for row in self._db.execute("SELECT * FROM devices ORDER BY created_at DESC")
]
def delete_device(self, device_id: str) -> bool:
cursor = self._db.execute("DELETE FROM devices WHERE id = ?", (device_id,))
self._db.commit()
return cursor.rowcount > 0
# --- Guets ----------------------------------------------------------
def add_watch(self, query: str) -> str:
+73
View File
@@ -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'],
],
};
+166 -12
View File
@@ -48,6 +48,25 @@
.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>
@@ -55,8 +74,6 @@
<h1>🎧 Noxely — serveur</h1>
<span id="status" class="pill">…</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>
</header>
<main>
@@ -65,6 +82,9 @@
<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">
@@ -88,8 +108,39 @@
<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') || '';
@@ -98,16 +149,7 @@ $('token').value = token;
function saveToken() {
token = $('token').value.trim();
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();
})();
}
async function api(path, options = {}) {
@@ -120,13 +162,16 @@ async function api(path, options = {}) {
}
function show(name) {
for (const tab of ['search', 'watch', 'queue', 'library']) {
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() {
@@ -135,6 +180,10 @@ async function refreshAll() {
$('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)
@@ -257,6 +306,39 @@ async function sweep() {
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' });
@@ -266,6 +348,78 @@ async function openPairing() {
} 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]));