From 91e67968cae7f76354627063452ff17ad2faf235 Mon Sep 17 00:00:00 2001 From: uzunerdev Date: Fri, 10 Jul 2026 18:17:02 +0300 Subject: [PATCH] Add Docker Compose setup --- .dockerignore | 6 + .gitignore | 3 + Dockerfile | 12 + app.js | 148 +++++++++ build_db.sql | 118 +++++++ conjugate.js | 70 ++++ data.js | 518 +++++++++++++++++++++++++++++ docker-compose.yml | 9 + explorer.html | 72 ++++ explorer.js | 208 ++++++++++++ flashcards.html | 66 ++++ flashcards.js | 566 ++++++++++++++++++++++++++++++++ index.html | 34 ++ serve.js | 24 ++ style.css | 803 +++++++++++++++++++++++++++++++++++++++++++++ theme.js | 26 ++ verbs.db | Bin 0 -> 45056 bytes 17 files changed, 2683 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app.js create mode 100644 build_db.sql create mode 100644 conjugate.js create mode 100644 data.js create mode 100644 docker-compose.yml create mode 100644 explorer.html create mode 100644 explorer.js create mode 100644 flashcards.html create mode 100644 flashcards.js create mode 100644 index.html create mode 100644 serve.js create mode 100644 style.css create mode 100644 theme.js create mode 100644 verbs.db diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..76f0fd0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.DS_Store +node_modules +npm-debug.log +Dockerfile +docker-compose*.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..153216e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +node_modules/ +npm-debug.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1e5f097 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY . . + +ENV NODE_ENV=production +ENV PORT=8011 + +EXPOSE 8011 + +CMD ["node", "serve.js"] diff --git a/app.js b/app.js new file mode 100644 index 0000000..1e963f7 --- /dev/null +++ b/app.js @@ -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 = ` +
+ ${niveau.niveau} ${niveau.titre} + ${completed}/${total} +
`; + chapitres.forEach((c) => { + const id = chapterId(niveau, c.titre); + const a = document.createElement("a"); + a.href = "#" + id; + a.dataset.id = id; + a.innerHTML = `${c.titre.split(" (")[0]}`; + group.appendChild(a); + }); + navEl.appendChild(group); + + // --- Content header --- + const header = document.createElement("div"); + header.className = "level-header"; + header.innerHTML = ` + ${niveau.niveau} +

${niveau.titre}

+

${niveau.description}

