148 lines
4.6 KiB
JavaScript
148 lines
4.6 KiB
JavaScript
const slug = (s) =>
|
|
s.toLowerCase()
|
|
.normalize("NFD").replace(/[̀-ͯ]/g, "")
|
|
.replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
|
|
const navEl = document.getElementById("nav");
|
|
const contentEl = document.getElementById("content");
|
|
const searchEl = document.getElementById("search");
|
|
|
|
const STORE_KEY = "frenchJourney.completed";
|
|
|
|
function loadDone() {
|
|
try { return JSON.parse(localStorage.getItem(STORE_KEY)) || {}; }
|
|
catch { return {}; }
|
|
}
|
|
function saveDone(done) {
|
|
localStorage.setItem(STORE_KEY, JSON.stringify(done));
|
|
}
|
|
let done = loadDone();
|
|
|
|
function chapterId(niveau, titre) {
|
|
return slug(niveau.niveau + "-" + titre);
|
|
}
|
|
|
|
function render(filter = "") {
|
|
const q = filter.trim().toLowerCase();
|
|
navEl.innerHTML = "";
|
|
contentEl.innerHTML = "";
|
|
let anyContent = false;
|
|
|
|
GRAMMAIRE.forEach((niveau) => {
|
|
const chapitres = niveau.chapitres.filter((c) => {
|
|
if (!q) return true;
|
|
const ex = c.exemples.map((e) => e.fr + " " + e.en).join(" ");
|
|
const hay = (c.titre + " " + c.points.join(" ") + " " + ex).toLowerCase();
|
|
return hay.includes(q) || niveau.niveau.toLowerCase().includes(q);
|
|
});
|
|
if (chapitres.length === 0) return;
|
|
anyContent = true;
|
|
|
|
const total = chapitres.length;
|
|
const completed = chapitres.filter((c) => done[chapterId(niveau, c.titre)]).length;
|
|
|
|
// --- Sidebar group ---
|
|
const group = document.createElement("div");
|
|
group.className = "level-group";
|
|
group.innerHTML = `
|
|
<div class="level-label">
|
|
<span>${niveau.niveau} <span class="lvl-name">${niveau.titre}</span></span>
|
|
<span class="lvl-count">${completed}/${total}</span>
|
|
</div>`;
|
|
chapitres.forEach((c) => {
|
|
const id = chapterId(niveau, c.titre);
|
|
const a = document.createElement("a");
|
|
a.href = "#" + id;
|
|
a.dataset.id = id;
|
|
a.innerHTML = `<span class="dot${done[id] ? " on" : ""}"></span>${c.titre.split(" (")[0]}`;
|
|
group.appendChild(a);
|
|
});
|
|
navEl.appendChild(group);
|
|
|
|
// --- Content header ---
|
|
const header = document.createElement("div");
|
|
header.className = "level-header";
|
|
header.innerHTML = `
|
|
<span class="badge">${niveau.niveau}</span>
|
|
<h1>${niveau.titre}</h1>
|
|
<p>${niveau.description}</p>
|
|
<div class="progress">
|
|
<div class="progress-bar"><span style="width:${total ? (completed / total) * 100 : 0}%"></span></div>
|
|
<span class="progress-text">${completed} of ${total} completed</span>
|
|
</div>`;
|
|
contentEl.appendChild(header);
|
|
|
|
// --- Chapters ---
|
|
chapitres.forEach((c) => {
|
|
const id = chapterId(niveau, c.titre);
|
|
const isDone = !!done[id];
|
|
const sec = document.createElement("section");
|
|
sec.className = "chapter" + (isDone ? " done" : "");
|
|
sec.id = id;
|
|
|
|
const points = c.points.map((p) => `<li>${p}</li>`).join("");
|
|
const ex = c.exemples
|
|
.map((e) => `<div class="ex"><code>${e.fr}</code><span class="ex-en">${e.en}</span></div>`)
|
|
.join("");
|
|
|
|
sec.innerHTML = `
|
|
<div class="chapter-head">
|
|
<h2>${c.titre}</h2>
|
|
<button class="mark" data-id="${id}">
|
|
<span class="check">${isDone ? "✓" : ""}</span>
|
|
${isDone ? "Completed" : "Mark as completed"}
|
|
</button>
|
|
</div>
|
|
<ul>${points}</ul>
|
|
<div class="examples">
|
|
<div class="ex-label">Examples</div>
|
|
${ex}
|
|
</div>`;
|
|
contentEl.appendChild(sec);
|
|
});
|
|
});
|
|
|
|
if (!anyContent) {
|
|
contentEl.innerHTML = `<p class="empty">No results for “${filter}”.</p>`;
|
|
}
|
|
|
|
bindMarks();
|
|
setupScrollSpy();
|
|
}
|
|
|
|
function bindMarks() {
|
|
contentEl.querySelectorAll(".mark").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const id = btn.dataset.id;
|
|
if (done[id]) delete done[id];
|
|
else done[id] = true;
|
|
saveDone(done);
|
|
render(searchEl.value);
|
|
});
|
|
});
|
|
}
|
|
|
|
function setupScrollSpy() {
|
|
const links = [...navEl.querySelectorAll("a")];
|
|
const sections = links
|
|
.map((a) => document.getElementById(a.dataset.id))
|
|
.filter(Boolean);
|
|
if (!sections.length) return;
|
|
|
|
const obs = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
links.forEach((l) => l.classList.remove("active"));
|
|
const active = links.find((l) => l.dataset.id === entry.target.id);
|
|
if (active) active.classList.add("active");
|
|
}
|
|
});
|
|
},
|
|
{ rootMargin: "-60px 0px -70% 0px", threshold: 0 }
|
|
);
|
|
sections.forEach((s) => obs.observe(s));
|
|
}
|
|
|
|
searchEl.addEventListener("input", (e) => render(e.target.value));
|
|
render();
|