Project Files
src / catalog.ts
import { NOTIONS } from "./data/notions";
export function buildCatalogPage(): string {
const notionsJson = JSON.stringify(NOTIONS);
return `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Livre dont vous êtes le héros — Catalogue de sujets</title>
<style>
:root { --bg:#f7f5ef; --card:#ffffff; --ink:#1f1f1f; --muted:#6b6b6b; --line:#e5e1d6;
--accent:#1565c0; --good:#2e7d32; --warn:#c62828; --soft:#fafaf6; --hint:#ad6800; --hintbg:#fff5d6; }
* { box-sizing: border-box; }
html, body { margin:0; padding:0; min-height:100%; background:var(--bg); color:var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; }
body { display:flex; flex-direction:column; align-items:center; padding:32px 16px 80px; }
header { width:100%; max-width:840px; margin-bottom:24px; }
header h1 { font-size:22px; margin:0 0 6px; }
header .sub { font-size:14px; color:var(--muted); line-height:1.5; }
header .links { font-size:12px; color:var(--muted); margin-top:10px; }
header .links a { color:var(--accent); margin-right:14px; }
main { width:100%; max-width:840px; display:flex; flex-direction:column; gap:18px; }
.panel { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:18px 20px; }
.panel h2 { font-size:15px; margin:0 0 12px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); }
.add-form { display:flex; flex-direction:column; gap:10px; }
.add-form .row { display:flex; gap:8px; }
.add-form input[type="text"], .add-form select, .add-form textarea {
flex:1; padding:10px 12px; border:1px solid var(--line); border-radius:8px;
font:inherit; font-size:14px; background:white;
}
.add-form textarea { min-height:60px; resize:vertical; }
.add-form button { padding:10px 18px; font:inherit; font-weight:600; font-size:14px;
border:0; border-radius:8px; color:white; background:var(--ink); cursor:pointer; }
.add-form button:disabled { opacity:.4; cursor:not-allowed; }
.add-form button.secondary { background:white; color:var(--ink); border:1px solid var(--line); }
.hint { font-size:13px; color:var(--hint); background:var(--hintbg); border:1px solid #f0d99a;
padding:10px 12px; border-radius:8px; margin-top:6px; }
.hint .label { font-weight:600; }
.similar-warn { font-size:13px; background:#fff3e0; border:1px solid #ffcc80; color:#bf360c;
padding:10px 12px; border-radius:8px; margin-top:8px; }
.similar-warn .actions { margin-top:8px; display:flex; gap:8px; }
.notion-block { margin-bottom:18px; }
.notion-block h3 { font-size:13px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted);
margin:0 0 8px; padding-bottom:6px; border-bottom:1px solid var(--line);
display:flex; align-items:center; gap:8px; }
.notion-block h3 .strength { font-size:10px; padding:2px 7px; border-radius:8px; font-weight:600;
text-transform:uppercase; letter-spacing:.04em; }
.notion-block h3 .strength.riche { background:#e8f5e9; color:var(--good); }
.notion-block h3 .strength.moyen { background:#fff3e0; color:#bf6800; }
.notion-block h3 .strength.faible { background:#ffebee; color:var(--warn); }
.notion-block h3 .count { font-size:10px; color:var(--muted); margin-left:auto; font-weight:400; letter-spacing:0; text-transform:none; }
.weak-warn { margin-top:10px; padding:10px 12px; background:var(--hintbg); border:1px solid #f0d99a;
border-radius:6px; font-size:12px; color:var(--hint); line-height:1.5; }
.weak-warn a { color:var(--accent); }
.sujet-row { display:flex; align-items:center; gap:8px; padding:8px 0; border-bottom:1px dotted var(--line); }
.sujet-row:last-child { border-bottom:none; }
.sujet-row .text { flex:1; font-size:14px; line-height:1.4; cursor:pointer; }
.sujet-row .text:hover { color:var(--accent); }
.sujet-row .badge { font-size:10px; padding:2px 6px; border-radius:4px; background:#eee; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; }
.sujet-row .badge.user { background:#e8f5e9; color:var(--good); }
.sujet-row .play { padding:6px 12px; font:inherit; font-size:12px; border:1px solid var(--line);
border-radius:6px; background:white; cursor:pointer; color:var(--accent); font-weight:600; }
.sujet-row .del { padding:4px 8px; font-size:11px; border:0; background:transparent; color:var(--muted); cursor:pointer; }
.sujet-row .del:hover { color:var(--warn); }
.empty { font-size:13px; color:var(--muted); font-style:italic; padding:8px 0; }
.filter { display:flex; gap:8px; margin-bottom:14px; align-items:center; flex-wrap:wrap; }
.filter label { font-size:12px; color:var(--muted); }
.filter select { padding:6px 10px; border:1px solid var(--line); border-radius:6px; font:inherit; font-size:13px; background:white; }
.stats { font-size:12px; color:var(--muted); margin-left:auto; }
</style>
</head>
<body>
<header>
<h1>Livre dont vous êtes le héros — Philo Bac</h1>
<div class="sub">
Choisis un sujet ci-dessous pour commencer, ou ajoute le tien.
Chaque session est sauvegardée localement dans <code>~/.livre-heros-bac/</code>.
</div>
<div class="links">
<a href="/corpus">Enrichir le corpus →</a>
<a href="/pedagogie" target="_blank">Pourquoi cet outil ?</a>
<a href="/admin" target="_blank">Sessions enregistrées</a>
</div>
</header>
<main>
<section class="panel">
<h2>Ajouter un sujet</h2>
<div class="add-form">
<div class="row">
<textarea id="newSujet" placeholder="Exemple : Le langage trahit-il la pensée ?"></textarea>
</div>
<div class="row" id="classifyRow" style="display:none;">
<label style="font-size:13px; color:var(--muted); align-self:center;">Notion : </label>
<select id="notionSelect"></select>
</div>
<div class="row">
<button id="classifyBtn" type="button">Classer automatiquement</button>
<button id="addBtn" type="button" class="secondary" disabled>Ajouter au catalogue</button>
</div>
<div id="hint" class="hint" style="display:none;"></div>
<div id="similar" class="similar-warn" style="display:none;"></div>
</div>
</section>
<section class="panel">
<h2>Catalogue</h2>
<div class="filter">
<label>Filtre :</label>
<select id="filterNotion">
<option value="">Toutes les notions</option>
</select>
<span class="stats" id="stats"></span>
</div>
<div id="catalog"></div>
</section>
</main>
<script>
"use strict";
const NOTIONS = ${notionsJson};
const newSujet = document.getElementById("newSujet");
const notionSelect = document.getElementById("notionSelect");
const classifyRow = document.getElementById("classifyRow");
const classifyBtn = document.getElementById("classifyBtn");
const addBtn = document.getElementById("addBtn");
const hint = document.getElementById("hint");
const similar = document.getElementById("similar");
const catalogEl = document.getElementById("catalog");
const filterNotion = document.getElementById("filterNotion");
const statsEl = document.getElementById("stats");
let allSujets = [];
let notionStats = {}; // slug → {activeCount, pendingCount, level}
let suggestedNotion = null;
let bypassSimilar = false;
function el(tag, attrs, ...kids) {
const n = document.createElement(tag);
if (attrs) for (const k of Object.keys(attrs)) {
if (k === "class") n.className = attrs[k];
else if (k.startsWith("on")) n.addEventListener(k.slice(2), attrs[k]);
else n.setAttribute(k, String(attrs[k]));
}
for (const c of kids) {
if (c == null || c === false) continue;
if (typeof c === "string") n.appendChild(document.createTextNode(c));
else n.appendChild(c);
}
return n;
}
function clearNode(n) { while (n.firstChild) n.removeChild(n.firstChild); }
for (const n of NOTIONS) {
notionSelect.appendChild(el("option", { value: n }, n));
filterNotion.appendChild(el("option", { value: n }, n));
}
function startSession(notion, sujet) {
// Si la notion est faible, on prévient via confirm() avant de partir.
const slug = NOTION_TO_SLUG[notion];
const stats = slug ? notionStats[slug] : null;
if (stats && stats.level === "faible") {
const proceed = confirm(
"La notion « " + notion + " » a " + stats.activeCount + " citation(s) active(s) dans le corpus — c'est peu.\\n\\n" +
"Le narrateur risque de tourner en rond ou de citer hors-sujet.\\n\\n" +
"Tu peux quand même lancer la session, ou bien enrichir le corpus d'abord."
);
if (!proceed) return;
}
localStorage.removeItem("livreHerosSessionId");
const url = "/play?notion=" + encodeURIComponent(notion) + "&sujet=" + encodeURIComponent(sujet);
window.location.href = url;
}
const NOTION_TO_SLUG = {};
for (const n of NOTIONS) {
NOTION_TO_SLUG[n] = n.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/^(l'|la |le |les )/, "").trim();
}
function renderCatalog() {
clearNode(catalogEl);
const filter = filterNotion.value;
const visible = filter ? allSujets.filter((s) => s.notion === filter) : allSujets;
statsEl.textContent = visible.length + " sujet" + (visible.length > 1 ? "s" : "");
if (visible.length === 0) {
catalogEl.appendChild(el("div", { class: "empty" }, "Aucun sujet."));
return;
}
const byNotion = new Map();
for (const n of NOTIONS) byNotion.set(n, []);
for (const s of visible) {
if (!byNotion.has(s.notion)) byNotion.set(s.notion, []);
byNotion.get(s.notion).push(s);
}
for (const [notion, sujets] of byNotion) {
if (sujets.length === 0) continue;
const slug = NOTION_TO_SLUG[notion];
const stats = slug ? notionStats[slug] : null;
const h3 = el("h3", null, notion);
if (stats) {
h3.appendChild(el("span", { class: "strength " + stats.level },
stats.level === "riche" ? "corpus riche" : stats.level === "moyen" ? "corpus moyen" : "corpus faible"));
h3.appendChild(el("span", { class: "count" },
stats.activeCount + " citation" + (stats.activeCount > 1 ? "s" : "") + " active" + (stats.activeCount > 1 ? "s" : "")
+ (stats.pendingCount > 0 ? " + " + stats.pendingCount + " en attente" : "")));
}
const block = el("div", { class: "notion-block" }, h3);
for (const s of sujets) {
const row = el("div", { class: "sujet-row" });
const text = el("div", { class: "text", onclick: () => startSession(s.notion, s.sujet) }, s.sujet);
row.appendChild(text);
if (s.source === "user") row.appendChild(el("span", { class: "badge user" }, "ajouté"));
row.appendChild(el("button", { class: "play", type: "button", onclick: () => startSession(s.notion, s.sujet) }, "Démarrer →"));
row.appendChild(el("button", { class: "del", type: "button", title: "Supprimer", onclick: () => deleteSujet(s.id) }, "✕"));
block.appendChild(row);
}
catalogEl.appendChild(block);
}
}
async function loadCatalog() {
// Les deux fetches sont indépendants — si /api/notions/stats échoue
// (ex: serveur pas encore redémarré après un rebuild), le catalogue
// doit quand même s'afficher (sans badges).
try {
const res = await fetch("/api/sujets");
const data = await res.json();
allSujets = data.sujets || [];
} catch (e) {
console.error("loadCatalog /api/sujets failed:", e);
allSujets = [];
}
try {
const res = await fetch("/api/notions/stats");
if (res.ok) {
const data = await res.json();
notionStats = {};
for (const s of (data.stats || [])) notionStats[s.slug] = s;
} else {
notionStats = {};
}
} catch (e) {
console.warn("loadCatalog /api/notions/stats unavailable:", e);
notionStats = {};
}
renderCatalog();
}
async function deleteSujet(id) {
if (!confirm("Supprimer ce sujet du catalogue ?")) return;
const res = await fetch("/api/sujets/" + encodeURIComponent(id), { method: "DELETE" });
if (res.ok) await loadCatalog();
else alert("Erreur suppression : " + res.status);
}
function resetAddForm() {
newSujet.value = "";
hint.style.display = "none";
similar.style.display = "none";
classifyRow.style.display = "none";
addBtn.disabled = true;
suggestedNotion = null;
bypassSimilar = false;
}
async function doClassify() {
const sujet = newSujet.value.trim();
if (sujet.length < 8) {
alert("Le sujet est trop court (minimum 8 caractères).");
return;
}
classifyBtn.disabled = true;
classifyBtn.textContent = "Classement en cours…";
hint.style.display = "none";
similar.style.display = "none";
try {
const res = await fetch("/api/sujets/classify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sujet }),
});
const data = await res.json();
classifyRow.style.display = "flex";
if (data.notion && data.confidence >= 0.6) {
notionSelect.value = data.notion;
suggestedNotion = data.notion;
hint.style.display = "block";
clearNode(hint);
hint.appendChild(el("span", { class: "label" }, "Suggestion : "));
hint.appendChild(document.createTextNode(
data.notion + " (confiance " + Math.round(data.confidence * 100) + "%). Tu peux changer dans le menu."
));
} else {
notionSelect.value = NOTIONS[0];
hint.style.display = "block";
clearNode(hint);
hint.appendChild(el("span", { class: "label" }, "Classement incertain — "));
hint.appendChild(document.createTextNode(
"choisis toi-même la notion qui correspond le mieux."
));
}
addBtn.disabled = false;
} catch (e) {
alert("Erreur classification : " + e);
} finally {
classifyBtn.disabled = false;
classifyBtn.textContent = "Classer automatiquement";
}
}
async function doAdd() {
const sujet = newSujet.value.trim();
const notion = notionSelect.value;
if (!sujet || !notion) return;
addBtn.disabled = true;
addBtn.textContent = "Vérification…";
similar.style.display = "none";
try {
// Étape 1 : check similarité (sauf si on a déjà bypass)
if (!bypassSimilar) {
const simRes = await fetch("/api/sujets/similar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sujet, notion }),
});
const simData = await simRes.json();
if (simData.similar) {
similar.style.display = "block";
clearNode(similar);
similar.appendChild(el("div", null,
el("strong", null, "Un sujet très proche existe déjà : "),
el("em", null, "« " + simData.similar.sujet + " »")
));
if (simData.reason) similar.appendChild(el("div", { style: "margin-top:4px; font-style:italic;" }, simData.reason));
const actions = el("div", { class: "actions" });
actions.appendChild(el("button", { class: "secondary", style: "padding:6px 12px; font-size:13px;",
onclick: () => { bypassSimilar = true; doAdd(); } }, "Ajouter quand même"));
actions.appendChild(el("button", { style: "padding:6px 12px; font-size:13px; background:var(--accent);",
onclick: () => startSession(simData.similar.notion, simData.similar.sujet) }, "Démarrer l'existant →"));
actions.appendChild(el("button", { class: "secondary", style: "padding:6px 12px; font-size:13px;",
onclick: () => { similar.style.display = "none"; addBtn.disabled = false; addBtn.textContent = "Ajouter au catalogue"; } }, "Annuler"));
similar.appendChild(actions);
addBtn.disabled = true;
addBtn.textContent = "Ajouter au catalogue";
return;
}
}
// Étape 2 : ajout réel
const res = await fetch("/api/sujets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sujet, notion }),
});
const data = await res.json();
if (!res.ok) { alert("Erreur : " + (data.error || res.statusText)); addBtn.disabled = false; addBtn.textContent = "Ajouter au catalogue"; return; }
resetAddForm();
await loadCatalog();
} catch (e) {
alert("Erreur : " + e);
addBtn.disabled = false;
addBtn.textContent = "Ajouter au catalogue";
}
}
classifyBtn.addEventListener("click", doClassify);
addBtn.addEventListener("click", doAdd);
filterNotion.addEventListener("change", renderCatalog);
newSujet.addEventListener("input", () => {
if (classifyRow.style.display !== "none") {
classifyRow.style.display = "none";
hint.style.display = "none";
similar.style.display = "none";
addBtn.disabled = true;
bypassSimilar = false;
}
});
loadCatalog();
</script>
</body>
</html>`;
}