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