french-journey/app.js
2026-07-10 18:17:02 +03:00

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();