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

208 lines
6.4 KiB
JavaScript

const searchEl = document.getElementById("explorer-search");
const srsFilterEl = document.getElementById("explorer-srs-filter");
const sortEl = document.getElementById("explorer-sort");
const countEl = document.getElementById("explorer-count");
const metaEl = document.getElementById("explorer-meta");
const listEl = document.getElementById("explorer-list");
const SRS_KEY = "frenchJourney.srs";
const FORCE_KEY = "frenchJourney.forceCard";
const DEFAULT_LIST_ID = "er-verbs";
const MIN = 60000;
const DAY = 86400000;
let cards = [];
let query = "";
let srsFilter = "all";
let sortMode = "srs";
let store = { cards: {}, meta: {} };
const PRONOUNS = ["je", "tu", "il/elle", "nous", "vous", "ils/elles"];
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (ch) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[ch]));
}
function withPronoun(pron, form) {
if (pron === "je" && /^[aeiouyhàâäéèêëîïôöûü]/i.test(form)) {
return `<span class="cj-p">j'</span>${escapeHtml(form)}`;
}
return `<span class="cj-p">${pron} </span>${escapeHtml(form)}`;
}
function conjTable(card) {
if (!card.present) return "";
const cells = card.present
.map((form, i) => `<span class="cj-cell">${withPronoun(PRONOUNS[i], form)}</span>`)
.join("");
return `<div class="fc-conj">
<div class="fc-conj-label">Présent</div>
<div class="fc-conj-grid">${cells}</div>
</div>`;
}
function loadSrs() {
try {
const saved = JSON.parse(localStorage.getItem(SRS_KEY));
if (saved && saved.cards) store = saved;
} catch {}
}
function cardKey(card) {
return `${DEFAULT_LIST_ID}:${card.infinitif}`;
}
function schedOf(card) {
return store.cards[cardKey(card)];
}
function fmtUntil(ms) {
if (ms <= 0) return "due now";
if (ms < DAY) {
const mins = Math.round(ms / MIN);
if (mins < 1) return "<1m";
if (mins < 60) return `${mins}m`;
return `${Math.round(mins / 60)}h`;
}
const days = ms / DAY;
if (days < 30) return `${Math.round(days)}d`;
if (days < 365) return `${(days / 30).toFixed(1).replace(/\.0$/, "")}mo`;
return `${(days / 365).toFixed(1).replace(/\.0$/, "")}y`;
}
function srsInfo(card) {
const s = schedOf(card);
if (!s) return { state: "new", label: "new", timeLeft: -1 };
const isLearning = s.state === "learning" || s.state === "relearning";
const timeLeft = Math.max(0, s.due - Date.now());
return {
state: s.state || "review",
label: timeLeft === 0 ? (isLearning ? "learning now" : "due now") : fmtUntil(timeLeft),
timeLeft,
};
}
function matches(card, q) {
if (!q) return true;
return [
card.infinitif,
card.anglais,
card.exemple_fr,
card.exemple_en,
].join(" ").toLowerCase().includes(q);
}
function matchesSrs(card) {
const s = schedOf(card);
if (srsFilter === "all") return true;
if (srsFilter === "new") return !s;
if (srsFilter === "scheduled") return !!s;
if (srsFilter === "due") return !!s && s.due <= Date.now();
return true;
}
function sortCards(a, b) {
if (sortMode === "az") return a.infinitif.localeCompare(b.infinitif, "fr");
const ai = srsInfo(a);
const bi = srsInfo(b);
if (ai.timeLeft !== bi.timeLeft) return ai.timeLeft - bi.timeLeft;
return a.infinitif.localeCompare(b.infinitif, "fr");
}
function studyCard(key) {
localStorage.setItem(FORCE_KEY, key);
window.location.href = "flashcards.html";
}
function render() {
const q = query.trim().toLowerCase();
const filtered = cards
.filter((card) => matches(card, q) && matchesSrs(card))
.sort(sortCards);
countEl.textContent = String(cards.length);
metaEl.textContent = `${filtered.length} of ${cards.length} cards`;
if (!cards.length) {
listEl.innerHTML = `<div class="fc-explorer-empty">No cards loaded.</div>`;
return;
}
if (!filtered.length) {
listEl.innerHTML = `<div class="fc-explorer-empty">No cards match this filter.</div>`;
return;
}
listEl.innerHTML = filtered.map((card) => `
<article class="fc-explorer-card">
<div class="fc-explorer-side front">
<span class="fc-label">Front</span>
<strong>${escapeHtml(card.infinitif)}</strong>
<span class="fc-srs-pill ${srsInfo(card).state}">${escapeHtml(srsInfo(card).label)}</span>
<button class="fc-btn ghost fc-study-card" type="button" data-card-key="${escapeHtml(cardKey(card))}">Study this</button>
</div>
<div class="fc-explorer-side back">
<span class="fc-label">Back</span>
<strong>${escapeHtml(card.anglais)}</strong>
${conjTable(card)}
<div class="fc-example">
<code>${escapeHtml(card.exemple_fr)}</code>
<span class="fc-ex-en">${escapeHtml(card.exemple_en)}</span>
</div>
</div>
</article>
`).join("");
listEl.querySelectorAll("[data-card-key]").forEach((button) => {
button.onclick = () => studyCard(button.dataset.cardKey);
});
}
async function init() {
try {
loadSrs();
const SQL = await initSqlJs({
locateFile: (f) => `https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/${f}`,
});
const buf = await fetch("verbs.db").then((r) => {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.arrayBuffer();
});
const db = new SQL.Database(new Uint8Array(buf));
const res = db.exec("SELECT infinitif, anglais, exemple_fr, exemple_en FROM verbs ORDER BY infinitif;");
const rows = res[0]?.values || [];
cards = rows.map(([infinitif, anglais, exemple_fr, exemple_en]) => ({
infinitif, anglais, exemple_fr, exemple_en,
}));
const conj = {};
const cres = db.exec("SELECT infinitif, p1, p2, p3, p4, p5, p6 FROM conjugations WHERE tense='présent';");
if (cres[0]) {
for (const [infinitif, ...forms] of cres[0].values) conj[infinitif] = forms;
}
cards.forEach((card) => { card.present = conj[card.infinitif] || null; });
searchEl.oninput = () => {
query = searchEl.value;
render();
};
srsFilterEl.onchange = () => {
srsFilter = srsFilterEl.value;
render();
};
sortEl.onchange = () => {
sortMode = sortEl.value;
render();
};
render();
} catch (err) {
metaEl.textContent = "Could not load database.";
listEl.innerHTML = `<div class="fc-error">
<strong>Couldn't load verbs.db.</strong>
<p>Open this app through a local server:</p>
<code>python3 -m http.server 8000</code>
<p class="fc-err-detail">${escapeHtml(err.message)}</p>
</div>`;
}
}
init();