Add Docker Compose setup
This commit is contained in:
commit
91e67968ca
17 changed files with 2683 additions and 0 deletions
148
app.js
Normal file
148
app.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue