commit 91e67968cae7f76354627063452ff17ad2faf235 Author: uzunerdev Date: Fri Jul 10 18:17:02 2026 +0300 Add Docker Compose setup 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 0000000..a0cbc8c Binary files /dev/null and b/verbs.db differ