+
+
+ ${completed} of ${total} completed +
`; + 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) => `
  • ${p}
  • `).join(""); + const ex = c.exemples + .map((e) => `
    ${e.fr}${e.en}
    `) + .join(""); + + sec.innerHTML = ` +
    +

    ${c.titre}

    + +
    + +
    +
    Examples
    + ${ex} +
    `; + contentEl.appendChild(sec); + }); + }); + + if (!anyContent) { + contentEl.innerHTML = `

    No results for “${filter}”.

    `; + } + + 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(); diff --git a/build_db.sql b/build_db.sql new file mode 100644 index 0000000..2824c78 --- /dev/null +++ b/build_db.sql @@ -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.'); diff --git a/conjugate.js b/conjugate.js new file mode 100644 index 0000000..4436730 --- /dev/null +++ b/conjugate.js @@ -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(); diff --git a/data.js b/data.js new file mode 100644 index 0000000..6704059 --- /dev/null +++ b/data.js @@ -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)." } + ] + } + ] + } +]; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d9b7054 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + french-journey: + build: . + container_name: french-journey + restart: unless-stopped + ports: + - "${PORT:-8011}:8011" + environment: + PORT: 8011 diff --git a/explorer.html b/explorer.html new file mode 100644 index 0000000..5069d46 --- /dev/null +++ b/explorer.html @@ -0,0 +1,72 @@ + + + + + + Explorer · French Journey + + + +
    +
    French Journey · Explorer
    + + +
    Database · front/back cards
    +
    + +
    + + +
    +
    + Explorer +

    Card database

    +

    Browse every flashcard, see the front and back, and filter by word, meaning, or example sentence.

    + +
    + + +
    +
    + +
    Loading cards…
    +
    +
    +
    + + + + + + diff --git a/explorer.js b/explorer.js new file mode 100644 index 0000000..2041db0 --- /dev/null +++ b/explorer.js @@ -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 `j'${escapeHtml(form)}`; + } + return `${pron} ${escapeHtml(form)}`; +} + +function conjTable(card) { + if (!card.present) return ""; + const cells = card.present + .map((form, i) => `${withPronoun(PRONOUNS[i], form)}`) + .join(""); + return `
    +
    Présent
    +
    ${cells}
    +
    `; +} + +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 = `
    No cards loaded.
    `; + return; + } + if (!filtered.length) { + listEl.innerHTML = `
    No cards match this filter.
    `; + return; + } + + listEl.innerHTML = filtered.map((card) => ` +
    +
    + Front + ${escapeHtml(card.infinitif)} + ${escapeHtml(srsInfo(card).label)} + +
    +
    + Back + ${escapeHtml(card.anglais)} + ${conjTable(card)} +
    + ${escapeHtml(card.exemple_fr)} + ${escapeHtml(card.exemple_en)} +
    +
    +
    + `).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 = `
    + Couldn't load verbs.db. +

    Open this app through a local server:

    + python3 -m http.server 8000 +

    ${escapeHtml(err.message)}

    +
    `; + } +} + +init(); diff --git a/flashcards.html b/flashcards.html new file mode 100644 index 0000000..667bd55 --- /dev/null +++ b/flashcards.html @@ -0,0 +1,66 @@ + + + + + + Flashcards · Lists · French Journey + + + +
    +
    French Journey · Flashcards
    + + +
    Lists · spaced repetition
    +
    + +
    + + +
    +
    +

    Flashcard lists

    +

    Select a list to study, or use Today for cards due from every list. Your lists and schedule are saved locally.

    +
    + +
    + +
    + +
    +
    Loading verbs…
    +
    + + + + + + + +
    + + +
    +
    +
    + + + + + + diff --git a/flashcards.js b/flashcards.js new file mode 100644 index 0000000..3440248 --- /dev/null +++ b/flashcards.js @@ -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 ``; +} + +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 = ` + New ${c.newLeft} + Learning ${c.learning} + To review ${c.due}`; +} + +// ---------- 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 = ` +
    + + ${next.empty ? "This list is empty" : "All caught up"} + ${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."} +
    `; + 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 = ` +
    + ${isNew ? 'new' : ""} + ${listenButton(active.infinitif)} + ${listTitle(active.listId)} · ${active.frontLabel} + ${active.infinitif} + space / click to show answer +
    `; + 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 = ` +
    + ${listenButton(v.infinitif)} + ${v.infinitif} · English + ${v.anglais} + ${conjTable(v)} +
    + ${v.exemple_fr} + ${v.exemple_en} +
    +
    `; + 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 `j'${form}`; + } + return `${pron} ${form}`; +} +function conjTable(v) { + if (!v.present) return ""; + const cells = v.present + .map((f, i) => `${withPronoun(PRONOUNS[i], f)}`) + .join(""); + return `
    +
    Présent
    +
    ${cells}
    +
    `; +} + +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 ``; + }).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 = ` +
    + ${listenButton(v.infinitif)} + ${v.infinitif} · English + ${v.anglais} + ${conjTable(v)} +
    + ${v.exemple_fr} + ${v.exemple_en} +
    + Previous grade undone. Choose a new grade. +
    `; + 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 = [``]; + 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(``); + } + 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 = `
    + +

    Today has ${c.due} review card${c.due === 1 ? "" : "s"} due across all lists.

    +
    `; +} + +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 = `
    + Couldn't load verbs.db. +

    Open this app through a local server (file:// blocks the database fetch):

    + python3 -m http.server 8000 +

    ${err.message}

    +
    `; + } +} + +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(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..4100c5b --- /dev/null +++ b/index.html @@ -0,0 +1,34 @@ + + + + + + French Journey, Grammar A1 to C2 + + + +
    +
    French Journey · Grammar
    + + +
    English → French · A1 → C2
    +
    + +
    + + +
    +
    + + + + + + diff --git a/serve.js b/serve.js new file mode 100644 index 0000000..80c1bd6 --- /dev/null +++ b/serve.js @@ -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}`)); diff --git a/style.css b/style.css new file mode 100644 index 0000000..26c09af --- /dev/null +++ b/style.css @@ -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); + } +} diff --git a/theme.js b/theme.js new file mode 100644 index 0000000..fef2c9e --- /dev/null +++ b/theme.js @@ -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"); + }); +})(); diff --git a/verbs.db b/verbs.db new file mode 100644 index 0000000000000000000000000000000000000000..a0cbc8cbd29ad7d2e9765a3976ccd6d1c6ed6ddc GIT binary patch literal 45056 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 p>VhjIL