Add Docker Compose setup
This commit is contained in:
commit
91e67968ca
17 changed files with 2683 additions and 0 deletions
6
.dockerignore
Normal file
6
.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
Dockerfile
|
||||||
|
docker-compose*.yml
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log
|
||||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=8011
|
||||||
|
|
||||||
|
EXPOSE 8011
|
||||||
|
|
||||||
|
CMD ["node", "serve.js"]
|
||||||
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();
|
||||||
118
build_db.sql
Normal file
118
build_db.sql
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
-- Builds verbs.db : regular -er verbs (1st group) for flashcards.
|
||||||
|
-- Run: sqlite3 verbs.db < build_db.sql
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS verbs;
|
||||||
|
CREATE TABLE verbs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
infinitif TEXT NOT NULL UNIQUE,
|
||||||
|
anglais TEXT NOT NULL,
|
||||||
|
exemple_fr TEXT NOT NULL,
|
||||||
|
exemple_en TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO verbs (infinitif, anglais, exemple_fr, exemple_en) VALUES
|
||||||
|
('parler', 'to speak', 'Je parle français.', 'I speak French.'),
|
||||||
|
('manger', 'to eat', 'Nous mangeons une pizza.', 'We are eating a pizza.'),
|
||||||
|
('aimer', 'to like / to love', 'J''aime le café.', 'I like coffee.'),
|
||||||
|
('habiter', 'to live (reside)', 'Elle habite à Lyon.', 'She lives in Lyon.'),
|
||||||
|
('travailler', 'to work', 'Il travaille beaucoup.', 'He works a lot.'),
|
||||||
|
('regarder', 'to watch / look', 'Je regarde un film.', 'I am watching a film.'),
|
||||||
|
('écouter', 'to listen', 'Tu écoutes la radio ?', 'Are you listening to the radio?'),
|
||||||
|
('donner', 'to give', 'Donne-moi le livre.', 'Give me the book.'),
|
||||||
|
('trouver', 'to find', 'Je trouve la clé.', 'I find the key.'),
|
||||||
|
('penser', 'to think', 'Je pense que oui.', 'I think so.'),
|
||||||
|
('demander', 'to ask', 'Il demande l''heure.', 'He asks the time.'),
|
||||||
|
('rester', 'to stay', 'Nous restons à la maison.', 'We are staying home.'),
|
||||||
|
('passer', 'to pass / spend', 'Je passe le week-end ici.', 'I spend the weekend here.'),
|
||||||
|
('arriver', 'to arrive', 'Le train arrive à midi.', 'The train arrives at noon.'),
|
||||||
|
('entrer', 'to enter', 'Entrez, s''il vous plaît.', 'Come in, please.'),
|
||||||
|
('monter', 'to go up', 'Je monte l''escalier.', 'I go up the stairs.'),
|
||||||
|
('chercher', 'to look for', 'Je cherche mes clés.', 'I am looking for my keys.'),
|
||||||
|
('porter', 'to wear / carry', 'Elle porte une robe.', 'She is wearing a dress.'),
|
||||||
|
('jouer', 'to play', 'Les enfants jouent dehors.', 'The children play outside.'),
|
||||||
|
('chanter', 'to sing', 'Elle chante bien.', 'She sings well.'),
|
||||||
|
('danser', 'to dance', 'Nous dansons ensemble.', 'We are dancing together.'),
|
||||||
|
('marcher', 'to walk / work', 'Je marche au parc.', 'I walk in the park.'),
|
||||||
|
('acheter', 'to buy', 'J''achète du pain.', 'I am buying bread.'),
|
||||||
|
('coûter', 'to cost', 'Ça coûte dix euros.', 'It costs ten euros.'),
|
||||||
|
('payer', 'to pay', 'Je paie l''addition.', 'I am paying the bill.'),
|
||||||
|
('aider', 'to help', 'Il aide sa mère.', 'He helps his mother.'),
|
||||||
|
('montrer', 'to show', 'Montre-moi la photo.', 'Show me the photo.'),
|
||||||
|
('appeler', 'to call', 'J''appelle un taxi.', 'I am calling a taxi.'),
|
||||||
|
('commencer', 'to begin', 'Le film commence à huit heures.','The film begins at eight.'),
|
||||||
|
('continuer', 'to continue', 'Nous continuons demain.', 'We continue tomorrow.'),
|
||||||
|
('arrêter', 'to stop', 'Arrête de parler !', 'Stop talking!'),
|
||||||
|
('oublier', 'to forget', 'J''oublie souvent mon parapluie.','I often forget my umbrella.'),
|
||||||
|
('expliquer', 'to explain', 'Le prof explique la règle.', 'The teacher explains the rule.'),
|
||||||
|
('étudier', 'to study', 'J''étudie le français.', 'I study French.'),
|
||||||
|
('voyager', 'to travel', 'Ils voyagent en Italie.', 'They are travelling in Italy.'),
|
||||||
|
('visiter', 'to visit (place)', 'Nous visitons le musée.', 'We are visiting the museum.'),
|
||||||
|
('rentrer', 'to go back home', 'Je rentre à six heures.', 'I go home at six.'),
|
||||||
|
('tomber', 'to fall', 'Attention, tu vas tomber !', 'Careful, you''re going to fall!'),
|
||||||
|
('fermer', 'to close', 'Ferme la porte.', 'Close the door.'),
|
||||||
|
('gagner', 'to win / earn', 'Notre équipe gagne le match.', 'Our team wins the match.'),
|
||||||
|
('garder', 'to keep', 'Je garde le secret.', 'I keep the secret.'),
|
||||||
|
('laisser', 'to leave / let', 'Laisse-moi tranquille.', 'Leave me alone.'),
|
||||||
|
('apporter', 'to bring', 'J''apporte le dessert.', 'I am bringing the dessert.'),
|
||||||
|
('rencontrer', 'to meet', 'Je rencontre des amis.', 'I am meeting friends.'),
|
||||||
|
('téléphoner', 'to phone', 'Je téléphone à ma sœur.', 'I am phoning my sister.'),
|
||||||
|
('préparer', 'to prepare', 'Elle prépare le dîner.', 'She is preparing dinner.'),
|
||||||
|
('cuisiner', 'to cook', 'J''aime cuisiner le soir.', 'I like cooking in the evening.'),
|
||||||
|
('voler', 'to fly / steal', 'L''oiseau vole haut.', 'The bird flies high.'),
|
||||||
|
('changer', 'to change', 'Je change d''avis.', 'I am changing my mind.'),
|
||||||
|
('utiliser', 'to use', 'J''utilise mon téléphone.', 'I use my phone.'),
|
||||||
|
('accepter', 'to accept', 'J''accepte ton idée.', 'I accept your idea.'),
|
||||||
|
('accompagner','to accompany', 'Elle accompagne son frère.', 'She accompanies her brother.'),
|
||||||
|
('adorer', 'to adore / love', 'J''adore cette chanson.', 'I love this song.'),
|
||||||
|
('ajouter', 'to add', 'Tu ajoutes du sel.', 'You add salt.'),
|
||||||
|
('allumer', 'to turn on', 'J''allume la lumière.', 'I turn on the light.'),
|
||||||
|
('annuler', 'to cancel', 'Nous annulons le rendez-vous.', 'We cancel the appointment.'),
|
||||||
|
('avancer', 'to move forward', 'Nous avançons lentement.', 'We move forward slowly.'),
|
||||||
|
('bavarder', 'to chat', 'Ils bavardent après le cours.', 'They chat after class.'),
|
||||||
|
('brosser', 'to brush', 'Je brosse mes dents.', 'I brush my teeth.'),
|
||||||
|
('cacher', 'to hide', 'Elle cache le cadeau.', 'She hides the gift.'),
|
||||||
|
('casser', 'to break', 'Tu casses le verre.', 'You break the glass.'),
|
||||||
|
('commander', 'to order', 'Nous commandons un café.', 'We order a coffee.'),
|
||||||
|
('compter', 'to count / intend', 'Je compte jusqu''à dix.', 'I count to ten.'),
|
||||||
|
('conseiller', 'to advise', 'Elle conseille ses amis.', 'She advises her friends.'),
|
||||||
|
('couper', 'to cut', 'Je coupe le pain.', 'I cut the bread.'),
|
||||||
|
('crier', 'to shout', 'Il crie trop fort.', 'He shouts too loudly.'),
|
||||||
|
('décider', 'to decide', 'Nous décidons ce soir.', 'We decide tonight.'),
|
||||||
|
('déjeuner', 'to have lunch', 'Je déjeune à midi.', 'I have lunch at noon.'),
|
||||||
|
('dessiner', 'to draw', 'Elle dessine un arbre.', 'She draws a tree.'),
|
||||||
|
('détester', 'to hate', 'Je déteste attendre.', 'I hate waiting.'),
|
||||||
|
('dîner', 'to have dinner', 'Nous dînons au restaurant.', 'We have dinner at the restaurant.'),
|
||||||
|
('discuter', 'to discuss', 'On discute du projet.', 'We discuss the project.'),
|
||||||
|
('durer', 'to last', 'Le cours dure une heure.', 'The class lasts one hour.'),
|
||||||
|
('emprunter', 'to borrow', 'J''emprunte un stylo.', 'I borrow a pen.'),
|
||||||
|
('enseigner', 'to teach', 'Il enseigne le français.', 'He teaches French.'),
|
||||||
|
('éviter', 'to avoid', 'J''évite les erreurs.', 'I avoid mistakes.'),
|
||||||
|
('fêter', 'to celebrate', 'Nous fêtons son anniversaire.', 'We celebrate her birthday.'),
|
||||||
|
('filmer', 'to film', 'Elle filme la scène.', 'She films the scene.'),
|
||||||
|
('goûter', 'to taste / snack', 'Je goûte le fromage.', 'I taste the cheese.'),
|
||||||
|
('grimper', 'to climb', 'Les enfants grimpent aux arbres.','The children climb trees.'),
|
||||||
|
('inviter', 'to invite', 'J''invite mes voisins.', 'I invite my neighbors.'),
|
||||||
|
('laver', 'to wash', 'Tu laves la voiture.', 'You wash the car.'),
|
||||||
|
('louer', 'to rent', 'Nous louons un appartement.', 'We rent an apartment.'),
|
||||||
|
('manquer', 'to miss / lack', 'Tu me manques.', 'I miss you.'),
|
||||||
|
('mesurer', 'to measure', 'Je mesure la table.', 'I measure the table.'),
|
||||||
|
('nettoyer', 'to clean', 'Je nettoie la cuisine.', 'I clean the kitchen.'),
|
||||||
|
('organiser', 'to organize', 'Elle organise une fête.', 'She organizes a party.'),
|
||||||
|
('partager', 'to share', 'Nous partageons le gâteau.', 'We share the cake.'),
|
||||||
|
('peigner', 'to comb', 'Elle peigne ses cheveux.', 'She combs her hair.'),
|
||||||
|
('pleurer', 'to cry', 'Le bébé pleure.', 'The baby cries.'),
|
||||||
|
('prêter', 'to lend', 'Je prête mon vélo.', 'I lend my bike.'),
|
||||||
|
('proposer', 'to suggest', 'Il propose une solution.', 'He suggests a solution.'),
|
||||||
|
('raconter', 'to tell', 'Elle raconte une histoire.', 'She tells a story.'),
|
||||||
|
('réparer', 'to repair', 'Je répare mon ordinateur.', 'I repair my computer.'),
|
||||||
|
('répéter', 'to repeat', 'Tu répètes la phrase.', 'You repeat the sentence.'),
|
||||||
|
('réserver', 'to reserve', 'Nous réservons une table.', 'We reserve a table.'),
|
||||||
|
('rêver', 'to dream', 'Je rêve de voyager.', 'I dream of traveling.'),
|
||||||
|
('saluer', 'to greet', 'Il salue le professeur.', 'He greets the teacher.'),
|
||||||
|
('sauter', 'to jump', 'Le chat saute sur la chaise.', 'The cat jumps onto the chair.'),
|
||||||
|
('signer', 'to sign', 'Je signe le document.', 'I sign the document.'),
|
||||||
|
('souhaiter', 'to wish', 'Je souhaite réussir.', 'I wish to succeed.'),
|
||||||
|
('terminer', 'to finish', 'Nous terminons le travail.', 'We finish the work.'),
|
||||||
|
('tirer', 'to pull', 'Il tire la porte.', 'He pulls the door.'),
|
||||||
|
('toucher', 'to touch', 'Ne touche pas ça.', 'Do not touch that.'),
|
||||||
|
('trier', 'to sort', 'Je trie mes papiers.', 'I sort my papers.');
|
||||||
70
conjugate.js
Normal file
70
conjugate.js
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
// Generates the present-tense conjugation for every -er verb in verbs.db
|
||||||
|
// and stores it in a `conjugations` table. Run: node conjugate.js
|
||||||
|
//
|
||||||
|
// Regular -er endings are computed; spelling-change verbs are overridden
|
||||||
|
// so the data stays accurate.
|
||||||
|
|
||||||
|
const { execFileSync } = require("child_process");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const DB = path.join(__dirname, "verbs.db");
|
||||||
|
|
||||||
|
// Verbs whose present tense is NOT the plain regular pattern.
|
||||||
|
const OVERRIDES = {
|
||||||
|
manger: ["mange", "manges", "mange", "mangeons", "mangez", "mangent"],
|
||||||
|
voyager: ["voyage", "voyages", "voyage", "voyageons", "voyagez", "voyagent"],
|
||||||
|
changer: ["change", "changes", "change", "changeons", "changez", "changent"],
|
||||||
|
commencer: ["commence", "commences", "commence", "commençons", "commencez", "commencent"],
|
||||||
|
acheter: ["achète", "achètes", "achète", "achetons", "achetez", "achètent"],
|
||||||
|
appeler: ["appelle", "appelles", "appelle", "appelons", "appelez", "appellent"],
|
||||||
|
payer: ["paie", "paies", "paie", "payons", "payez", "paient"],
|
||||||
|
nettoyer: ["nettoie", "nettoies", "nettoie", "nettoyons", "nettoyez", "nettoient"],
|
||||||
|
répéter: ["répète", "répètes", "répète", "répétons", "répétez", "répètent"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function regular(inf) {
|
||||||
|
const stem = inf.slice(0, -2); // drop "er"
|
||||||
|
const nousStem = inf.endsWith("ger")
|
||||||
|
? stem + "e"
|
||||||
|
: inf.endsWith("cer")
|
||||||
|
? stem.slice(0, -1) + "ç"
|
||||||
|
: stem;
|
||||||
|
return [stem + "e", stem + "es", stem + "e", nousStem + "ons", stem + "ez", stem + "ent"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function present(inf) {
|
||||||
|
return OVERRIDES[inf] || regular(inf);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInfinitives() {
|
||||||
|
const out = execFileSync("sqlite3", [DB, "SELECT infinitif FROM verbs ORDER BY infinitif;"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
return out.split("\n").map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return s.replace(/'/g, "''");
|
||||||
|
}
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
const verbs = getInfinitives();
|
||||||
|
let sql = `
|
||||||
|
DROP TABLE IF EXISTS conjugations;
|
||||||
|
CREATE TABLE conjugations (
|
||||||
|
infinitif TEXT NOT NULL,
|
||||||
|
tense TEXT NOT NULL,
|
||||||
|
p1 TEXT, p2 TEXT, p3 TEXT, p4 TEXT, p5 TEXT, p6 TEXT,
|
||||||
|
PRIMARY KEY (infinitif, tense)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
for (const inf of verbs) {
|
||||||
|
const f = present(inf).map(esc);
|
||||||
|
sql += `INSERT INTO conjugations VALUES ('${esc(inf)}','présent','${f[0]}','${f[1]}','${f[2]}','${f[3]}','${f[4]}','${f[5]}');\n`;
|
||||||
|
}
|
||||||
|
execFileSync("sqlite3", [DB], { input: sql });
|
||||||
|
const n = execFileSync("sqlite3", [DB, "SELECT COUNT(*) FROM conjugations;"], { encoding: "utf8" }).trim();
|
||||||
|
console.log(`conjugations written: ${n} rows`);
|
||||||
|
}
|
||||||
|
|
||||||
|
build();
|
||||||
518
data.js
Normal file
518
data.js
Normal file
|
|
@ -0,0 +1,518 @@
|
||||||
|
// French grammar structure, A1 to C2 (CEFR)
|
||||||
|
// For English speakers learning French.
|
||||||
|
// Explanations are in English; the French is the target language to learn.
|
||||||
|
|
||||||
|
const GRAMMAIRE = [
|
||||||
|
{
|
||||||
|
niveau: "A1",
|
||||||
|
titre: "Beginner",
|
||||||
|
description: "The basics: present tense, articles, gender & number, simple questions.",
|
||||||
|
chapitres: [
|
||||||
|
{
|
||||||
|
titre: "Subject pronouns (les pronoms personnels sujets)",
|
||||||
|
points: [
|
||||||
|
"The people doing the action: je (I), tu (you, informal), il / elle / on (he / she / one), nous (we), vous (you, formal or plural), ils / elles (they).",
|
||||||
|
"« on » very often replaces « nous » (we) in everyday speech.",
|
||||||
|
"« vous » is also the polite way to say 'you' to one person."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Je parle français.", en: "I speak French." },
|
||||||
|
{ fr: "On va au cinéma.", en: "We're going to the cinema." },
|
||||||
|
{ fr: "Vous êtes prêt ?", en: "Are you ready? (polite)" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "The verbs « être » (to be) and « avoir » (to have)",
|
||||||
|
points: [
|
||||||
|
"être (to be): je suis, tu es, il est, nous sommes, vous êtes, ils sont.",
|
||||||
|
"avoir (to have): j'ai, tu as, il a, nous avons, vous avez, ils ont.",
|
||||||
|
"These two are the most important verbs, used everywhere, including to build past tenses."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Je suis étudiant.", en: "I am a student." },
|
||||||
|
{ fr: "Elle a vingt ans.", en: "She is twenty (lit. 'has twenty years')." },
|
||||||
|
{ fr: "Nous avons faim.", en: "We are hungry (lit. 'have hunger')." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Definite & indefinite articles (les articles)",
|
||||||
|
points: [
|
||||||
|
"Definite ('the'): le (masc.), la (fem.), l' (before a vowel), les (plural).",
|
||||||
|
"Indefinite ('a / some'): un (masc.), une (fem.), des (plural).",
|
||||||
|
"The noun's gender decides which article you use, learn the article with the noun."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "le livre / la table", en: "the book / the table" },
|
||||||
|
{ fr: "un homme / une femme", en: "a man / a woman" },
|
||||||
|
{ fr: "des enfants", en: "some children" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Present tense of -er verbs (le présent)",
|
||||||
|
points: [
|
||||||
|
"The biggest, most regular verb group. Drop -er and add: -e, -es, -e, -ons, -ez, -ent.",
|
||||||
|
"Example: parler (to speak) → je parle, tu parles, il parle, nous parlons, vous parlez, ils parlent.",
|
||||||
|
"Most new verbs you meet follow this pattern."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Je travaille à Paris.", en: "I work in Paris." },
|
||||||
|
{ fr: "Ils habitent ici.", en: "They live here." },
|
||||||
|
{ fr: "Nous mangeons.", en: "We are eating." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Gender & number of nouns (le genre et le nombre)",
|
||||||
|
points: [
|
||||||
|
"Every noun is masculine or feminine; the feminine often (not always) ends in -e.",
|
||||||
|
"Plural is usually formed by adding -s (sometimes -x); the -s is silent.",
|
||||||
|
"Adjectives must agree with the noun in gender and number."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "un ami / une amie", en: "a (male) friend / a (female) friend" },
|
||||||
|
{ fr: "un chat / des chats", en: "a cat / some cats" },
|
||||||
|
{ fr: "un journal / des journaux", en: "a newspaper / some newspapers" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Negation (« ne... pas »)",
|
||||||
|
points: [
|
||||||
|
"Wrap the verb: ne + verb + pas.",
|
||||||
|
"In casual speech the « ne » is often dropped: « Je sais pas ».",
|
||||||
|
"Before a vowel, ne becomes n'."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Je ne comprends pas.", en: "I don't understand." },
|
||||||
|
{ fr: "Il n'aime pas le café.", en: "He doesn't like coffee." },
|
||||||
|
{ fr: "(spoken) Je sais pas.", en: "I dunno." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Simple questions (les questions)",
|
||||||
|
points: [
|
||||||
|
"Intonation: just raise your voice, « Tu viens ? » (Are you coming?).",
|
||||||
|
"Est-ce que: put it in front of a statement, « Est-ce que tu viens ? ».",
|
||||||
|
"Question words: qui (who), quoi (what), où (where), quand (when), comment (how), combien (how much/many)."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Où habites-tu ?", en: "Where do you live?" },
|
||||||
|
{ fr: "Est-ce que c'est loin ?", en: "Is it far?" },
|
||||||
|
{ fr: "Comment ça va ?", en: "How are you?" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
niveau: "A2",
|
||||||
|
titre: "Elementary",
|
||||||
|
description: "Talking about the past and near future, adjectives, object pronouns.",
|
||||||
|
chapitres: [
|
||||||
|
{
|
||||||
|
titre: "The passé composé (completed past actions)",
|
||||||
|
points: [
|
||||||
|
"Built with an auxiliary (avoir or être) in the present + a past participle.",
|
||||||
|
"Most verbs use avoir: « J'ai mangé » (I ate / I have eaten).",
|
||||||
|
"Movement verbs and reflexive verbs use être, and the participle then agrees with the subject."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "J'ai mangé.", en: "I ate / I have eaten." },
|
||||||
|
{ fr: "Elle est partie.", en: "She left (fem. agreement: parti+e)." },
|
||||||
|
{ fr: "Nous sommes arrivés tard.", en: "We arrived late." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "The imparfait (ongoing / habitual past)",
|
||||||
|
points: [
|
||||||
|
"Used for descriptions, habits, and actions in progress in the past.",
|
||||||
|
"Take the 'nous' present-tense stem and add: -ais, -ais, -ait, -ions, -iez, -aient.",
|
||||||
|
"Contrast with passé composé: imparfait = background, passé composé = single completed event."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Quand j'étais petit...", en: "When I was little..." },
|
||||||
|
{ fr: "Il pleuvait.", en: "It was raining." },
|
||||||
|
{ fr: "Nous allions souvent à la mer.", en: "We used to go to the sea often." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "The near future (le futur proche)",
|
||||||
|
points: [
|
||||||
|
"aller (to go) in the present + an infinitive, just like English 'going to'.",
|
||||||
|
"Used for plans and things about to happen.",
|
||||||
|
"Very common in speech, easier than the simple future."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Je vais partir.", en: "I'm going to leave." },
|
||||||
|
{ fr: "On va manger.", en: "We're going to eat." },
|
||||||
|
{ fr: "Ils vont arriver bientôt.", en: "They're going to arrive soon." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Adjectives (placement & agreement)",
|
||||||
|
points: [
|
||||||
|
"Adjectives agree in gender and number with their noun.",
|
||||||
|
"Most adjectives go after the noun (unlike English).",
|
||||||
|
"A few common ones go before: grand, petit, beau, bon, jeune, vieux, nouveau."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "une voiture rouge", en: "a red car" },
|
||||||
|
{ fr: "un grand jardin", en: "a big garden" },
|
||||||
|
{ fr: "de belles fleurs", en: "beautiful flowers" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Direct & indirect object pronouns",
|
||||||
|
points: [
|
||||||
|
"Direct (COD): me, te, le/la, nous, vous, les, replace 'the thing/person'.",
|
||||||
|
"Indirect (COI): me, te, lui, nous, vous, leur, replace 'to someone'.",
|
||||||
|
"They go before the verb, unlike in English."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Je le vois.", en: "I see him/it." },
|
||||||
|
{ fr: "Elle nous parle.", en: "She is talking to us." },
|
||||||
|
{ fr: "Tu lui donnes le livre ?", en: "Are you giving him/her the book?" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Possessive & demonstrative adjectives",
|
||||||
|
points: [
|
||||||
|
"Possessives ('my, your...'): mon, ma, mes, ton, ta, tes, son, sa, ses, notre, votre, leur.",
|
||||||
|
"Demonstratives ('this/that'): ce, cet (before vowel), cette, ces.",
|
||||||
|
"They agree with the thing owned/pointed at, NOT the owner."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "mon frère, ma sœur", en: "my brother, my sister" },
|
||||||
|
{ fr: "ce livre, cette idée", en: "this book, this idea" },
|
||||||
|
{ fr: "leurs enfants", en: "their children" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
niveau: "B1",
|
||||||
|
titre: "Intermediate",
|
||||||
|
description: "Simple future, conditional, relative pronouns, introducing the subjunctive.",
|
||||||
|
chapitres: [
|
||||||
|
{
|
||||||
|
titre: "The simple future (le futur simple)",
|
||||||
|
points: [
|
||||||
|
"Add endings to the infinitive: -ai, -as, -a, -ons, -ez, -ont.",
|
||||||
|
"Some stems are irregular: être → ser-, avoir → aur-, aller → ir-, faire → fer-.",
|
||||||
|
"Used for certain or planned future events."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Je partirai demain.", en: "I will leave tomorrow." },
|
||||||
|
{ fr: "Nous serons là.", en: "We will be there." },
|
||||||
|
{ fr: "Il fera beau.", en: "The weather will be nice." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "The present conditional (le conditionnel)",
|
||||||
|
points: [
|
||||||
|
"Future stem + imparfait endings (-ais, -ais, -ait, -ions, -iez, -aient).",
|
||||||
|
"Used for politeness, wishes, and hypotheticals, like English 'would'.",
|
||||||
|
"Key polite forms: je voudrais (I would like), j'aimerais (I'd love)."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Je voudrais un café.", en: "I would like a coffee." },
|
||||||
|
{ fr: "On pourrait sortir.", en: "We could go out." },
|
||||||
|
{ fr: "Ce serait bien.", en: "That would be nice." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Relative pronouns (qui, que, où, dont)",
|
||||||
|
points: [
|
||||||
|
"qui = subject ('who/which'), que = object ('whom/that'), où = place/time ('where/when').",
|
||||||
|
"dont replaces a 'de' phrase ('whose / of which / about which').",
|
||||||
|
"They join two clauses into one sentence."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "L'homme qui parle.", en: "The man who is speaking." },
|
||||||
|
{ fr: "Le livre que je lis.", en: "The book (that) I'm reading." },
|
||||||
|
{ fr: "Le film dont je parle.", en: "The film I'm talking about." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Conditionals with « si » (if)",
|
||||||
|
points: [
|
||||||
|
"si + present → future: « Si j'ai le temps, je viendrai » (If I have time, I'll come).",
|
||||||
|
"si + imparfait → conditional: « Si j'avais le temps, je viendrais » (If I had time, I would come).",
|
||||||
|
"Never use the future or conditional directly after « si »."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Si tu veux, on y va.", en: "If you want, we'll go." },
|
||||||
|
{ fr: "Si j'étais riche, je voyagerais.", en: "If I were rich, I would travel." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "The present subjunctive (introduction)",
|
||||||
|
points: [
|
||||||
|
"Used after expressions of necessity, will, or emotion: « il faut que », « je veux que », « pour que ».",
|
||||||
|
"Form: take the 'ils' stem and add -e, -es, -e, -ions, -iez, -ent.",
|
||||||
|
"It expresses something wished-for or uncertain, not a plain fact."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Il faut que tu partes.", en: "You have to leave." },
|
||||||
|
{ fr: "Je veux que tu viennes.", en: "I want you to come." },
|
||||||
|
{ fr: "Pour que ce soit clair.", en: "So that it's clear." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Comparatives & superlatives",
|
||||||
|
points: [
|
||||||
|
"Comparative: plus / moins / aussi ... que (more / less / as ... than/as).",
|
||||||
|
"Superlative: le / la / les plus (moins) ... (the most / least).",
|
||||||
|
"Irregular: bon → meilleur (better), bien → mieux (better, adverb), mauvais → pire (worse)."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "plus grand que toi", en: "taller than you" },
|
||||||
|
{ fr: "le plus intéressant", en: "the most interesting" },
|
||||||
|
{ fr: "C'est meilleur ici.", en: "It's better here." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
niveau: "B2",
|
||||||
|
titre: "Upper intermediate",
|
||||||
|
description: "Subjunctive in depth, reported speech, passive voice, finer past tenses.",
|
||||||
|
chapitres: [
|
||||||
|
{
|
||||||
|
titre: "The subjunctive (extended uses)",
|
||||||
|
points: [
|
||||||
|
"Also used after doubt, emotion, and negative opinions.",
|
||||||
|
"After certain conjunctions: bien que (although), avant que (before), à condition que (provided that).",
|
||||||
|
"Indicative vs subjunctive depends on certainty: « Je pense que c'est vrai » (sure) vs « Je ne pense pas que ce soit vrai » (doubt)."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Bien qu'il pleuve, on sort.", en: "Although it's raining, we're going out." },
|
||||||
|
{ fr: "Je doute qu'il vienne.", en: "I doubt he'll come." },
|
||||||
|
{ fr: "Je ne pense pas que ce soit vrai.", en: "I don't think it's true." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Reported speech (le discours indirect)",
|
||||||
|
points: [
|
||||||
|
"Reporting what someone said, without quotation marks.",
|
||||||
|
"Tenses shift back (present → imparfait, passé composé → plus-que-parfait, future → conditional).",
|
||||||
|
"Pronouns and time words also change."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Il dit qu'il est fatigué.", en: "He says (that) he's tired." },
|
||||||
|
{ fr: "Elle a dit qu'elle viendrait.", en: "She said she would come." },
|
||||||
|
{ fr: "Il a demandé si j'étais prêt.", en: "He asked if I was ready." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "The passive voice (la voix passive)",
|
||||||
|
points: [
|
||||||
|
"être + past participle (agrees) + par (by).",
|
||||||
|
"Puts the focus on the thing receiving the action.",
|
||||||
|
"The doer (agent) is introduced by « par »."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "La lettre est écrite par Marie.", en: "The letter is written by Marie." },
|
||||||
|
{ fr: "Le pont a été construit en 1990.", en: "The bridge was built in 1990." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "The pluperfect (le plus-que-parfait)",
|
||||||
|
points: [
|
||||||
|
"Auxiliary in the imparfait + past participle.",
|
||||||
|
"An action that happened before another past action ('had done').",
|
||||||
|
"Often paired with passé composé or imparfait."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "J'avais déjà mangé quand il est arrivé.", en: "I had already eaten when he arrived." },
|
||||||
|
{ fr: "Elle était partie avant midi.", en: "She had left before noon." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Compound relative pronouns",
|
||||||
|
points: [
|
||||||
|
"lequel, laquelle, lesquels, lesquelles, used after a preposition.",
|
||||||
|
"They contract: à + lequel → auquel, de + lequel → duquel.",
|
||||||
|
"Used when the relative pronoun follows a preposition (on which, to whom...)."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "La table sur laquelle je travaille.", en: "The table on which I work." },
|
||||||
|
{ fr: "L'ami auquel je pense.", en: "The friend I'm thinking of." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Gerund & present participle",
|
||||||
|
points: [
|
||||||
|
"Gerund: en + present participle, 'while/by doing' (simultaneous action or means).",
|
||||||
|
"Present participle (-ant) can express cause or a quality, like 'being'.",
|
||||||
|
"Examples: en marchant (while walking), étant fatigué (being tired)."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Il chante en travaillant.", en: "He sings while working." },
|
||||||
|
{ fr: "Étant malade, il est resté.", en: "Being sick, he stayed home." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
niveau: "C1",
|
||||||
|
titre: "Advanced",
|
||||||
|
description: "Stylistic nuance, full tense agreement, structuring sophisticated discourse.",
|
||||||
|
chapitres: [
|
||||||
|
{
|
||||||
|
titre: "The past conditional (le conditionnel passé)",
|
||||||
|
points: [
|
||||||
|
"Auxiliary in the conditional + past participle ('would have done').",
|
||||||
|
"Expresses regret, reproach, or an unrealised hypothesis.",
|
||||||
|
"Pairs with « si + plus-que-parfait »."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "J'aurais aimé venir.", en: "I would have liked to come." },
|
||||||
|
{ fr: "Si j'avais su, je serais resté.", en: "If I had known, I would have stayed." },
|
||||||
|
{ fr: "Tu aurais dû me prévenir.", en: "You should have warned me." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "The past subjunctive (le subjonctif passé)",
|
||||||
|
points: [
|
||||||
|
"Subjunctive auxiliary + past participle.",
|
||||||
|
"Marks an action completed before the main verb, still inside a subjunctive clause.",
|
||||||
|
"Examples: que j'aie fait, qu'il soit venu."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Je suis content que tu sois venu.", en: "I'm glad you came." },
|
||||||
|
{ fr: "Bien qu'il ait fini, il reste.", en: "Although he has finished, he's staying." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Logical connectors (les connecteurs logiques)",
|
||||||
|
points: [
|
||||||
|
"Cause: car, puisque, étant donné que (since / given that).",
|
||||||
|
"Consequence: si bien que, de sorte que (so that / with the result that).",
|
||||||
|
"Contrast/concession: néanmoins, toutefois, en revanche (nevertheless, however, on the other hand)."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Puisque tu insistes...", en: "Since you insist..." },
|
||||||
|
{ fr: "Il pleut, néanmoins nous sortons.", en: "It's raining; nevertheless we're going out." },
|
||||||
|
{ fr: "..., si bien qu'il a réussi.", en: "..., so that he succeeded." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Emphasis & highlighting (la mise en relief)",
|
||||||
|
points: [
|
||||||
|
"C'est ... qui / que to stress a word: « C'est toi qui décides ».",
|
||||||
|
"Ce qui / ce que ..., c'est ... structure.",
|
||||||
|
"Dislocation (very common in speech): « Moi, je pense que... »."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "C'est toi qui décides.", en: "You're the one who decides." },
|
||||||
|
{ fr: "Ce que je veux, c'est partir.", en: "What I want is to leave." },
|
||||||
|
{ fr: "Lui, il ne sait rien.", en: "Him? He knows nothing." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Nominalisation (la nominalisation)",
|
||||||
|
points: [
|
||||||
|
"Turning a verb or adjective into a noun.",
|
||||||
|
"Typical of formal, written, and journalistic style.",
|
||||||
|
"Example: construire (to build) → la construction (the construction)."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "L'augmentation des prix.", en: "The rise in prices." },
|
||||||
|
{ fr: "La fermeture du magasin.", en: "The closing of the shop." },
|
||||||
|
{ fr: "Le développement durable.", en: "Sustainable development." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Registers of language (les registres)",
|
||||||
|
points: [
|
||||||
|
"Three main levels: familier (casual), courant (standard), soutenu (formal).",
|
||||||
|
"They differ in vocabulary and sentence structure.",
|
||||||
|
"Match the register to your context and listener."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "bagnole / voiture / automobile", en: "car (casual / standard / formal)" },
|
||||||
|
{ fr: "bouquin / livre / ouvrage", en: "book (casual / standard / formal)" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
niveau: "C2",
|
||||||
|
titre: "Mastery",
|
||||||
|
description: "Literary tenses, stylistic subtleties, idiomatic and near-native expression.",
|
||||||
|
chapitres: [
|
||||||
|
{
|
||||||
|
titre: "The passé simple (literary past)",
|
||||||
|
points: [
|
||||||
|
"The tense of written narration: novels, history, fairy tales.",
|
||||||
|
"A single completed action, used in writing instead of the passé composé.",
|
||||||
|
"Examples: il fut (he was), il alla (he went), ils firent (they did)."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Il entra et s'assit.", en: "He entered and sat down." },
|
||||||
|
{ fr: "Ils partirent à l'aube.", en: "They left at dawn." },
|
||||||
|
{ fr: "Elle naquit en 1850.", en: "She was born in 1850." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Past anterior & imperfect subjunctive",
|
||||||
|
points: [
|
||||||
|
"Literary tenses, almost never used in speech.",
|
||||||
|
"Past anterior: action just before a passé simple in a narrative.",
|
||||||
|
"Imperfect subjunctive: tense agreement in very formal writing."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Quand il eut fini, il sortit.", en: "When he had finished, he went out." },
|
||||||
|
{ fr: "Il fallait qu'il vînt.", en: "It was necessary for him to come." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Subtleties of the subjunctive",
|
||||||
|
points: [
|
||||||
|
"Subjunctive vs indicative can change the meaning of a sentence.",
|
||||||
|
"Used after a superlative or « le seul », « le premier » (the only/first).",
|
||||||
|
"Used in relative clauses expressing a goal or something sought but uncertain."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "C'est le seul qui puisse le faire.", en: "He's the only one who can do it." },
|
||||||
|
{ fr: "Je cherche un livre qui soit simple.", en: "I'm looking for a book that is simple (if one exists)." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Idioms & fixed expressions",
|
||||||
|
points: [
|
||||||
|
"Set phrases, proverbs, and figurative expressions.",
|
||||||
|
"Their meaning is not literal, you must learn them whole.",
|
||||||
|
"Using them naturally is a hallmark of native-level fluency."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Tomber dans les pommes.", en: "To faint (lit. 'fall into the apples')." },
|
||||||
|
{ fr: "Avoir le cafard.", en: "To feel down/blue (lit. 'have the cockroach')." },
|
||||||
|
{ fr: "Mettre la charrue avant les bœufs.", en: "To put the cart before the horse." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Full sequence of tenses",
|
||||||
|
points: [
|
||||||
|
"Command of every temporal relationship across a long text.",
|
||||||
|
"Reported speech in elevated, literary style.",
|
||||||
|
"Keeping tenses consistent throughout extended writing."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Il affirma qu'il fût venu si on l'eût invité.", en: "He claimed he would have come had he been invited." },
|
||||||
|
{ fr: "Elle craignait qu'il ne fût trop tard.", en: "She feared it was too late." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: "Style & rhetoric (le style)",
|
||||||
|
points: [
|
||||||
|
"Figures of speech: metaphor, litotes (understatement), irony.",
|
||||||
|
"Varying rhythm and sentence structure for effect.",
|
||||||
|
"Devices of emphasis and nuance."
|
||||||
|
],
|
||||||
|
exemples: [
|
||||||
|
{ fr: "Ce n'est pas mauvais.", en: "It's not bad (= it's quite good, litotes)." },
|
||||||
|
{ fr: "Une mer de visages.", en: "A sea of faces (metaphor)." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
services:
|
||||||
|
french-journey:
|
||||||
|
build: .
|
||||||
|
container_name: french-journey
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8011}:8011"
|
||||||
|
environment:
|
||||||
|
PORT: 8011
|
||||||
72
explorer.html
Normal file
72
explorer.html
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Explorer · French Journey</title>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">French Journey <span>·</span> Explorer</div>
|
||||||
|
<nav class="topnav">
|
||||||
|
<a href="index.html">Grammar</a>
|
||||||
|
<a href="flashcards.html">Flashcards</a>
|
||||||
|
<a href="explorer.html" class="active">Explorer</a>
|
||||||
|
</nav>
|
||||||
|
<button class="theme-toggle" type="button" data-theme-toggle aria-pressed="false">Dark</button>
|
||||||
|
<div class="topbar-meta">Database · front/back cards</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<nav class="nav">
|
||||||
|
<div class="level-group">
|
||||||
|
<div class="level-label">
|
||||||
|
<span>Database</span>
|
||||||
|
<span class="lvl-count" id="explorer-count">0</span>
|
||||||
|
</div>
|
||||||
|
<a href="#" class="active">
|
||||||
|
<span class="dot on"></span>
|
||||||
|
<span>-er verbs</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="content explorer-content">
|
||||||
|
<div class="level-header">
|
||||||
|
<span class="badge">Explorer</span>
|
||||||
|
<h1>Card database</h1>
|
||||||
|
<p>Browse every flashcard, see the front and back, and filter by word, meaning, or example sentence.</p>
|
||||||
|
<input type="text" id="explorer-search" class="search explorer-main-search" placeholder="Filter examples…" />
|
||||||
|
<div class="explorer-controls">
|
||||||
|
<label>
|
||||||
|
SRS
|
||||||
|
<select id="explorer-srs-filter">
|
||||||
|
<option value="all">All cards</option>
|
||||||
|
<option value="due">Due now</option>
|
||||||
|
<option value="new">New only</option>
|
||||||
|
<option value="scheduled">Scheduled only</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Sort
|
||||||
|
<select id="explorer-sort">
|
||||||
|
<option value="srs">Least time left</option>
|
||||||
|
<option value="az">A → Z</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fc-explorer-meta" id="explorer-meta">Loading cards…</div>
|
||||||
|
<div class="fc-explorer-list" id="explorer-list"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="theme.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/sql-wasm.js"></script>
|
||||||
|
<script src="explorer.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
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();
|
||||||
66
flashcards.html
Normal file
66
flashcards.html
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Flashcards · Lists · French Journey</title>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">French Journey <span>·</span> Flashcards</div>
|
||||||
|
<nav class="topnav">
|
||||||
|
<a href="index.html">Grammar</a>
|
||||||
|
<a href="flashcards.html" class="active">Flashcards</a>
|
||||||
|
<a href="explorer.html">Explorer</a>
|
||||||
|
</nav>
|
||||||
|
<button class="theme-toggle" type="button" data-theme-toggle aria-pressed="false">Dark</button>
|
||||||
|
<div class="topbar-meta">Lists · spaced repetition</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="fc-shell" id="app">
|
||||||
|
<aside class="fc-sidebar">
|
||||||
|
<div class="fc-list-head">
|
||||||
|
<div>
|
||||||
|
<span class="fc-section-label">Flashcards</span>
|
||||||
|
<p>Make a title now; cards can be added later.</p>
|
||||||
|
</div>
|
||||||
|
<button id="add-list" class="fc-btn ghost">Add list</button>
|
||||||
|
</div>
|
||||||
|
<nav class="fc-list-grid" id="list-grid"></nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="fc-wrap">
|
||||||
|
<div class="fc-head">
|
||||||
|
<h1>Flashcard lists</h1>
|
||||||
|
<p>Select a list to study, or use Today for cards due from every list. Your lists and schedule are saved locally.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="fc-today" id="today-panel"></section>
|
||||||
|
|
||||||
|
<div class="fc-counts" id="counts"></div>
|
||||||
|
|
||||||
|
<div class="fc-card" id="card">
|
||||||
|
<div class="fc-loading">Loading verbs…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- shown on the front -->
|
||||||
|
<div class="fc-controls" id="show-row" hidden>
|
||||||
|
<button id="show" class="fc-btn primary wide">Show answer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- shown on the back: Anki-style grades -->
|
||||||
|
<div class="fc-controls fc-grades" id="grade-row" hidden></div>
|
||||||
|
|
||||||
|
<div class="fc-tools">
|
||||||
|
<button id="undo" class="fc-btn ghost" disabled>Back to previous card</button>
|
||||||
|
<button id="reset" class="fc-btn ghost">Reset progress</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="theme.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/sql-wasm.js"></script>
|
||||||
|
<script src="flashcards.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
566
flashcards.js
Normal file
566
flashcards.js
Normal file
|
|
@ -0,0 +1,566 @@
|
||||||
|
// Anki-style spaced repetition (SM-2) over flashcard lists.
|
||||||
|
// List metadata and schedules live in localStorage; the -er verb cards come from verbs.db.
|
||||||
|
|
||||||
|
const SRS_KEY = "frenchJourney.srs";
|
||||||
|
const LISTS_KEY = "frenchJourney.flashcardLists";
|
||||||
|
const FORCE_KEY = "frenchJourney.forceCard";
|
||||||
|
const DEFAULT_LIST_ID = "er-verbs";
|
||||||
|
const TODAY_ID = "today";
|
||||||
|
|
||||||
|
// --- Scheduler constants (Anki defaults) ---
|
||||||
|
const MIN = 60000, DAY = 86400000;
|
||||||
|
const LEARN_STEPS = [1, 10]; // minutes (new cards)
|
||||||
|
const RELEARN_STEPS = [10]; // minutes (lapsed cards)
|
||||||
|
const HARD_LEARN_DELAY = 6; // minutes; keeps Hard distinct from Again on new cards
|
||||||
|
const GRAD_INT = 1; // days, graduating interval (Good)
|
||||||
|
const EASY_INT = 4; // days, graduating interval (Easy)
|
||||||
|
const START_EASE = 2.5;
|
||||||
|
const EASY_BONUS = 1.3;
|
||||||
|
const HARD_FACTOR = 1.2;
|
||||||
|
const MIN_EASE = 1.3;
|
||||||
|
const NEW_PER_DAY = 20;
|
||||||
|
|
||||||
|
const cardEl = document.getElementById("card");
|
||||||
|
const countsEl = document.getElementById("counts");
|
||||||
|
const showRow = document.getElementById("show-row");
|
||||||
|
const gradeRow = document.getElementById("grade-row");
|
||||||
|
const listGridEl = document.getElementById("list-grid");
|
||||||
|
const todayPanelEl = document.getElementById("today-panel");
|
||||||
|
const undoEl = document.getElementById("undo");
|
||||||
|
|
||||||
|
let listCards = {};
|
||||||
|
let lists = [];
|
||||||
|
let selectedListId = DEFAULT_LIST_ID;
|
||||||
|
let store = { cards: {}, meta: { date: "", newCount: 0 } };
|
||||||
|
let active = null; // current card being shown
|
||||||
|
let revealed = false;
|
||||||
|
let undo = null;
|
||||||
|
let forcedCardKey = null;
|
||||||
|
|
||||||
|
// ---------- persistence ----------
|
||||||
|
function load() {
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(localStorage.getItem(SRS_KEY));
|
||||||
|
if (s && s.cards) store = s;
|
||||||
|
} catch {}
|
||||||
|
migrateLegacySchedule();
|
||||||
|
loadLists();
|
||||||
|
forcedCardKey = localStorage.getItem(FORCE_KEY);
|
||||||
|
rolloverDay();
|
||||||
|
}
|
||||||
|
function save() { localStorage.setItem(SRS_KEY, JSON.stringify(store)); }
|
||||||
|
function saveLists() { localStorage.setItem(LISTS_KEY, JSON.stringify(lists)); }
|
||||||
|
function today() { return new Date().toISOString().slice(0, 10); }
|
||||||
|
function rolloverDay() {
|
||||||
|
if (store.meta.date !== today()) store.meta = Object.assign({}, store.meta, { date: today(), newCount: 0 });
|
||||||
|
}
|
||||||
|
function loadLists() {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(LISTS_KEY));
|
||||||
|
if (Array.isArray(saved)) lists = saved.filter((l) => l && l.id && l.title);
|
||||||
|
} catch {}
|
||||||
|
if (!lists.some((l) => l.id === DEFAULT_LIST_ID)) {
|
||||||
|
lists.unshift({ id: DEFAULT_LIST_ID, title: "-er verbs", source: "verbs-db", createdAt: Date.now() });
|
||||||
|
}
|
||||||
|
if (!lists.length) lists = [{ id: DEFAULT_LIST_ID, title: "-er verbs", source: "verbs-db", createdAt: Date.now() }];
|
||||||
|
saveLists();
|
||||||
|
}
|
||||||
|
function migrateLegacySchedule() {
|
||||||
|
if (store.meta && store.meta.listMigration === 1) return;
|
||||||
|
const migrated = {};
|
||||||
|
for (const [key, value] of Object.entries(store.cards || {})) {
|
||||||
|
migrated[key.includes(":") ? key : `${DEFAULT_LIST_ID}:${key}`] = value;
|
||||||
|
}
|
||||||
|
store.cards = migrated;
|
||||||
|
store.meta = Object.assign({}, store.meta, { listMigration: 1 });
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- scheduler ----------
|
||||||
|
function fresh() {
|
||||||
|
return { state: "new", ease: START_EASE, interval: 0, step: 0, due: 0, reps: 0, lapses: 0, lapsed: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a NEW schedule object for `prev` given a grade, without mutating.
|
||||||
|
function schedule(prev, grade, now) {
|
||||||
|
const c = Object.assign(fresh(), prev);
|
||||||
|
if (c.state === "new") { c.state = "learning"; c.step = 0; }
|
||||||
|
|
||||||
|
const learning = c.state === "learning" || c.state === "relearning";
|
||||||
|
|
||||||
|
if (learning) {
|
||||||
|
const steps = c.state === "relearning" ? RELEARN_STEPS : LEARN_STEPS;
|
||||||
|
if (grade === "again") {
|
||||||
|
c.step = 0;
|
||||||
|
c.due = now;
|
||||||
|
} else if (grade === "hard") {
|
||||||
|
const nextStep = steps[Math.min(c.step + 1, steps.length - 1)];
|
||||||
|
c.due = now + Math.max(HARD_LEARN_DELAY, nextStep / 2) * MIN;
|
||||||
|
} else if (grade === "good") {
|
||||||
|
c.step += 1;
|
||||||
|
if (c.step >= steps.length) {
|
||||||
|
c.state = "review";
|
||||||
|
c.interval = c.lapsed && prev && prev.state === "relearning" ? Math.max(1, c.lapsed) : GRAD_INT;
|
||||||
|
c.due = now + c.interval * DAY;
|
||||||
|
c.reps += 1;
|
||||||
|
} else {
|
||||||
|
c.due = now + steps[c.step] * MIN;
|
||||||
|
}
|
||||||
|
} else { // easy -> graduate immediately
|
||||||
|
c.state = "review";
|
||||||
|
c.interval = EASY_INT;
|
||||||
|
c.due = now + c.interval * DAY;
|
||||||
|
c.reps += 1;
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// review state
|
||||||
|
if (grade === "again") {
|
||||||
|
c.lapses += 1;
|
||||||
|
c.ease = Math.max(MIN_EASE, c.ease - 0.2);
|
||||||
|
c.lapsed = Math.max(1, Math.round(c.interval * 0)); // 0% new interval -> floor 1d on relearn
|
||||||
|
c.state = "relearning";
|
||||||
|
c.step = 0;
|
||||||
|
c.due = now + RELEARN_STEPS[0] * MIN;
|
||||||
|
} else if (grade === "hard") {
|
||||||
|
c.ease = Math.max(MIN_EASE, c.ease - 0.15);
|
||||||
|
c.interval = Math.max(c.interval + 1, Math.round(c.interval * HARD_FACTOR));
|
||||||
|
c.due = now + c.interval * DAY;
|
||||||
|
c.reps += 1;
|
||||||
|
} else if (grade === "good") {
|
||||||
|
c.interval = Math.max(c.interval + 1, Math.round(c.interval * c.ease));
|
||||||
|
c.due = now + c.interval * DAY;
|
||||||
|
c.reps += 1;
|
||||||
|
} else { // easy
|
||||||
|
c.interval = Math.max(c.interval + 1, Math.round(c.interval * c.ease * EASY_BONUS));
|
||||||
|
c.ease += 0.15;
|
||||||
|
c.due = now + c.interval * DAY;
|
||||||
|
c.reps += 1;
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtUntil(ms) {
|
||||||
|
if (ms < 0) ms = 0;
|
||||||
|
if (ms === 0) return "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";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- audio ----------
|
||||||
|
function speakFrench(text) {
|
||||||
|
if (!("speechSynthesis" in window) || !text) return;
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
const utterance = new SpeechSynthesisUtterance(text);
|
||||||
|
utterance.lang = "fr-FR";
|
||||||
|
utterance.rate = 0.9;
|
||||||
|
window.speechSynthesis.speak(utterance);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listenButton(text) {
|
||||||
|
return `<button class="fc-listen" type="button" data-speak="${escapeHtml(text)}" aria-label="Listen">
|
||||||
|
<span class="fc-listen-icon" aria-hidden="true">🔊</span>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireListenButtons() {
|
||||||
|
cardEl.querySelectorAll("[data-speak]").forEach((button) => {
|
||||||
|
button.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
speakFrench(button.dataset.speak);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- queue ----------
|
||||||
|
function activeList() { return lists.find((l) => l.id === selectedListId); }
|
||||||
|
function cardsForList(id) { return listCards[id] || []; }
|
||||||
|
function studyCards() {
|
||||||
|
if (selectedListId === TODAY_ID) return lists.flatMap((l) => cardsForList(l.id));
|
||||||
|
return cardsForList(selectedListId);
|
||||||
|
}
|
||||||
|
function cardKey(v) { return `${v.listId}:${v.id}`; }
|
||||||
|
function schedOf(v) { return store.cards[cardKey(v)]; }
|
||||||
|
function listTitle(id) {
|
||||||
|
const l = lists.find((x) => x.id === id);
|
||||||
|
return l ? l.title : "Unknown list";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNext() {
|
||||||
|
const now = Date.now();
|
||||||
|
const cards = studyCards();
|
||||||
|
if (!cards.length) return { done: true, empty: true, wait: null };
|
||||||
|
const forced = forcedCardKey && cards.find((card) => cardKey(card) === forcedCardKey);
|
||||||
|
if (forced) return { card: forced, forced: true };
|
||||||
|
const learnDue = [], reviewDue = [], news = [];
|
||||||
|
let learnAhead = Infinity;
|
||||||
|
|
||||||
|
for (const v of cards) {
|
||||||
|
const s = schedOf(v);
|
||||||
|
if (!s) { news.push(v); continue; }
|
||||||
|
const isLearn = s.state === "learning" || s.state === "relearning";
|
||||||
|
if (s.due <= now) {
|
||||||
|
if (isLearn) learnDue.push(v); else reviewDue.push(v);
|
||||||
|
} else if (isLearn) {
|
||||||
|
learnAhead = Math.min(learnAhead, s.due - now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
learnDue.sort((a, b) => schedOf(a).due - schedOf(b).due);
|
||||||
|
reviewDue.sort((a, b) => schedOf(a).due - schedOf(b).due);
|
||||||
|
|
||||||
|
if (learnDue.length) return { card: learnDue[0] };
|
||||||
|
if (reviewDue.length) return { card: reviewDue[0] };
|
||||||
|
if (selectedListId === TODAY_ID) return { done: true, wait: learnAhead === Infinity ? null : learnAhead };
|
||||||
|
if (news.length) return { card: news[0] };
|
||||||
|
return { done: true, wait: learnAhead === Infinity ? null : learnAhead };
|
||||||
|
}
|
||||||
|
|
||||||
|
function counts(cards = studyCards(), includeNew = selectedListId !== TODAY_ID) {
|
||||||
|
const now = Date.now();
|
||||||
|
let learning = 0, due = 0, newSeen = 0;
|
||||||
|
for (const v of cards) {
|
||||||
|
const s = schedOf(v);
|
||||||
|
if (!s) { newSeen++; continue; }
|
||||||
|
if (s.state === "learning" || s.state === "relearning") learning++;
|
||||||
|
else if (s.due <= now) due++;
|
||||||
|
}
|
||||||
|
const newLeft = includeNew ? newSeen : 0;
|
||||||
|
return { newLeft, learning, due };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCounts() {
|
||||||
|
const c = counts();
|
||||||
|
countsEl.innerHTML = `
|
||||||
|
<span class="cnt new">New <b>${c.newLeft}</b></span>
|
||||||
|
<span class="cnt learn">Learning <b>${c.learning}</b></span>
|
||||||
|
<span class="cnt rev">To review <b>${c.due}</b></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- rendering ----------
|
||||||
|
function render({ speak = true } = {}) {
|
||||||
|
renderLists();
|
||||||
|
renderToday();
|
||||||
|
renderCounts();
|
||||||
|
renderUndo();
|
||||||
|
const next = pickNext();
|
||||||
|
|
||||||
|
if (next.done) {
|
||||||
|
active = null;
|
||||||
|
showRow.hidden = true;
|
||||||
|
gradeRow.hidden = true;
|
||||||
|
cardEl.className = "fc-card done";
|
||||||
|
cardEl.innerHTML = `
|
||||||
|
<div class="fc-face">
|
||||||
|
<span class="fc-done-check">✓</span>
|
||||||
|
<span class="fc-term" style="font-size:24px">${next.empty ? "This list is empty" : "All caught up"}</span>
|
||||||
|
<span class="fc-hint">${next.empty
|
||||||
|
? "Add cards later by asking me to fill this list."
|
||||||
|
: next.wait != null
|
||||||
|
? "Next card due in " + fmtUntil(next.wait)
|
||||||
|
: selectedListId === TODAY_ID
|
||||||
|
? "No more reviews due today."
|
||||||
|
: "No more cards for today. Come back tomorrow."}</span>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
active = next.card;
|
||||||
|
if (next.forced) {
|
||||||
|
forcedCardKey = null;
|
||||||
|
localStorage.removeItem(FORCE_KEY);
|
||||||
|
}
|
||||||
|
revealed = false;
|
||||||
|
const isNew = !schedOf(active);
|
||||||
|
cardEl.className = "fc-card";
|
||||||
|
cardEl.innerHTML = `
|
||||||
|
<div class="fc-face fc-front">
|
||||||
|
${isNew ? '<span class="fc-tag new-tag">new</span>' : ""}
|
||||||
|
${listenButton(active.infinitif)}
|
||||||
|
<span class="fc-label">${listTitle(active.listId)} · ${active.frontLabel}</span>
|
||||||
|
<span class="fc-term">${active.infinitif}</span>
|
||||||
|
<span class="fc-hint">space / click to show answer</span>
|
||||||
|
</div>`;
|
||||||
|
wireListenButtons();
|
||||||
|
if (speak) speakFrench(active.infinitif);
|
||||||
|
showRow.hidden = false;
|
||||||
|
gradeRow.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUndo() {
|
||||||
|
undoEl.disabled = !undo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCardByKey(key) {
|
||||||
|
for (const cards of Object.values(listCards)) {
|
||||||
|
const found = cards.find((card) => cardKey(card) === key);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reveal() {
|
||||||
|
if (!active || revealed) return;
|
||||||
|
revealed = true;
|
||||||
|
const v = active;
|
||||||
|
cardEl.innerHTML = `
|
||||||
|
<div class="fc-face fc-back">
|
||||||
|
${listenButton(v.infinitif)}
|
||||||
|
<span class="fc-label">${v.infinitif} · English</span>
|
||||||
|
<span class="fc-term en">${v.anglais}</span>
|
||||||
|
${conjTable(v)}
|
||||||
|
<div class="fc-example">
|
||||||
|
<code>${v.exemple_fr}</code>
|
||||||
|
<span class="fc-ex-en">${v.exemple_en}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
wireListenButtons();
|
||||||
|
showRow.hidden = true;
|
||||||
|
renderGrades();
|
||||||
|
gradeRow.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// present-tense conjugation table (with elision: je -> j' before a vowel/h)
|
||||||
|
const PRONOUNS = ["je", "tu", "il/elle", "nous", "vous", "ils/elles"];
|
||||||
|
function withPronoun(pron, form) {
|
||||||
|
if (pron === "je" && /^[aeiouyhàâäéèêëîïôöûü]/i.test(form)) {
|
||||||
|
return `<span class="cj-p">j'</span>${form}`;
|
||||||
|
}
|
||||||
|
return `<span class="cj-p">${pron} </span>${form}`;
|
||||||
|
}
|
||||||
|
function conjTable(v) {
|
||||||
|
if (!v.present) return "";
|
||||||
|
const cells = v.present
|
||||||
|
.map((f, i) => `<span class="cj-cell">${withPronoun(PRONOUNS[i], f)}</span>`)
|
||||||
|
.join("");
|
||||||
|
return `<div class="fc-conj">
|
||||||
|
<div class="fc-conj-label">Présent</div>
|
||||||
|
<div class="fc-conj-grid">${cells}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GRADES = [
|
||||||
|
{ key: "again", label: "Again", cls: "again" },
|
||||||
|
{ key: "hard", label: "Hard", cls: "hard" },
|
||||||
|
{ key: "good", label: "Good", cls: "good" },
|
||||||
|
{ key: "easy", label: "Easy", cls: "easy" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderGrades() {
|
||||||
|
const now = Date.now();
|
||||||
|
const prev = schedOf(active);
|
||||||
|
gradeRow.innerHTML = GRADES.map((g) => {
|
||||||
|
const next = schedule(prev, g.key, now);
|
||||||
|
return `<button class="fc-btn grade ${g.cls}" data-grade="${g.key}">
|
||||||
|
<span class="g-label">${g.label}</span>
|
||||||
|
<span class="g-int">${fmtUntil(next.due - now)}</span>
|
||||||
|
</button>`;
|
||||||
|
}).join("");
|
||||||
|
gradeRow.querySelectorAll("button").forEach((b) => {
|
||||||
|
b.onclick = () => grade(b.dataset.grade);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function grade(g) {
|
||||||
|
if (!active) return;
|
||||||
|
const key = cardKey(active);
|
||||||
|
const previousSchedule = schedOf(active);
|
||||||
|
const wasNew = !schedOf(active);
|
||||||
|
undo = {
|
||||||
|
key,
|
||||||
|
previousSchedule: previousSchedule ? Object.assign({}, previousSchedule) : null,
|
||||||
|
wasNew,
|
||||||
|
meta: Object.assign({}, store.meta),
|
||||||
|
};
|
||||||
|
store.cards[key] = schedule(previousSchedule, g, Date.now());
|
||||||
|
if (wasNew) { rolloverDay(); store.meta.newCount += 1; }
|
||||||
|
save();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function undoGrade() {
|
||||||
|
if (!undo) return;
|
||||||
|
const card = findCardByKey(undo.key);
|
||||||
|
if (!card) {
|
||||||
|
undo = null;
|
||||||
|
renderUndo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (undo.previousSchedule) store.cards[undo.key] = undo.previousSchedule;
|
||||||
|
else delete store.cards[undo.key];
|
||||||
|
store.meta = Object.assign({}, undo.meta);
|
||||||
|
active = card;
|
||||||
|
revealed = true;
|
||||||
|
undo = null;
|
||||||
|
save();
|
||||||
|
renderLists();
|
||||||
|
renderToday();
|
||||||
|
renderCounts();
|
||||||
|
renderUndo();
|
||||||
|
revealRestored(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealRestored(card) {
|
||||||
|
cardEl.className = "fc-card";
|
||||||
|
const v = card;
|
||||||
|
cardEl.innerHTML = `
|
||||||
|
<div class="fc-face fc-back">
|
||||||
|
${listenButton(v.infinitif)}
|
||||||
|
<span class="fc-label">${v.infinitif} · English</span>
|
||||||
|
<span class="fc-term en">${v.anglais}</span>
|
||||||
|
${conjTable(v)}
|
||||||
|
<div class="fc-example">
|
||||||
|
<code>${v.exemple_fr}</code>
|
||||||
|
<span class="fc-ex-en">${v.exemple_en}</span>
|
||||||
|
</div>
|
||||||
|
<span class="fc-hint">Previous grade undone. Choose a new grade.</span>
|
||||||
|
</div>`;
|
||||||
|
wireListenButtons();
|
||||||
|
showRow.hidden = true;
|
||||||
|
renderGrades();
|
||||||
|
gradeRow.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLists() {
|
||||||
|
const todayActive = selectedListId === TODAY_ID ? " active" : "";
|
||||||
|
const todayCount = counts(lists.flatMap((l) => cardsForList(l.id)), false).due;
|
||||||
|
const items = [`<button class="fc-list-item${todayActive}" data-list-id="${TODAY_ID}">
|
||||||
|
<span class="dot ${todayCount ? "on" : ""}"></span>
|
||||||
|
<span class="fc-list-title">Today</span>
|
||||||
|
<span class="fc-list-count">${todayCount}</span>
|
||||||
|
</button>`];
|
||||||
|
for (const list of lists) {
|
||||||
|
const c = counts(cardsForList(list.id), true);
|
||||||
|
const total = cardsForList(list.id).length;
|
||||||
|
const activeCls = selectedListId === list.id ? " active" : "";
|
||||||
|
items.push(`<button class="fc-list-item${activeCls}" data-list-id="${list.id}">
|
||||||
|
<span class="dot ${c.due ? "on" : ""}"></span>
|
||||||
|
<span class="fc-list-title">${escapeHtml(list.title)}</span>
|
||||||
|
<span class="fc-list-count">${total}</span>
|
||||||
|
</button>`);
|
||||||
|
}
|
||||||
|
listGridEl.innerHTML = items.join("");
|
||||||
|
listGridEl.querySelectorAll("[data-list-id]").forEach((b) => {
|
||||||
|
b.onclick = () => {
|
||||||
|
selectedListId = b.dataset.listId;
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToday() {
|
||||||
|
const c = counts(lists.flatMap((l) => cardsForList(l.id)), false);
|
||||||
|
const title = selectedListId === TODAY_ID
|
||||||
|
? "Studying today's due cards"
|
||||||
|
: `Studying ${escapeHtml(activeList()?.title || "list")}`;
|
||||||
|
todayPanelEl.innerHTML = `<div>
|
||||||
|
<span class="fc-section-label">${title}</span>
|
||||||
|
<p>Today has <b>${c.due}</b> review card${c.due === 1 ? "" : "s"} due across all lists.</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addList() {
|
||||||
|
const title = prompt("List title");
|
||||||
|
if (!title || !title.trim()) return;
|
||||||
|
const id = slugify(title.trim());
|
||||||
|
let unique = id || `list-${Date.now()}`;
|
||||||
|
let n = 2;
|
||||||
|
while (lists.some((l) => l.id === unique) || unique === TODAY_ID) unique = `${id}-${n++}`;
|
||||||
|
lists.push({ id: unique, title: title.trim(), source: "manual", createdAt: Date.now() });
|
||||||
|
listCards[unique] = [];
|
||||||
|
selectedListId = unique;
|
||||||
|
saveLists();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(s) {
|
||||||
|
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, (ch) => ({
|
||||||
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||||
|
}[ch]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- boot ----------
|
||||||
|
async function init() {
|
||||||
|
load();
|
||||||
|
try {
|
||||||
|
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;");
|
||||||
|
listCards[DEFAULT_LIST_ID] = res[0].values.map(([infinitif, anglais, exemple_fr, exemple_en]) => ({
|
||||||
|
id: infinitif,
|
||||||
|
listId: DEFAULT_LIST_ID,
|
||||||
|
frontLabel: "French · infinitive",
|
||||||
|
infinitif, anglais, exemple_fr, exemple_en,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// present-tense conjugations, keyed by infinitive
|
||||||
|
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 [inf, ...forms] of cres[0].values) conj[inf] = forms;
|
||||||
|
}
|
||||||
|
listCards[DEFAULT_LIST_ID].forEach((v) => { v.present = conj[v.infinitif] || null; });
|
||||||
|
lists.forEach((l) => { if (!listCards[l.id]) listCards[l.id] = []; });
|
||||||
|
|
||||||
|
document.getElementById("add-list").onclick = addList;
|
||||||
|
document.getElementById("show").onclick = reveal;
|
||||||
|
undoEl.onclick = undoGrade;
|
||||||
|
cardEl.onclick = () => { if (!revealed) reveal(); };
|
||||||
|
document.getElementById("reset").onclick = () => {
|
||||||
|
if (!confirm("Reset all spaced-repetition progress?")) return;
|
||||||
|
store = { cards: {}, meta: { date: today(), newCount: 0, listMigration: 1 } };
|
||||||
|
undo = null;
|
||||||
|
save();
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
wireKeys();
|
||||||
|
render({ speak: false });
|
||||||
|
} catch (err) {
|
||||||
|
showRow.hidden = true;
|
||||||
|
cardEl.innerHTML = `<div class="fc-error">
|
||||||
|
<strong>Couldn't load verbs.db.</strong>
|
||||||
|
<p>Open this app through a local server (file:// blocks the database fetch):</p>
|
||||||
|
<code>python3 -m http.server 8000</code>
|
||||||
|
<p class="fc-err-detail">${err.message}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireKeys() {
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if ((e.key === "Backspace" || e.key.toLowerCase() === "z") && undo) {
|
||||||
|
e.preventDefault();
|
||||||
|
undoGrade();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!active) return;
|
||||||
|
if (!revealed) {
|
||||||
|
if (e.key === " " || e.key === "Enter") { e.preventDefault(); reveal(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "1") grade("again");
|
||||||
|
else if (e.key === "2") grade("hard");
|
||||||
|
else if (e.key === "3" || e.key === " ") { e.preventDefault(); grade("good"); }
|
||||||
|
else if (e.key === "4") grade("easy");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
34
index.html
Normal file
34
index.html
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>French Journey, Grammar A1 to C2</title>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">French Journey <span>·</span> Grammar</div>
|
||||||
|
<nav class="topnav">
|
||||||
|
<a href="index.html" class="active">Grammar</a>
|
||||||
|
<a href="flashcards.html">Flashcards</a>
|
||||||
|
<a href="explorer.html">Explorer</a>
|
||||||
|
</nav>
|
||||||
|
<button class="theme-toggle" type="button" data-theme-toggle aria-pressed="false">Dark</button>
|
||||||
|
<div class="topbar-meta">English → French · A1 → C2</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<input type="text" id="search" class="search" placeholder="Search…" />
|
||||||
|
<nav id="nav" class="nav"></nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="content" id="content"></main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="theme.js"></script>
|
||||||
|
<script src="data.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
serve.js
Normal file
24
serve.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// Minimal static server for local preview. Run: node serve.js [port]
|
||||||
|
const http = require("http");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const ROOT = __dirname;
|
||||||
|
const PORT = process.env.PORT || process.argv[2] || 8011;
|
||||||
|
const TYPES = {
|
||||||
|
".html": "text/html", ".js": "text/javascript", ".css": "text/css",
|
||||||
|
".db": "application/octet-stream", ".wasm": "application/wasm",
|
||||||
|
".json": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
http.createServer((req, res) => {
|
||||||
|
let p = decodeURIComponent(req.url.split("?")[0]);
|
||||||
|
if (p === "/") p = "/index.html";
|
||||||
|
const file = path.join(ROOT, p);
|
||||||
|
if (!file.startsWith(ROOT)) { res.writeHead(403); return res.end(); }
|
||||||
|
fs.readFile(file, (err, data) => {
|
||||||
|
if (err) { res.writeHead(404); return res.end("Not found"); }
|
||||||
|
res.writeHead(200, { "Content-Type": TYPES[path.extname(file)] || "application/octet-stream" });
|
||||||
|
res.end(data);
|
||||||
|
});
|
||||||
|
}).listen(PORT, () => console.log(`http://localhost:${PORT}`));
|
||||||
803
style.css
Normal file
803
style.css
Normal file
|
|
@ -0,0 +1,803 @@
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #ffffff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--line: #e5e7eb;
|
||||||
|
--accent: #2563eb;
|
||||||
|
--soft: #f8f9fa;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--code-bg: #f3f4f6;
|
||||||
|
--ok: #16a34a;
|
||||||
|
--new-soft: #eff6ff;
|
||||||
|
--new-line: #2563eb;
|
||||||
|
--new-border: #bfdbfe;
|
||||||
|
--ok-soft: #f0fdf4;
|
||||||
|
--ok-border: #bbf7d0;
|
||||||
|
--danger-soft: #fef2f2;
|
||||||
|
--danger-line: #fecaca;
|
||||||
|
--danger: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg: #090909;
|
||||||
|
--text: #eeeeee;
|
||||||
|
--muted: #a3a3a3;
|
||||||
|
--line: #2b2b2b;
|
||||||
|
--accent: #7eb6ff;
|
||||||
|
--soft: #161616;
|
||||||
|
--surface: #101010;
|
||||||
|
--code-bg: #1c1c1c;
|
||||||
|
--ok: #4ade80;
|
||||||
|
--new-soft: #171717;
|
||||||
|
--new-line: #7eb6ff;
|
||||||
|
--new-border: #294f78;
|
||||||
|
--ok-soft: #102116;
|
||||||
|
--ok-border: #27653c;
|
||||||
|
--danger-soft: #261313;
|
||||||
|
--danger-line: #743232;
|
||||||
|
--danger: #f87171;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Topbar */
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 52px;
|
||||||
|
padding: 0 24px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.brand { font-weight: 600; font-size: 15px; }
|
||||||
|
.brand span { color: var(--muted); margin: 0 4px; }
|
||||||
|
.topbar-meta { color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
|
.topnav { display: flex; gap: 4px; margin-left: 28px; margin-right: auto; }
|
||||||
|
.topnav a {
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.topnav a:hover { background: var(--soft); color: var(--text); }
|
||||||
|
.topnav a.active { color: var(--accent); background: var(--soft); font-weight: 500; }
|
||||||
|
.theme-toggle {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 14px;
|
||||||
|
}
|
||||||
|
.theme-toggle:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.layout { display: flex; align-items: flex-start; }
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
height: calc(100vh - 52px);
|
||||||
|
overflow-y: auto;
|
||||||
|
position: sticky;
|
||||||
|
top: 52px;
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
outline: none;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.search::placeholder { color: var(--muted); }
|
||||||
|
.search:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.nav .level-group { margin-bottom: 14px; }
|
||||||
|
.nav .level-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
padding: 4px 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.nav .level-label .lvl-name { font-weight: 500; text-transform: none; letter-spacing: 0; }
|
||||||
|
.nav .lvl-count {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0 7px;
|
||||||
|
}
|
||||||
|
.nav a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 10px 5px 14px;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13.5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.nav a:hover { background: var(--soft); }
|
||||||
|
.nav a.active { color: var(--accent); background: var(--soft); font-weight: 500; }
|
||||||
|
.nav a .dot {
|
||||||
|
width: 7px; height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--line);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.nav a .dot.on { background: var(--ok); border-color: var(--ok); }
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 760px;
|
||||||
|
padding: 40px 48px 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-header { margin-bottom: 36px; }
|
||||||
|
.level-header .badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.level-header h1 { font-size: 26px; font-weight: 700; margin-bottom: 6px; }
|
||||||
|
.level-header p { color: var(--muted); font-size: 14px; }
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 280px;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-bar span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--ok);
|
||||||
|
transition: width .25s ease;
|
||||||
|
}
|
||||||
|
.progress-text { font-size: 12.5px; color: var(--muted); }
|
||||||
|
|
||||||
|
.chapter {
|
||||||
|
padding: 22px 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.chapter-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.chapter h2 { font-size: 18px; font-weight: 600; scroll-margin-top: 70px; }
|
||||||
|
|
||||||
|
.mark {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all .15s ease;
|
||||||
|
}
|
||||||
|
.mark:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.mark .check { font-weight: 700; }
|
||||||
|
.chapter.done .mark {
|
||||||
|
color: var(--ok);
|
||||||
|
border-color: var(--ok);
|
||||||
|
background: var(--ok-soft);
|
||||||
|
}
|
||||||
|
.chapter.done h2 { color: var(--muted); }
|
||||||
|
|
||||||
|
.chapter ul { list-style: none; margin-bottom: 14px; }
|
||||||
|
.chapter ul li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 16px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.chapter ul li::before {
|
||||||
|
content: "–";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.examples {
|
||||||
|
background: var(--soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.examples .ex-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.examples .ex {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 3px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px 10px;
|
||||||
|
}
|
||||||
|
.examples .ex code {
|
||||||
|
background: transparent;
|
||||||
|
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.examples .ex-en {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty { color: var(--muted); padding: 40px 0; }
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.sidebar { display: none; }
|
||||||
|
.content { padding: 24px 18px 80px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Flashcards ---------- */
|
||||||
|
.fc-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.fc-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 52px;
|
||||||
|
width: 280px;
|
||||||
|
height: calc(100vh - 52px);
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
|
.fc-wrap {
|
||||||
|
max-width: 640px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 48px 100px;
|
||||||
|
}
|
||||||
|
.fc-head { text-align: center; margin-bottom: 24px; }
|
||||||
|
.fc-head h1 { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
|
||||||
|
.fc-head p { color: var(--muted); font-size: 14px; }
|
||||||
|
.fc-head code {
|
||||||
|
background: var(--code-bg);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-tabs {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.fc-tab {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fc-tab:hover {
|
||||||
|
background: var(--soft);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.fc-tab.active {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--soft);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-today {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.fc-list-head {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.fc-section-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.fc-list-head p,
|
||||||
|
.fc-today p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.fc-list-head .fc-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.fc-list-grid {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.fc-list-item {
|
||||||
|
width: 100%;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
padding: 5px 8px 5px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.fc-list-item:hover,
|
||||||
|
.fc-list-item.active {
|
||||||
|
background: var(--soft);
|
||||||
|
}
|
||||||
|
.fc-list-item.active {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.fc-list-item .dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--line);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.fc-list-item .dot.on {
|
||||||
|
background: var(--ok);
|
||||||
|
border-color: var(--ok);
|
||||||
|
}
|
||||||
|
.fc-list-title {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.fc-list-count {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0 7px;
|
||||||
|
}
|
||||||
|
.fc-today {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-counts {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 22px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.fc-counts .cnt { color: var(--muted); }
|
||||||
|
.fc-counts .cnt b { font-weight: 700; margin-left: 3px; }
|
||||||
|
.fc-counts .new b { color: var(--new-line); }
|
||||||
|
.fc-counts .learn b { color: var(--danger); }
|
||||||
|
.fc-counts .rev b { color: var(--ok); }
|
||||||
|
|
||||||
|
.fc-card {
|
||||||
|
position: relative;
|
||||||
|
min-height: 280px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.04);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
user-select: none;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.fc-card:hover { box-shadow: 0 4px 14px rgba(0,0,0,.07); }
|
||||||
|
.fc-card.is-known { border-color: var(--ok); }
|
||||||
|
|
||||||
|
.fc-face {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.fc-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.fc-term { font-size: 40px; font-weight: 700; }
|
||||||
|
.fc-term.en { font-size: 30px; font-weight: 600; color: var(--accent); }
|
||||||
|
.fc-hint { font-size: 12px; color: var(--muted); margin-top: 6px; }
|
||||||
|
.fc-listen {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 14px;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--soft);
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .15s, color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.fc-listen:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.fc-listen-icon {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-example {
|
||||||
|
margin-top: 14px;
|
||||||
|
background: var(--soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.fc-example code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 15px; }
|
||||||
|
.fc-ex-en { font-size: 13px; color: var(--muted); font-style: italic; }
|
||||||
|
|
||||||
|
/* present-tense conjugation table */
|
||||||
|
.fc-conj {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.fc-conj-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.fc-conj-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px 18px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.cj-cell {
|
||||||
|
font-size: 14.5px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
.cj-p { color: var(--muted); }
|
||||||
|
|
||||||
|
.fc-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 13px; right: 56px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--ok);
|
||||||
|
background: var(--ok-soft);
|
||||||
|
border: 1px solid var(--ok);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
.fc-controls-2 { margin-top: 10px; }
|
||||||
|
.fc-btn {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text);
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.fc-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.fc-btn:disabled {
|
||||||
|
opacity: .45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.fc-btn:disabled:hover {
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.fc-btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
.fc-btn.primary:hover { opacity: .9; color: #fff; }
|
||||||
|
.fc-btn.wide { min-width: 240px; }
|
||||||
|
.fc-btn.ghost { color: var(--muted); font-size: 12.5px; padding: 6px 12px; }
|
||||||
|
.fc-btn.ghost:hover { border-color: var(--danger); color: var(--danger); }
|
||||||
|
|
||||||
|
/* Anki-style grade buttons */
|
||||||
|
.fc-grades { gap: 8px; }
|
||||||
|
.fc-btn.grade {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 92px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.fc-btn.grade .g-label { font-weight: 600; font-size: 14px; }
|
||||||
|
.fc-btn.grade .g-int { font-size: 11px; color: var(--muted); }
|
||||||
|
.fc-btn.grade:hover { color: var(--text); }
|
||||||
|
.fc-btn.grade.again:hover { border-color: var(--danger); }
|
||||||
|
.fc-btn.grade.again .g-label { color: var(--danger); }
|
||||||
|
.fc-btn.grade.hard:hover { border-color: #d97706; }
|
||||||
|
.fc-btn.grade.hard .g-label { color: #d97706; }
|
||||||
|
.fc-btn.grade.good:hover { border-color: var(--ok); }
|
||||||
|
.fc-btn.grade.good .g-label { color: var(--ok); }
|
||||||
|
.fc-btn.grade.easy:hover { border-color: var(--new-line); }
|
||||||
|
.fc-btn.grade.easy .g-label { color: var(--new-line); }
|
||||||
|
|
||||||
|
.fc-tools { display: flex; justify-content: center; margin-top: 22px; }
|
||||||
|
|
||||||
|
.fc-card.done { border-style: dashed; cursor: default; }
|
||||||
|
.fc-card.done:hover { box-shadow: 0 1px 3px rgba(0,0,0,.04); }
|
||||||
|
.fc-done-check {
|
||||||
|
font-size: 40px;
|
||||||
|
color: var(--ok);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.new-tag { color: var(--new-line) !important; background: var(--new-soft) !important; border-color: var(--new-line) !important; }
|
||||||
|
|
||||||
|
.fc-loading { color: var(--muted); font-size: 14px; }
|
||||||
|
.fc-error { text-align: center; padding: 24px; }
|
||||||
|
.fc-error code {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--code-bg);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.fc-err-detail { font-size: 12px; color: var(--muted); }
|
||||||
|
|
||||||
|
.fc-explorer {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
.fc-explorer-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.fc-explorer-head p,
|
||||||
|
.fc-explorer-meta,
|
||||||
|
.fc-explorer-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.fc-explorer-search {
|
||||||
|
width: 220px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.fc-explorer-search:focus { border-color: var(--accent); }
|
||||||
|
.fc-explorer-meta { margin-bottom: 14px; }
|
||||||
|
.explorer-main-search {
|
||||||
|
max-width: 360px;
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.explorer-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.explorer-controls label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.explorer-controls select {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.explorer-controls select:focus { border-color: var(--accent); }
|
||||||
|
.fc-explorer-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.fc-explorer-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(130px, .8fr) minmax(0, 1.4fr);
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.fc-explorer-side {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.fc-explorer-side.front {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--soft);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.fc-explorer-side strong {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.fc-explorer-side.back strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.fc-explorer-side .fc-conj {
|
||||||
|
max-width: none;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.fc-explorer-side .fc-example {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.fc-srs-pill {
|
||||||
|
align-self: flex-start;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .03em;
|
||||||
|
}
|
||||||
|
.fc-srs-pill.new { color: var(--new-line); border-color: var(--new-border); background: var(--new-soft); }
|
||||||
|
.fc-srs-pill.learning,
|
||||||
|
.fc-srs-pill.relearning { color: var(--danger); border-color: var(--danger-line); background: var(--danger-soft); }
|
||||||
|
.fc-srs-pill.review { color: var(--ok); border-color: var(--ok-border); background: var(--ok-soft); }
|
||||||
|
.fc-study-card {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.topnav { margin-left: 12px; }
|
||||||
|
.fc-shell {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.fc-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.fc-wrap {
|
||||||
|
padding: 24px 18px 80px;
|
||||||
|
}
|
||||||
|
.fc-term { font-size: 32px; }
|
||||||
|
.fc-explorer-head,
|
||||||
|
.fc-explorer-card {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.fc-explorer-search {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.fc-explorer-side.front {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
theme.js
Normal file
26
theme.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
(function () {
|
||||||
|
const KEY = "frenchJourney.theme";
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
function apply(theme) {
|
||||||
|
root.dataset.theme = theme;
|
||||||
|
localStorage.setItem(KEY, theme);
|
||||||
|
const button = document.querySelector("[data-theme-toggle]");
|
||||||
|
if (button) {
|
||||||
|
const dark = theme === "dark";
|
||||||
|
button.textContent = dark ? "Light" : "Dark";
|
||||||
|
button.setAttribute("aria-pressed", String(dark));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = localStorage.getItem(KEY);
|
||||||
|
const preferred = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
|
apply(saved || preferred);
|
||||||
|
|
||||||
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const button = document.querySelector("[data-theme-toggle]");
|
||||||
|
if (!button) return;
|
||||||
|
button.onclick = () => apply(root.dataset.theme === "dark" ? "light" : "dark");
|
||||||
|
apply(root.dataset.theme || "light");
|
||||||
|
});
|
||||||
|
})();
|
||||||
BIN
verbs.db
Normal file
BIN
verbs.db
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue