commit f35864a03edc2a6e15ebe0c7fe4683eff60ff5d7 Author: Chris W. Fuller Date: Wed Oct 21 04:14:43 2026 +0000 Initial release of WortKraft German A2/B1 learning studio diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d70e5c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +data_store/ +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a8df0fe --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:22-alpine + +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=80 +ENV DATA_DIR=/app/data + +COPY package*.json ./ +RUN npm install --omit=dev + +COPY index.html styles.css app.js ./ +COPY data/ ./data/ +COPY server/ ./server/ + +RUN mkdir -p /app/data + +EXPOSE 80 + +HEALTHCHECK --interval=15s --timeout=5s --start-period=5s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://127.0.0.1:80/api/health || exit 1 + +CMD ["node", "server/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7268185 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# WortKraft — Active German A2/B1 Vocabulary & Context Studio + +**WortKraft** is a self-hosted German learning application built to bridge the "1,400-day Duolingo plateau" by transitioning from passive multiple-choice recognition to **active recall, grammatical gender mastery (`der`/`die`/`das`), contextual reading, and targeted sentence production**. + +## Core Features +- **⚡ Wortschatz-Trainer (SM-2 Spaced Repetition)**: + - **Lückensatz im Kontext**: Fill in the missing German word in realistic A2/B1 sentences. + - **Aktiv Schreiben (EN → DE + Artikel)**: Active recall of both the German word and its grammatical gender (`der`, `die`, `das`). + - **Artikel & Plural-Blitz**: Rapid-fire noun gender and plural drill with morphological suffix rules. + - **Karteikarte & Wortbau**: Compound word and prefix breakdowns with `de-DE` native speech synthesis. +- **📖 Interaktive Leseecke (Graded Reader)**: + - Pre-loaded practical everyday German scenarios (*Bürgeramt, Arztpraxis, Feierabend, Zugfahrt, Flohmarkt*) plus custom text import. + - Click any word in any text for instant lemma, gender, plural, English translation, audio pronunciation, and 1-click saving into the SRS deck. +- **💬 Sprachpraxis (Vocabulary-Targeted Roleplay)**: + - Multi-turn real-world conversations that challenge you to weave **5 Zielwörter** into your German responses, with real-time inflection detection and V2 / subordinate clause word-order coaching. +- **💾 Server + Local Persistence**: + - Syncs vocabulary state across devices via `/api/state` (`/app/data/wortkraft_state.json`) with `localStorage` fallback. diff --git a/app.js b/app.js new file mode 100644 index 0000000..0e75721 --- /dev/null +++ b/app.js @@ -0,0 +1,1628 @@ +// WortKraft — Active German A2/B1 Vocabulary, Reader & Conversation Studio + +const STORAGE_KEY = "wortkraft_state_v1"; + +class WortKraftApp { + constructor() { + this.currentView = "dashboard"; + this.trainerMode = "cloze"; // 'cloze' | 'production' | 'gender' | 'flashcard' + this.trainerFilter = "due"; + this.currentCard = null; + this.cardRevealed = false; + this.lastAnswerStatus = null; // { isCorrect, message, userText } + + this.activeStoryId = window.GRADED_STORIES[0].id; + this.showReaderTranslation = false; + this.selectedReaderWord = null; + this.quizSelection = {}; + + this.activeScenarioId = window.CONVERSATION_SCENARIOS[0].id; + this.scenarioState = {}; // { [scenarioId]: { turnIndex, messages, usedTargetWords } } + + this.spotlightWords = []; + + this.loadState(); + this.initScenarioStates(); + this.refreshDailySpotlight(); + this.renderAll(); + this.syncFromServer(); + } + + async syncFromServer() { + try { + const res = await fetch("/api/state", { cache: "no-store" }); + if (!res.ok) return; + const data = await res.json(); + if (data && data.exists && data.state) { + const parsed = data.state; + if (Array.isArray(parsed.vocab) && parsed.vocab.length > 0) { + this.vocab = parsed.vocab; + const existingIds = new Set(this.vocab.map(v => v.id)); + window.INITIAL_VOCABULARY.forEach(item => { + if (!existingIds.has(item.id)) { + this.vocab.push(this.createVocabEntry(item)); + } + }); + } + if (Array.isArray(parsed.customStories)) this.customStories = parsed.customStories; + if (parsed.stats) this.stats = parsed.stats; + if (parsed.settings) this.settings = parsed.settings; + localStorage.setItem(STORAGE_KEY, JSON.stringify(parsed)); + this.renderAll(); + } else { + // First run on server: push current default state to server + this.pushStateToServer(); + } + } catch (_e) { + // Offline or opened as local file:// — localStorage handles persistence + } + } + + async pushStateToServer() { + try { + await fetch("/api/state", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + vocab: this.vocab, + customStories: this.customStories, + stats: this.stats, + settings: this.settings + }) + }); + } catch (_e) { + // Ignore network errors when running locally + } + } + + loadState() { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + try { + const parsed = JSON.parse(saved); + this.vocab = parsed.vocab || []; + // Merge any new default words if vocabulary.js was updated + const existingIds = new Set(this.vocab.map(v => v.id)); + window.INITIAL_VOCABULARY.forEach(item => { + if (!existingIds.has(item.id)) { + this.vocab.push(this.createVocabEntry(item)); + } + }); + this.customStories = parsed.customStories || []; + this.stats = parsed.stats || { + streak: 1401, + totalChecks: 0, + correctChecks: 0, + sentencesWritten: 0 + }; + this.settings = parsed.settings || { + aiProvider: "builtin", + apiKey: "" + }; + return; + } catch (e) { + console.warn("Could not parse saved state, initializing fresh state.", e); + } + } + + // Initialize default state + const now = Date.now(); + this.vocab = window.INITIAL_VOCABULARY.map((item, idx) => { + const entry = this.createVocabEntry(item); + // Make first 15 words immediately due today so user has a focused starting queue, + // and the rest available as new/due as well + entry.srs.dueDate = idx < 18 ? now - 1000 : now; + return entry; + }); + + this.customStories = []; + this.stats = { + streak: 1401, + totalChecks: 0, + correctChecks: 0, + sentencesWritten: 0 + }; + this.settings = { + aiProvider: "builtin", + apiKey: "" + }; + this.saveState(); + } + + createVocabEntry(raw) { + return { + ...raw, + srs: raw.srs || { + interval: 0, + repetition: 0, + efactor: 2.5, + dueDate: Date.now(), + status: "new", // 'new' | 'learning' | 'mastered' + reviews: 0 + } + }; + } + + saveState() { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + vocab: this.vocab, + customStories: this.customStories, + stats: this.stats, + settings: this.settings + }) + ); + this.updateDashboardCounters(); + this.pushStateToServer(); + } + + getAllStories() { + return [...this.customStories, ...window.GRADED_STORIES]; + } + + // === AUDIO PRONUNCIATION (Web Speech API de-DE) === + speak(text, rate = 0.92) { + if (!("speechSynthesis" in window)) { + this.showToast("Audio wird von diesem Browser leider nicht unterstützt."); + return; + } + window.speechSynthesis.cancel(); + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = "de-DE"; + utterance.rate = rate; + + const voices = window.speechSynthesis.getVoices(); + const germanVoice = + voices.find(v => v.lang.startsWith("de") && (v.name.includes("Natural") || v.name.includes("Google") || v.name.includes("Anna") || v.name.includes("Katja"))) || + voices.find(v => v.lang.startsWith("de")); + if (germanVoice) { + utterance.voice = germanVoice; + } + window.speechSynthesis.speak(utterance); + } + + // === GENDER BADGE HELPER === + getGenderBadgeHtml(article, pos = "") { + if (!article) { + return `${pos || "Wort"}`; + } + const clean = article.toLowerCase(); + if (clean === "der") return `der`; + if (clean === "die") return `die`; + if (clean === "das") return `das`; + if (clean.includes("pl")) return `die (Pl.)`; + return `${article}`; + } + + // === NAVIGATION === + switchView(viewName) { + this.currentView = viewName; + document.querySelectorAll(".view-section").forEach(sec => { + sec.classList.toggle("active", sec.id === `view-${viewName}`); + }); + document.querySelectorAll(".nav-tab").forEach(tab => { + tab.classList.toggle("active", tab.dataset.view === viewName); + }); + + if (viewName === "dashboard") { + this.updateDashboardCounters(); + } else if (viewName === "trainer") { + if (!this.currentCard) this.pickNextCard(); + this.renderTrainerStage(); + } else if (viewName === "reader") { + this.renderReaderSidebar(); + this.renderReaderMain(); + } else if (viewName === "practice") { + this.renderPracticeSidebar(); + this.renderPracticeStage(); + } else if (viewName === "dictionary") { + this.renderDictionary(); + } + } + + startTrainerMode(mode) { + this.trainerMode = mode; + this.switchView("trainer"); + this.setTrainerMode(mode); + } + + // === DASHBOARD === + updateDashboardCounters() { + const now = Date.now(); + const dueWords = this.vocab.filter(w => w.srs.dueDate <= now); + const learnedWords = this.vocab.filter(w => w.srs.reviews > 0); + const masteredWords = this.vocab.filter(w => w.srs.interval >= 6); + const learningWords = this.vocab.filter(w => w.srs.reviews > 0 && w.srs.interval < 6); + + const dueCount = dueWords.length; + const navBadge = document.getElementById("nav-due-badge"); + const heroDue = document.getElementById("hero-due-count"); + const statDue = document.getElementById("stat-due"); + const statLearned = document.getElementById("stat-learned"); + const statMasteredSub = document.getElementById("stat-mastered-sub"); + const statAccuracy = document.getElementById("stat-accuracy"); + const statSentences = document.getElementById("stat-sentences"); + const streakCount = document.getElementById("streak-count"); + + if (navBadge) navBadge.textContent = dueCount; + if (heroDue) heroDue.textContent = dueCount; + if (statDue) statDue.textContent = dueCount; + if (statLearned) statLearned.textContent = `${learnedWords.length} / ${this.vocab.length}`; + if (statMasteredSub) { + statMasteredSub.textContent = `${masteredWords.length} gemeistert • ${learningWords.length} im Training`; + } + if (statAccuracy) { + const pct = this.stats.totalChecks > 0 + ? Math.round((this.stats.correctChecks / this.stats.totalChecks) * 100) + "%" + : "—"; + statAccuracy.textContent = pct; + } + if (statSentences) statSentences.textContent = this.stats.sentencesWritten; + if (streakCount) streakCount.textContent = this.stats.streak; + } + + refreshDailySpotlight() { + const shuffled = [...this.vocab].sort(() => Math.random() - 0.5); + this.spotlightWords = shuffled.slice(0, 4); + this.renderDailySpotlight(); + } + + renderDailySpotlight() { + const container = document.getElementById("daily-spotlight-list"); + if (!container) return; + + container.innerHTML = this.spotlightWords + .map( + w => ` +
+
+
+ ${this.getGenderBadgeHtml(w.article, w.pos)} + ${w.word} + — ${w.english} +
+
+ „${w.exampleDe}“ +
+
+ +
+ ` + ) + .join(""); + } + + // === ACTIVE RECALL & SRS TRAINER === + setTrainerMode(mode) { + this.trainerMode = mode; + document.querySelectorAll(".mode-pill").forEach(btn => { + btn.classList.toggle("active", btn.dataset.mode === mode); + }); + this.cardRevealed = false; + this.lastAnswerStatus = null; + this.pickNextCard(); + this.renderTrainerStage(); + } + + filterTrainerDeck() { + const sel = document.getElementById("trainer-category-select"); + if (sel) this.trainerFilter = sel.value; + this.cardRevealed = false; + this.lastAnswerStatus = null; + this.pickNextCard(); + this.renderTrainerStage(); + } + + getFilteredTrainerPool() { + let pool = [...this.vocab]; + + // If in gender mode, only include nouns with articles + if (this.trainerMode === "gender") { + pool = pool.filter(w => Boolean(w.article)); + } + + if (this.trainerFilter === "due") { + const now = Date.now(); + const due = pool.filter(w => w.srs.dueDate <= now); + if (due.length > 0) return due; + return pool; // fallback if all due cards finished + } else if (this.trainerFilter !== "all") { + const catPool = pool.filter(w => w.category === this.trainerFilter); + if (catPool.length > 0) return catPool; + } + return pool; + } + + pickNextCard() { + const pool = this.getFilteredTrainerPool(); + if (pool.length === 0) { + this.currentCard = null; + return; + } + // Sort by due date and pick among top 5 so it feels fresh + const sorted = [...pool].sort((a, b) => a.srs.dueDate - b.srs.dueDate); + const candidates = sorted.slice(0, Math.min(5, sorted.length)); + const next = candidates.find(c => !this.currentCard || c.id !== this.currentCard.id) || candidates[0]; + this.currentCard = next; + this.cardRevealed = false; + this.lastAnswerStatus = null; + } + + renderTrainerStage() { + const container = document.getElementById("trainer-stage-container"); + if (!container) return; + + const w = this.currentCard; + if (!w) { + container.innerHTML = ` +
+

🎉 Alle Karten in dieser Auswahl erledigt!

+

Wähle oben eine andere Kategorie oder lies eine Geschichte in der Leseecke.

+ +
+ `; + return; + } + + const fullDisplayWord = w.article + ? `${w.article.replace(" (Pl.)", "")} ${w.word}` + : w.word; + + let promptHtml = ""; + + if (this.trainerMode === "cloze") { + const clozeText = w.clozeSentence || w.exampleDe.replace(new RegExp(w.word, "i"), "___"); + promptHtml = ` +
🧩 Lückensatz im Kontext • Ergänze das fehlende Wort
+
${w.english}
+
+
${clozeText.replace(/___/g, '______')}
+
${w.exampleEn}
+
+ ${ + !this.cardRevealed + ? ` +
+
+ + + +
+ ${this.renderUmlautBar("trainer-answer-input")} +
+ ` + : "" + } + `; + } else if (this.trainerMode === "production") { + const needsArticle = Boolean(w.article); + promptHtml = ` +
✍️ Aktiver Abruf • Übersetze auf Deutsch ${needsArticle ? "(MIT Artikel der/die/das!)" : ""}
+
${w.english}
+
+ Wortart: ${w.pos} • Kontext: „${w.exampleEn}“ +
+ ${ + !this.cardRevealed + ? ` +
+
+ + + +
+ ${this.renderUmlautBar("trainer-answer-input")} +
+ ` + : "" + } + `; + } else if (this.trainerMode === "gender") { + promptHtml = ` +
🎨 Artikel & Plural-Blitz • Welcher Artikel gehört zu diesem Nomen?
+
___ ${w.word}
+
+ Bedeutung: ${w.english} +
+ ${ + !this.cardRevealed + ? ` +
+ + + + +
+ ` + : "" + } + `; + } else { + // Flashcard mode + promptHtml = ` +
🃏 Karteikarte & Wortbau • Überprüfe Artikel, Aussprache & Satz
+
${w.english}
+
+ Versuche das deutsche Wort (inkl. Artikel & Beispielsatz) laut auszusprechen, bevor du umdrehst. +
+ ${ + !this.cardRevealed + ? ` + + ` + : "" + } + `; + } + + let revealHtml = ""; + if (this.cardRevealed) { + const statusClass = this.lastAnswerStatus + ? this.lastAnswerStatus.isCorrect + ? "correct" + : "incorrect" + : ""; + revealHtml = ` +
+ ${ + this.lastAnswerStatus + ? `
+ ${this.lastAnswerStatus.message} +
` + : "" + } +
+
+ ${this.getGenderBadgeHtml(w.article, w.pos)} + ${w.word} + ${w.plural ? `(${w.plural})` : ""} +
+ +
+ +
+ ${w.english} +
+ +
+
„${w.exampleDe}“
+
${w.exampleEn}
+
+ +
+ ${ + w.breakdown + ? `
+ 🧱 Wortbau & Eselsbrücke + ${w.breakdown} +
` + : "" + } + ${ + w.forms + ? `
+ 🔄 Formen & Kollokationen + ${w.forms} +
` + : "" + } + ${ + w.usageTip + ? `
+ 💡 Alltagstipp (Beyond Duolingo) + ${w.usageTip} +
` + : "" + } +
+ +
+ Wie gut saß das Wort aktiv? (SM-2 Spaced Repetition) +
+
+ + + + +
+
+ `; + } + + container.innerHTML = ` +
+
+ ${w.category} • ${w.level} + + Wiederholungen: ${w.srs.reviews} • Status: ${w.srs.status.toUpperCase()} + +
+ ${promptHtml} + ${revealHtml} +
+ `; + + const inputEl = document.getElementById("trainer-answer-input"); + if (inputEl && !this.cardRevealed) { + setTimeout(() => inputEl.focus(), 30); + } + } + + renderUmlautBar(inputId) { + const chars = ["ä", "ö", "ü", "ß", "Ä", "Ö", "Ü"]; + return ` +
+ Umlaute: + ${chars + .map( + ch => + `` + ) + .join("")} +
+ `; + } + + insertUmlaut(inputId, char) { + const input = document.getElementById(inputId); + if (!input) return; + const start = input.selectionStart ?? input.value.length; + const end = input.selectionEnd ?? input.value.length; + const val = input.value; + input.value = val.slice(0, start) + char + val.slice(end); + input.focus(); + input.setSelectionRange(start + 1, start + 1); + if (inputId === "practice-user-input") { + this.updateLiveTargetWordHighlights(input.value); + } + } + + normalizeGerman(str) { + return (str || "") + .trim() + .toLowerCase() + .replace(/\s+/g, " ") + .replace(/[.,!?]/g, ""); + } + + checkTrainerTextAnswer(e) { + e.preventDefault(); + const input = document.getElementById("trainer-answer-input"); + if (!input || !this.currentCard) return; + + const userRaw = input.value.trim(); + if (!userRaw) return; + + const w = this.currentCard; + const userNorm = this.normalizeGerman(userRaw); + + this.stats.totalChecks++; + + if (this.trainerMode === "cloze") { + const targetCloze = this.normalizeGerman(w.clozeAnswer || w.word); + const targetWordOnly = this.normalizeGerman(w.word); + const isCorrect = userNorm === targetCloze || userNorm === targetWordOnly; + if (isCorrect) this.stats.correctChecks++; + + this.lastAnswerStatus = { + isCorrect, + message: isCorrect + ? `✅ Perfekt! „${w.clozeAnswer || w.word}“ passt genau in den Satz.` + : `❌ Fast! Deine Eingabe: „${userRaw}“ — Gesucht war: „${w.clozeAnswer || w.word}“.` + }; + } else if (this.trainerMode === "production") { + const cleanArticle = w.article ? w.article.replace(" (Pl.)", "").toLowerCase() : ""; + const expectedFull = cleanArticle ? `${cleanArticle} ${w.word.toLowerCase()}` : w.word.toLowerCase(); + const expectedWordOnly = w.word.toLowerCase(); + + if (userNorm === expectedFull) { + this.stats.correctChecks++; + const capitalizedCheck = w.article && userRaw.split(" ").pop()[0] === w.word[0]; + this.lastAnswerStatus = { + isCorrect: true, + message: capitalizedCheck + ? `✅ Ausgezeichnet! Wort UND Artikel (${w.article} ${w.word}) komplett richtig!` + : `✅ Richtig (${w.article} ${w.word})! Denk daran, Nomen im Deutschen großzuschreiben.` + }; + } else if (w.article && userNorm.endsWith(expectedWordOnly)) { + // Right noun, wrong or missing article! + this.lastAnswerStatus = { + isCorrect: false, + message: `⚠️ Das Nomen „${w.word}“ stimmt, aber der Artikel ist ${w.article} ${w.word}! (${w.breakdown || "Merk dir Wort + Artikel immer als Einheit."})` + }; + } else { + this.lastAnswerStatus = { + isCorrect: false, + message: `❌ Deine Eingabe: „${userRaw}“ — Richtige Antwort: ${w.article ? w.article + " " : ""}${w.word}.` + }; + } + } + + this.cardRevealed = true; + this.saveState(); + this.speak((w.article ? w.article.replace(" (Pl.)", "") + " " : "") + w.word); + this.renderTrainerStage(); + } + + checkGenderChoice(chosenArticle) { + if (!this.currentCard) return; + const w = this.currentCard; + this.stats.totalChecks++; + + const isCorrect = w.article === chosenArticle; + if (isCorrect) this.stats.correctChecks++; + + this.lastAnswerStatus = { + isCorrect, + message: isCorrect + ? `✅ Genau richtig! Es heißt ${w.article} ${w.word} (${w.plural || "kein Plural"}).` + : `❌ Nicht ganz: Du hast „${chosenArticle}“ gewählt, aber es heißt ${w.article} ${w.word}!` + }; + + this.cardRevealed = true; + this.saveState(); + this.speak(`${w.article.replace(" (Pl.)", "")} ${w.word}`); + this.renderTrainerStage(); + } + + revealCardWithoutAnswer() { + if (!this.currentCard) return; + const w = this.currentCard; + this.cardRevealed = true; + this.lastAnswerStatus = null; + this.speak((w.article ? w.article.replace(" (Pl.)", "") + " " : "") + w.word); + this.renderTrainerStage(); + } + + gradeCurrentCard(grade) { + // grade: 1 (Again), 2 (Hard), 3 (Good), 4 (Easy) + if (!this.currentCard) return; + const w = this.currentCard; + const now = Date.now(); + const DAY_MS = 24 * 60 * 60 * 1000; + + w.srs.reviews = (w.srs.reviews || 0) + 1; + + if (grade === 1) { + w.srs.repetition = 0; + w.srs.interval = 0; + w.srs.dueDate = now + 10 * 60 * 1000; // 10 minutes + w.srs.status = "learning"; + } else if (grade === 2) { + w.srs.repetition += 1; + w.srs.interval = 1; + w.srs.dueDate = now + 1 * DAY_MS; + w.srs.status = "learning"; + } else if (grade === 3) { + w.srs.repetition += 1; + w.srs.interval = w.srs.interval === 0 ? 3 : Math.round(w.srs.interval * w.srs.efactor); + w.srs.dueDate = now + w.srs.interval * DAY_MS; + w.srs.status = w.srs.interval >= 6 ? "mastered" : "learning"; + } else if (grade === 4) { + w.srs.repetition += 1; + w.srs.efactor = Math.min(3.0, w.srs.efactor + 0.15); + w.srs.interval = w.srs.interval === 0 ? 7 : Math.round(w.srs.interval * w.srs.efactor * 1.3); + w.srs.dueDate = now + w.srs.interval * DAY_MS; + w.srs.status = "mastered"; + } + + this.saveState(); + this.pickNextCard(); + this.renderTrainerStage(); + } + + // === INTERACTIVE GRADED READER (LESEECKE) === + renderReaderSidebar() { + const container = document.getElementById("story-list-container"); + if (!container) return; + + const stories = this.getAllStories(); + container.innerHTML = stories + .map( + s => ` +
+
+ ${s.type} + ${s.level} • ${s.readTime} +
+
${s.title}
+
${s.subtitle}
+
+ ` + ) + .join(""); + } + + selectStory(storyId) { + this.activeStoryId = storyId; + this.selectedReaderWord = null; + this.renderReaderSidebar(); + this.renderReaderMain(); + } + + toggleReaderTranslation() { + this.showReaderTranslation = !this.showReaderTranslation; + this.renderReaderMain(); + } + + readWholeStoryAloud(rate = 0.9) { + const story = this.getAllStories().find(s => s.id === this.activeStoryId); + if (!story) return; + const fullText = story.paragraphs.map(p => p.de).join(" "); + this.speak(fullText, rate); + } + + tokenizeParagraphToInteractiveHtml(deText, story) { + // Split keeping punctuation and words separate + const tokens = deText.split(/([A-Za-zÄÖÜäöüß]+)/g); + return tokens + .map(token => { + if (!/^[A-Za-zÄÖÜäöüß]+$/.test(token)) { + return token; + } + const lookup = this.lookupWordInfo(token, story); + const isTarget = lookup && lookup.isTarget; + const inDeck = lookup && lookup.inDeck; + const classes = ["word-token"]; + if (isTarget) classes.push("target-vocab"); + else if (inDeck) classes.push("in-deck"); + + return `${token}`; + }) + .join(""); + } + + lookupWordInfo(rawToken, story) { + const clean = rawToken.trim(); + const lower = clean.toLowerCase(); + + // 1. Check story's own glossary + if (story && story.glossary && story.glossary[clean]) { + const g = story.glossary[clean]; + const existingDeck = this.vocab.find(v => v.word.toLowerCase() === g.lemma.toLowerCase()); + return { + surface: clean, + lemma: g.lemma, + article: g.article, + plural: g.plural, + pos: g.pos, + english: g.english, + note: g.note, + isTarget: true, + inDeck: Boolean(existingDeck) + }; + } + + // 2. Check user's SRS vocabulary database (exact or stem match) + const deckMatch = this.vocab.find( + v => + v.word.toLowerCase() === lower || + (v.plural && v.plural.toLowerCase().endsWith(lower)) || + (v.word.length >= 5 && lower.startsWith(v.word.toLowerCase().slice(0, 5))) + ); + if (deckMatch) { + return { + surface: clean, + lemma: deckMatch.word, + article: deckMatch.article, + plural: deckMatch.plural, + pos: deckMatch.pos, + english: deckMatch.english, + note: deckMatch.usageTip || deckMatch.breakdown, + isTarget: story?.targetVocabIds?.includes(deckMatch.id) || false, + inDeck: true + }; + } + + // 3. Check other stories' glossaries + for (const st of window.GRADED_STORIES) { + if (st.glossary && st.glossary[clean]) { + const g = st.glossary[clean]; + return { + surface: clean, + lemma: g.lemma, + article: g.article, + plural: g.plural, + pos: g.pos, + english: g.english, + note: g.note, + isTarget: false, + inDeck: false + }; + } + } + + // 4. Check common German dictionary + if (window.COMMON_GERMAN_DICT[lower]) { + const c = window.COMMON_GERMAN_DICT[lower]; + return { + surface: clean, + lemma: c.lemma, + article: c.article, + plural: c.plural, + pos: c.pos, + english: c.english, + note: c.note, + isTarget: false, + inDeck: false + }; + } + + // 5. Smart Morphological Analyzer for any clicked German word + const isCapitalized = clean[0] === clean[0].toUpperCase() && clean[0] !== clean[0].toLowerCase(); + let guessedArticle = null; + let morphNote = "Klicke auf 'Ins SRS-Deck speichern', um dieses Wort zu trainieren."; + + if (isCapitalized) { + if (lower.endsWith("ung") || lower.endsWith("heit") || lower.endsWith("keit") || lower.endsWith("schaft") || lower.endsWith("tät") || lower.endsWith("ion")) { + guessedArticle = "die"; + morphNote = "Suffix-Regel: Deutsche Nomen auf -ung, -heit, -keit, -schaft, -ion sind immer feminin (die)!"; + } else if (lower.endsWith("chen") || lower.endsWith("lein") || lower.endsWith("ment") || lower.endsWith("um")) { + guessedArticle = "das"; + morphNote = "Suffix-Regel: Nomen auf -chen, -lein, -ment, -um sind fast immer neutrum (das)!"; + } + } + + return { + surface: clean, + lemma: clean, + article: guessedArticle, + plural: null, + pos: isCapitalized ? "noun" : "word", + english: "Klicke zum Nachschlagen oder füge deine eigene Übersetzung hinzu", + note: morphNote, + isTarget: false, + inDeck: false + }; + } + + inspectReaderWord(rawToken) { + const story = this.getAllStories().find(s => s.id === this.activeStoryId); + this.selectedReaderWord = this.lookupWordInfo(rawToken, story); + this.speak(rawToken); + this.renderReaderMain(); + } + + saveInspectedWordToDeck() { + const info = this.selectedReaderWord; + if (!info) return; + + const existing = this.vocab.find(v => v.word.toLowerCase() === info.lemma.toLowerCase()); + if (existing) { + existing.srs.dueDate = Date.now() - 1000; + this.saveState(); + this.showToast(`„${existing.word}“ wurde im SRS-Trainer als sofort fällig markiert!`); + this.renderReaderMain(); + return; + } + + const story = this.getAllStories().find(s => s.id === this.activeStoryId); + const matchingParagraph = story?.paragraphs.find(p => p.de.includes(info.surface)) || story?.paragraphs[0]; + + const newEntry = this.createVocabEntry({ + id: "custom_" + Date.now(), + word: info.lemma, + article: info.article, + plural: info.plural, + pos: info.pos || "word", + level: "A2/B1", + category: "Saved from Reading", + english: info.english.startsWith("Klicke zum") ? "Aus Leseecke gespeichert" : info.english, + breakdown: info.note, + forms: null, + exampleDe: matchingParagraph ? matchingParagraph.de : `Ich habe das Wort „${info.lemma}“ im Text gelesen.`, + exampleEn: matchingParagraph ? matchingParagraph.en : "", + clozeSentence: matchingParagraph + ? matchingParagraph.de.replace(info.surface, "___") + : `Das Wort ___ ist sehr nützlich.`, + clozeAnswer: info.surface, + usageTip: info.note + }); + + this.vocab.unshift(newEntry); + this.selectedReaderWord.inDeck = true; + this.saveState(); + this.showToast(`➕ „${info.lemma}“ zum SRS-Trainer hinzugefügt!`); + this.renderReaderMain(); + } + + answerStoryQuiz(storyId, optionIndex) { + this.quizSelection[storyId] = optionIndex; + this.renderReaderMain(); + } + + renderReaderMain() { + const container = document.getElementById("reader-content-area"); + if (!container) return; + + const story = this.getAllStories().find(s => s.id === this.activeStoryId) || this.getAllStories()[0]; + if (!story) return; + + const paragraphsHtml = story.paragraphs + .map( + (p, idx) => ` +
+
+ ${this.tokenizeParagraphToInteractiveHtml(p.de, story)} + +
+ ${ + this.showReaderTranslation && p.en + ? `
${p.en}
` + : "" + } +
+ ` + ) + .join(""); + + let inspectorHtml = ""; + if (this.selectedReaderWord) { + const w = this.selectedReaderWord; + inspectorHtml = ` +
+
+
+
+ ${this.getGenderBadgeHtml(w.article, w.pos)} + ${w.lemma} + ${w.plural ? `(${w.plural})` : ""} + +
+
+ ${w.english} +
+ ${ + w.note + ? `
💡 ${w.note}
` + : "" + } +
+
+ +
+
+
+ `; + } else { + inspectorHtml = ` +
+ 👆 Tipp: Klicke oben im Text auf ein beliebiges Wort (besonders die gelb markierten Zielwörter), um Artikel, Stammform und Aussprache zu sehen! +
+ `; + } + + let quizHtml = ""; + if (story.comprehensionQuestion) { + const q = story.comprehensionQuestion; + const chosen = this.quizSelection[story.id]; + quizHtml = ` +
+
🧠 Verständnis-Check zur Geschichte
+
${q.question}
+
+ ${q.options + .map((opt, idx) => { + let bg = "var(--bg-main)"; + let border = "var(--border-color)"; + if (chosen !== undefined) { + if (idx === q.correctIndex) { + bg = "var(--success-bg)"; + border = "#34d399"; + } else if (idx === chosen) { + bg = "var(--danger-bg)"; + border = "#fca5a5"; + } + } + return ` + + `; + }) + .join("")} +
+ ${ + chosen !== undefined + ? `
+ ${chosen === q.correctIndex ? "✅ Richtig! " : "❌ Nicht ganz: "}${q.explanation} +
` + : "" + } +
+ `; + } + + container.innerHTML = ` +
+
+ ${story.type} • ${story.level} +

${story.title}

+

${story.subtitle}

+
+
+ + + +
+
+ + ${paragraphsHtml} + ${inspectorHtml} + ${quizHtml} + `; + } + + // === CONVERSATION & WRITING PRACTICE (SPRACHPRAXIS) === + initScenarioStates() { + window.CONVERSATION_SCENARIOS.forEach(scen => { + if (!this.scenarioState[scen.id]) { + this.resetScenario(scen.id, false); + } + }); + } + + resetScenario(scenarioId, reRender = true) { + const scen = window.CONVERSATION_SCENARIOS.find(s => s.id === scenarioId); + if (!scen) return; + this.scenarioState[scenarioId] = { + turnIndex: 0, + usedWords: [], + completed: false, + messages: [ + { + role: "partner", + de: scen.turns[0].partnerDe, + en: scen.turns[0].partnerEn + } + ] + }; + if (reRender) this.renderPracticeStage(); + } + + renderPracticeSidebar() { + const container = document.getElementById("scenario-list-container"); + if (!container) return; + + container.innerHTML = window.CONVERSATION_SCENARIOS.map(scen => { + const st = this.scenarioState[scen.id]; + const usedCount = st ? st.usedWords.length : 0; + return ` +
+
+ ${scen.avatar} ${scen.rolePartner} + ${usedCount}/${scen.targetWords.length} Zielwörter +
+
${scen.title}
+
Level ${scen.level}
+
+ `; + }).join(""); + } + + selectScenario(scenarioId) { + this.activeScenarioId = scenarioId; + this.renderPracticeSidebar(); + this.renderPracticeStage(); + } + + detectTargetWordsInText(text, scen) { + const lower = (text || "").toLowerCase(); + const matched = []; + scen.targetWords.forEach(tw => { + const hit = tw.stems.some(stem => lower.includes(stem.toLowerCase())); + if (hit) matched.push(tw.word); + }); + return matched; + } + + updateLiveTargetWordHighlights(currentInputText) { + const scen = window.CONVERSATION_SCENARIOS.find(s => s.id === this.activeScenarioId); + if (!scen) return; + const st = this.scenarioState[scen.id]; + const newlyTyped = this.detectTargetWordsInText(currentInputText, scen); + const combined = new Set([...st.usedWords, ...newlyTyped]); + + document.querySelectorAll(".target-word-chip").forEach(chip => { + const w = chip.dataset.word; + const isUsed = combined.has(w); + chip.classList.toggle("used", isUsed); + const iconSpan = chip.querySelector(".chip-check"); + if (iconSpan) iconSpan.textContent = isUsed ? "✅" : "⭕"; + }); + } + + analyzeGermanSentence(userText, newlyUsedTargetWords, currentTurn) { + const tips = []; + const trimmed = userText.trim(); + + // Celebrate target vocabulary usage + if (newlyUsedTargetWords.length > 0) { + tips.push(`🎯 Zielwörter aktiv eingebaut: ${newlyUsedTargetWords.join(", ")}!`); + } else { + tips.push(`💡 Tipp: Versuche in deiner Antwort mindestens eines der oben gelisteten Zielwörter zu verwenden.`); + } + + // Check subordinate clause connectors (weil, obwohl, dass) + const lower = trimmed.toLowerCase(); + if (lower.includes("weil ") || lower.includes("obwohl ") || lower.includes("dass ")) { + tips.push(`🧱 Nebensatz-Struktur genutzt (weil/obwohl/dass): Super für B1-Niveau! Denk daran, dass das konjugierte Verb im Nebensatz ganz ans Ende wandert.`); + } + + // Check V2 connectors (deshalb, trotzdem) + if (lower.includes("deshalb ") || lower.includes("trotzdem ")) { + const badOrder = /deshalb\s+ich\b|trotzdem\s+ich\b/i.test(trimmed); + if (badOrder) { + tips.push(`⚠️ Wortstellung nach „deshalb / trotzdem“: Im Deutschen steht das Verb direkt auf Position 2: „Deshalb möchte ich...“ (nicht „Deshalb ich möchte“).`); + } else { + tips.push(`✨ Starker Konnektor (deshalb/trotzdem) mit richtiger Verb-Zweitstellung!`); + } + } + + if (currentTurn && currentTurn.sampleResponse) { + tips.push(`🇩🇪 Muster-Formulierung zum Vergleich: „${currentTurn.sampleResponse}“`); + } + + return tips.join("
"); + } + + async handlePracticeSubmit(e) { + e.preventDefault(); + const input = document.getElementById("practice-user-input"); + if (!input) return; + const text = input.value.trim(); + if (!text) return; + + const scen = window.CONVERSATION_SCENARIOS.find(s => s.id === this.activeScenarioId); + const st = this.scenarioState[scen.id]; + const currentTurn = scen.turns[st.turnIndex]; + + const matchedWords = this.detectTargetWordsInText(text, scen); + matchedWords.forEach(w => { + if (!st.usedWords.includes(w)) st.usedWords.push(w); + }); + + st.messages.push({ + role: "user", + de: text + }); + + this.stats.sentencesWritten++; + input.value = ""; + + // Generate feedback (either via optional Live AI Key or instant built-in Smart Coach) + let feedbackHtml = this.analyzeGermanSentence(text, matchedWords, currentTurn); + if (this.settings.aiProvider !== "builtin" && this.settings.apiKey) { + const aiCoachNote = await this.fetchOptionalAiFeedback(text, scen, currentTurn); + if (aiCoachNote) { + feedbackHtml = `🤖 Live-KI-Feedback: ${aiCoachNote}
` + feedbackHtml; + } + } + + st.messages.push({ + role: "feedback", + html: feedbackHtml + }); + + // Advance to next partner turn if available + if (st.turnIndex + 1 < scen.turns.length) { + st.turnIndex += 1; + const nextTurn = scen.turns[st.turnIndex]; + st.messages.push({ + role: "partner", + de: nextTurn.partnerDe, + en: nextTurn.partnerEn + }); + this.speak(nextTurn.partnerDe); + } else { + st.completed = true; + st.messages.push({ + role: "partner", + de: "Vielen Dank für das Gespräch! Du hast die Situation erfolgreich auf Deutsch gemeistert. 🎉", + en: "Thank you for the conversation! You successfully mastered the situation in German." + }); + } + + this.saveState(); + this.renderPracticeSidebar(); + this.renderPracticeStage(); + } + + async fetchOptionalAiFeedback(userGerman, scen, currentTurn) { + try { + const prompt = `Du bist ein freundlicher Deutschlehrer für Niveau A2/B1. Situation: ${scen.situation}. Gesprächspartner sagte: "${currentTurn?.partnerDe || ""}". Der Lernende antwortete: "${userGerman}". Gib in 1-2 kurzen Sätzen konstruktives Feedback zu Grammatik/Wortwahl und nenne bei Fehlern eine natürliche Korrektur.`; + if (this.settings.aiProvider === "gemini") { + const res = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${encodeURIComponent(this.settings.apiKey)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }] + }) + } + ); + const data = await res.json(); + return data?.candidates?.[0]?.content?.parts?.[0]?.text || null; + } else if (this.settings.aiProvider === "openai") { + const res = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.settings.apiKey}` + }, + body: JSON.stringify({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + max_tokens: 150 + }) + }); + const data = await res.json(); + return data?.choices?.[0]?.message?.content || null; + } + } catch (err) { + console.warn("Optional AI request failed, using built-in coach:", err); + } + return null; + } + + insertSampleHint() { + const scen = window.CONVERSATION_SCENARIOS.find(s => s.id === this.activeScenarioId); + const st = this.scenarioState[scen.id]; + const currentTurn = scen?.turns[st.turnIndex]; + const input = document.getElementById("practice-user-input"); + if (input && currentTurn) { + input.value = currentTurn.sampleResponse; + input.focus(); + this.updateLiveTargetWordHighlights(input.value); + } + } + + renderPracticeStage() { + const container = document.getElementById("practice-stage-area"); + if (!container) return; + + const scen = window.CONVERSATION_SCENARIOS.find(s => s.id === this.activeScenarioId); + if (!scen) return; + const st = this.scenarioState[scen.id]; + const currentTurn = scen.turns[st.turnIndex]; + + const targetChipsHtml = scen.targetWords + .map(tw => { + const isUsed = st.usedWords.includes(tw.word); + return ` +
+ ${isUsed ? "✅" : "⭕"} + ${tw.word} + (${tw.hint}) +
+ `; + }) + .join(""); + + const messagesHtml = st.messages + .map(m => { + if (m.role === "partner") { + return ` +
+
+ ${scen.avatar} ${scen.rolePartner} + +
+
${m.de}
+ ${m.en ? `
${m.en}
` : ""} +
+ `; + } else if (m.role === "user") { + return ` +
+
Du (Aktiv produziert)
+
${m.de}
+
+ `; + } else { + return `
${m.html}
`; + } + }) + .join(""); + + container.innerHTML = ` +
+
+ Rollenspiel • ${scen.level} +

${scen.avatar} ${scen.title}

+

${scen.situation}

+
+ +
+ +
+
+ + 🎯 Deine 5 Zielwörter für dieses Gespräch (${st.usedWords.length}/${scen.targetWords.length} genutzt) + +
+
+ ${targetChipsHtml} +
+
+ +
+ ${messagesHtml} +
+ + ${ + !st.completed + ? ` +
+
+ Aufgabe (Schritt ${st.turnIndex + 1}/${scen.turns.length}): ${currentTurn.promptHint} +
+
+
+ + +
+
+ ${this.renderUmlautBar("practice-user-input")} + +
+
+
+ ` + : ` +
+ 🎉 Szenario abgeschlossen! Du hast ${st.usedWords.length} von ${scen.targetWords.length} Zielwörtern aktiv im Kontext verwendet. +
+ +
+
+ ` + } + `; + + const chatBox = document.getElementById("chat-stream-box"); + if (chatBox) { + chatBox.scrollTop = chatBox.scrollHeight; + } + } + + // === VOCABULARY BANK (WÖRTERBUCH) === + renderDictionary() { + const grid = document.getElementById("dictionary-grid"); + if (!grid) return; + + const query = (document.getElementById("dict-search-input")?.value || "").trim().toLowerCase(); + const genderFilter = document.getElementById("dict-gender-filter")?.value || "all"; + const categoryFilter = document.getElementById("dict-category-filter")?.value || "all"; + + const filtered = this.vocab.filter(w => { + if (query) { + const hay = `${w.word} ${w.english} ${w.exampleDe} ${w.category}`.toLowerCase(); + if (!hay.includes(query)) return false; + } + if (genderFilter !== "all") { + if (genderFilter === "verb") { + if (w.article) return false; + } else if (!w.article || !w.article.startsWith(genderFilter)) { + return false; + } + } + if (categoryFilter !== "all" && w.category !== categoryFilter) { + return false; + } + return true; + }); + + grid.innerHTML = filtered + .map( + w => ` +
+
+
+
+ ${this.getGenderBadgeHtml(w.article, w.pos)} + ${w.word} +
+ +
+ ${w.plural ? `
Plural: ${w.plural}
` : ""} +
${w.english}
+
+ „${w.exampleDe}“ +
+ ${w.breakdown ? `
🧱 ${w.breakdown}
` : ""} +
+
+ ${w.category} + SRS: ${w.srs.status.toUpperCase()} (${w.srs.reviews}x) +
+
+ ` + ) + .join(""); + } + + // === MODALS & CUSTOM CONTENT === + openModal(id) { + const el = document.getElementById(id); + if (el) el.classList.add("open"); + if (id === "settings-modal") { + document.getElementById("settings-ai-provider").value = this.settings.aiProvider || "builtin"; + document.getElementById("settings-api-key").value = this.settings.apiKey || ""; + } + } + + closeModal(id) { + const el = document.getElementById(id); + if (el) el.classList.remove("open"); + } + + handleAddCustomWord(e) { + e.preventDefault(); + const article = document.getElementById("custom-article").value || null; + const word = document.getElementById("custom-word").value.trim(); + const english = document.getElementById("custom-english").value.trim(); + const plural = document.getElementById("custom-plural").value.trim() || null; + const exampleDe = document.getElementById("custom-example-de").value.trim(); + const exampleEn = document.getElementById("custom-example-en").value.trim(); + const tip = document.getElementById("custom-tip").value.trim(); + + if (!word || !english || !exampleDe) return; + + const newWord = this.createVocabEntry({ + id: "user_" + Date.now(), + word, + article, + plural, + pos: article ? "noun" : "verb / expression", + level: "A2/B1", + category: "Saved from Reading", + english, + breakdown: tip || null, + forms: plural, + exampleDe, + exampleEn, + clozeSentence: exampleDe.replace(new RegExp(word, "i"), "___"), + clozeAnswer: word, + usageTip: tip || null + }); + + this.vocab.unshift(newWord); + this.saveState(); + this.closeModal("add-word-modal"); + e.target.reset(); + this.showToast(`✅ „${word}“ wurde zu deinem Wortschatz-Trainer hinzugefügt!`); + if (this.currentView === "dictionary") this.renderDictionary(); + } + + handleImportCustomStory(e) { + e.preventDefault(); + const title = document.getElementById("import-story-title").value.trim(); + const body = document.getElementById("import-story-body").value.trim(); + if (!title || !body) return; + + const rawParagraphs = body + .split(/\n\s*\n/) + .map(p => p.trim()) + .filter(Boolean); + + const newStory = { + id: "custom_story_" + Date.now(), + title, + subtitle: "Eigener importierter Text (Jedes Wort ist klickbar)", + level: "A2 / B1", + type: "Eigener Text", + readTime: "3 min", + targetVocabIds: [], + paragraphs: rawParagraphs.map(p => ({ de: p, en: "" })), + glossary: {} + }; + + this.customStories.unshift(newStory); + this.activeStoryId = newStory.id; + this.saveState(); + this.closeModal("import-text-modal"); + e.target.reset(); + this.switchView("reader"); + this.showToast(`📄 „${title}“ erfolgreich in der Leseecke geöffnet!`); + } + + saveSettings() { + this.settings.aiProvider = document.getElementById("settings-ai-provider").value; + this.settings.apiKey = document.getElementById("settings-api-key").value.trim(); + this.saveState(); + this.closeModal("settings-modal"); + this.showToast("⚙️ Einstellungen gespeichert!"); + } + + exportProgress() { + const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(this.vocab, null, 2)); + const dlAnchor = document.createElement("a"); + dlAnchor.setAttribute("href", dataStr); + dlAnchor.setAttribute("download", "wortkraft_wortschatz_backup.json"); + document.body.appendChild(dlAnchor); + dlAnchor.click(); + dlAnchor.remove(); + this.showToast("💾 Backup heruntergeladen!"); + } + + resetProgress() { + if (!confirm("Möchtest du deinen Lernfortschritt wirklich auf den Anfang zurücksetzen?")) return; + localStorage.removeItem(STORAGE_KEY); + window.location.reload(); + } + + showToast(message) { + const toast = document.getElementById("toast"); + if (!toast) return; + toast.textContent = message; + toast.classList.add("show"); + clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => { + toast.classList.remove("show"); + }, 3200); + } + + escapeJs(str) { + return (str || "") + .replace(/\\/g, "\\\\") + .replace(/'/g, "\\'") + .replace(/"/g, """) + .replace(/\n/g, " "); + } + + renderAll() { + this.updateDashboardCounters(); + this.renderDailySpotlight(); + } +} + +window.addEventListener("DOMContentLoaded", () => { + window.app = new WortKraftApp(); +}); diff --git a/data/scenarios.js b/data/scenarios.js new file mode 100644 index 0000000..6abdd92 --- /dev/null +++ b/data/scenarios.js @@ -0,0 +1,145 @@ +// Interactive Conversation & Writing Missions (Sprachpraxis) +// Forces active production of target A2/B1 vocabulary in realistic German scenarios. + +window.CONVERSATION_SCENARIOS = [ + { + id: "scen-arzt", + title: "Arzttermin verschieben & Krankschreibung", + rolePartner: "Praxis Dr. Lindner (Herr Krug)", + avatar: "🩺", + level: "A2", + situation: "You have a sore throat and fever today. You need to call Dr. Lindner's office, postpone tomorrow's routine checkup or book an urgent visit today, ask about a sick note for work, and mention your health insurance card.", + targetWords: [ + { word: "vereinbaren", hint: "to schedule (einen Termin vereinbaren)", stems: ["vereinbar", "vereinbart"] }, + { word: "verschieben", hint: "to postpone (auf nächste Woche verschieben)", stems: ["verschieb", "verschob"] }, + { word: "Krankenkasse", hint: "health insurance (die Krankenkasse)", stems: ["krankenkasse", "versichertenkarte"] }, + { word: "Krankschreibung", hint: "sick note for work (die Krankschreibung)", stems: ["krankschreibung", "krankschreiben"] }, + { word: "Rezept", hint: "prescription (das Rezept)", stems: ["rezept"] } + ], + turns: [ + { + partnerDe: "Guten Morgen, Praxis Dr. Lindner, Krug am Apparat. Wie kann ich Ihnen heute helfen?", + partnerEn: "Good morning, Dr. Lindner's office, Krug speaking. How can I help you today?", + promptHint: "Greet Herr Krug, explain that you feel sick today, and ask to schedule (vereinbaren) an urgent appointment today OR postpone (verschieben) your other appointment.", + sampleResponse: "Guten Morgen! Ich bin leider krank geworden und möchte heute kurzfristig einen Termin vereinbaren oder meinen alten Termin verschieben." + }, + { + partnerDe: "Oh, das tut mir leid! Sie können heute um 11:30 Uhr vorbeikommen. Brauchen Sie auch etwas für Ihren Arbeitgeber, und haben Sie Ihre Unterlagen dabei?", + partnerEn: "Oh, I'm sorry to hear that! You can come by today at 11:30. Do you also need something for your employer, and do you have your documents with you?", + promptHint: "Say that you bring your card from the health insurance (Krankenkasse) and need a sick note (Krankschreibung) for work.", + sampleResponse: "Ja, ich bringe meine Karte von der Krankenkasse mit, weil ich eine Krankschreibung für meine Arbeit brauche." + }, + { + partnerDe: "Alles klar, das notiere ich so für Frau Dr. Lindner. Brauchen Sie sonst noch etwas aus der Apotheke?", + partnerEn: "All right, I'll note that down for Dr. Lindner. Do you need anything else from the pharmacy?", + promptHint: "Ask if the doctor can also give you a prescription (Rezept) for medicine after examining you.", + sampleResponse: "Könnte die Ärztin mir nach der Untersuchung vielleicht auch ein Rezept für Medikamente geben? Vielen Dank!" + } + ] + }, + { + id: "scen-feierabend", + title: "Feierabend & Wochenendpläne schmieden", + rolePartner: "Lukas (Dein Kollege im Büro)", + avatar: "🍻", + level: "A2 / B1", + situation: "It's Friday at 5:00 PM! Your colleague Lukas wants to plan a weekend trip or evening out. Make suggestions, divide up responsibilities, and use natural connectors.", + targetWords: [ + { word: "Feierabend", hint: "end of workday (Feierabend machen)", stems: ["feierabend"] }, + { word: "vorschlagen", hint: "to suggest (ich schlage vor... / Vorschlag)", stems: ["vorschlag", "schlage", "schlägst", "vorgeschlagen"] }, + { word: "einverstanden", hint: "agreed (ich bin einverstanden)", stems: ["einverstanden"] }, + { word: "sich kümmern um", hint: "to take care of (ich kümmere mich um...)", stems: ["kümmer", "kuemmer"] }, + { word: "sich freuen auf", hint: "to look forward to (ich freue mich auf...)", stems: ["freu"] } + ], + turns: [ + { + partnerDe: "Puh, schon siebzehn Uhr! Wollen wir langsam den Computer ausmachen? Was hast du heute oder am Wochenende vor?", + partnerEn: "Phew, already five o'clock! Shall we slowly turn off the computer? What are your plans for today or the weekend?", + promptHint: "Say it's time to call it a day (Feierabend machen) and suggest (vorschlagen) doing something together this weekend.", + sampleResponse: "Ja, lass uns endlich Feierabend machen! Ich schlage vor, dass wir am Samstag zusammen einen Ausflug an den See machen." + }, + { + partnerDe: "Super Idee! Wollen wir mit dem Zug fahren und ein kleines Picknick mitnehmen? Wer organisiert was?", + partnerEn: "Great idea! Shall we go by train and bring a little picnic? Who is organizing what?", + promptHint: "Say you agree (einverstanden) and tell Lukas what you will take care of (sich kümmern um + Accusative, e.g., die Zugtickets oder das Essen).", + sampleResponse: "Ich bin sofort einverstanden! Ich kümmere mich gerne um die Zugtickets und Getränke." + }, + { + partnerDe: "Perfekt, dann bringe ich frisches Brot und Käse vom Markt mit. Hoffentlich spielt das Wetter mit!", + partnerEn: "Perfect, then I'll bring fresh bread and cheese from the market. Hopefully the weather cooperates!", + promptHint: "Say that you are really looking forward to (sich freuen auf) the trip, even if (obwohl/trotzdem) it might rain a little.", + sampleResponse: "Das klingt toll! Ich freue mich schon sehr auf Samstag, obwohl es vielleicht kurz regnen soll." + } + ] + }, + { + id: "scen-wohnung", + title: "Wohnungsbesichtigung & Anmeldung", + rolePartner: "Frau Bergmann (Vermieterin)", + avatar: "🔑", + level: "A2 / B1", + situation: "You are viewing a bright two-room apartment in Berlin. Ask the landlady about the rent and utilities, show her your application paperwork, and ask about the confirmation needed for your city registration.", + targetWords: [ + { word: "Besichtigung", hint: "viewing (die Besichtigung)", stems: ["besichtigung", "besichtigen"] }, + { word: "Miete", hint: "rent (die Miete / Warmmiete)", stems: ["miete", "kaltmiete", "warmmiete"] }, + { word: "Nebenkosten", hint: "utility costs (die Nebenkosten)", stems: ["nebenkosten"] }, + { word: "Unterlagen", hint: "documents/paperwork (die Unterlagen)", stems: ["unterlagen"] }, + { word: "Anmeldung", hint: "city registration (die Anmeldung)", stems: ["anmeldung", "anmelden"] } + ], + turns: [ + { + partnerDe: "Hallo und herzlich willkommen! Schön, dass Sie es pünktlich in den dritten Stock geschafft haben. Wie gefällt Ihnen der erste Eindruck?", + partnerEn: "Hello and welcome! Nice that you made it to the third floor on time. How do you like the first impression?", + promptHint: "Thank her for the viewing (Besichtigung) and ask a question about the rent (Miete) and utility costs (Nebenkosten).", + sampleResponse: "Vielen Dank für die Einladung zur Besichtigung! Die Wohnung ist sehr hell. Wie hoch sind denn die Miete und die monatlichen Nebenkosten?" + }, + { + partnerDe: "Die Kaltmiete beträgt 780 Euro plus 190 Euro Nebenkosten für Heizung und Wasser. Haben Sie denn schon alle wichtigen Papiere dabei?", + partnerEn: "The base rent is 780 euros plus 190 euros utilities for heating and water. Did you bring all the important papers with you?", + promptHint: "Confirm that you brought all your application documents (Unterlagen) in a folder or by email.", + sampleResponse: "Ja, auf jeden Fall! Ich habe heute alle meine Unterlagen in dieser Mappe mitgebracht." + }, + { + partnerDe: "Sehr ordentlich! Wenn wir den Mietvertrag nächste Woche unterschreiben, ab wann möchten Sie einziehen?", + partnerEn: "Very tidy! If we sign the lease next week, starting when would you like to move in?", + promptHint: "Say when you'd like to move in and ask for the landlord confirmation for your official registration (Anmeldung) at the Bürgeramt.", + sampleResponse: "Ich möchte gerne am ersten Mai einziehen und brauche dann bitte auch die Bestätigung für meine Anmeldung beim Bürgeramt." + } + ] + }, + { + id: "scen-umtausch", + title: "Reklamation im Geschäft: Jacke umtauschen", + rolePartner: "Herr Yilmaz (Kundenservice im Kaufhaus)", + avatar: "🛍️", + level: "A2 / B1", + situation: "You bought a winter jacket yesterday, but the zipper is broken (kaputt). You stand in line at customer service with your receipt to exchange it or get your money back.", + targetWords: [ + { word: "umtauschen", hint: "to exchange (umtauschen / der Umtausch)", stems: ["umtausch", "tausche", "umgetauscht"] }, + { word: "Kassenbon", hint: "receipt (der Kassenbon)", stems: ["kassenbon", "bon", "quittung"] }, + { word: "obwohl", hint: "although (obwohl + verb at the end)", stems: ["obwohl"] }, + { word: "deshalb", hint: "therefore / that's why (deshalb + verb in pos 2)", stems: ["deshalb", "deswegen"] }, + { word: "Verständnis", hint: "understanding (das Verständnis)", stems: ["verständnis", "verstaendnis"] } + ], + turns: [ + { + partnerDe: "Guten Tag! Entschuldigen Sie die kurze Wartezeit an der Kasse. Was kann ich für Sie tun?", + partnerEn: "Good day! Excuse the short wait at the register. What can I do for you?", + promptHint: "Explain that the zipper (der Reißverschluss) on your new jacket is broken, even though (obwohl) you just bought it yesterday, and you want to exchange (umtauschen) it.", + sampleResponse: "Guten Tag! Ich möchte diese Jacke gerne umtauschen, weil der Reißverschluss kaputt ist, obwohl ich sie erst gestern gekauft habe." + }, + { + partnerDe: "Oh, das darf natürlich nicht passieren! Haben Sie den Kaufbeleg von gestern noch dabei?", + partnerEn: "Oh, that shouldn't happen of course! Do you still have the proof of purchase from yesterday with you?", + promptHint: "Show him the receipt (Kassenbon) and use 'deshalb' to explain whether you want a new jacket in size M or your money back.", + sampleResponse: "Ja, hier ist mein Kassenbon. Ich fahre morgen in den Urlaub, deshalb hätte ich gerne sofort eine neue Jacke in Größe M." + }, + { + partnerDe: "Ich habe gerade im Lager nachgeschaut: Wir haben genau noch ein Stück in Größe M da! Hier, bitte schön.", + partnerEn: "I just checked the stockroom: we have one last piece in size M! Here you go.", + promptHint: "Thank him for his help and understanding (Verständnis).", + sampleResponse: "Super, jetzt funktioniert alles perfekt. Vielen Dank für Ihre schnelle Hilfe und Ihr Verständnis!" + } + ] + } +]; diff --git a/data/stories.js b/data/stories.js new file mode 100644 index 0000000..d0ad42c --- /dev/null +++ b/data/stories.js @@ -0,0 +1,301 @@ +// Graded A2/B1 Reading Library (Leseecke) + Rich Contextual Dictionary for Tap-to-Translate + +window.GRADED_STORIES = [ + { + id: "story-buergeramt", + title: "Beim Bürgeramt: Das Abenteuer Anmeldung", + subtitle: "How to survive your first German city registration appointment", + level: "A2", + type: "Practical Situation", + readTime: "3 min", + targetVocabIds: ["v3", "v6", "v1", "v14", "v27"], + paragraphs: [ + { + de: "Wer in Deutschland in eine neue Wohnung zieht, muss sich innerhalb von zwei Wochen beim Bürgeramt anmelden. Ohne diese offizielle Anmeldung bekommt man oft kein Bankkonto und keinen Internetvertrag.", + en: "Anyone moving into a new apartment in Germany must register at the civic office within two weeks. Without this official registration, you often cannot get a bank account or an internet contract." + }, + { + de: "Am Montagmorgen gehe ich zehn Minuten vor meinem Termin ins Rathaus. Vor dem Eingang steht schon eine kleine Schlange, obwohl man heute nur nach vorheriger Vereinbarung reinkommt.", + en: "On Monday morning, I go into the town hall ten minutes before my appointment. In front of the entrance there is already a small queue, even though today you can only get in by prior appointment." + }, + { + de: "Im Wartezimmer überprüfe ich noch einmal nervös meine Unterlagen: den Reisepass, das ausgefüllte Formular und die Bestätigung vom Vermieter. Plötzlich erscheint meine Nummer auf dem Bildschirm.", + en: "In the waiting room, I nervously check my documents one more time: the passport, the completed form, and the confirmation from the landlord. Suddenly my number appears on the screen." + }, + { + de: "Die Mitarbeiterin am Schalter lächelt freundlich. Sie prüft meine Unterlagen, tippt etwas in den Computer und gibt mir nach fünf Minuten den Stempel. „Vielen Dank für Ihr Verständnis bei der Wartezeit“, sagt sie. Das war viel einfacher als gedacht!", + en: "The employee at the counter smiles kindly. She checks my documents, types something into the computer, and gives me the stamp after five minutes. 'Thank you very much for your understanding regarding the waiting time,' she says. That was much easier than expected!" + } + ], + glossary: { + "zieht": { lemma: "ziehen (umziehen)", pos: "verb", article: null, plural: null, english: "moves (relocates)", note: "in eine Wohnung ziehen = to move into an apartment" }, + "innerhalb": { lemma: "innerhalb", pos: "preposition", article: null, plural: null, english: "within", note: "innerhalb von + Dative" }, + "Wochen": { lemma: "Woche", pos: "noun", article: "die", plural: "die Wochen", english: "weeks", note: "Feminine noun ending in -e" }, + "Bürgeramt": { lemma: "Bürgeramt", pos: "noun", article: "das", plural: "die Bürgerämter", english: "citizens' registration office", note: "Compound: der Bürger (citizen) + das Amt (office)" }, + "anmelden": { lemma: "sich anmelden", pos: "verb (sep.)", article: null, plural: null, english: "to register", note: "meldet an, meldete an, hat angemeldet" }, + "Anmeldung": { lemma: "Anmeldung", pos: "noun", article: "die", plural: "die Anmeldungen", english: "registration", note: "Always 'die' (-ung suffix)" }, + "Bankkonto": { lemma: "Bankkonto", pos: "noun", article: "das", plural: "die Bankkonten", english: "bank account", note: "ein Konto eröffnen = to open an account" }, + "Internetvertrag": { lemma: "Internetvertrag", pos: "noun", article: "der", plural: "die Internetverträge", english: "internet contract", note: "der Vertrag (contract)" }, + "Rathaus": { lemma: "Rathaus", pos: "noun", article: "das", plural: "die Rathäuser", english: "town hall / city hall", note: "der Rat (council) + das Haus" }, + "Eingang": { lemma: "Eingang", pos: "noun", article: "der", plural: "die Eingänge", english: "entrance", note: "Opposite: der Ausgang (exit)" }, + "Schlange": { lemma: "Schlange", pos: "noun", article: "die", plural: "die Schlangen", english: "queue / waiting line", note: "Schlange stehen = to stand in line" }, + "obwohl": { lemma: "obwohl", pos: "conjunction", article: null, plural: null, english: "although / even though", note: "Sends the conjugated verb to the end of the clause" }, + "vorheriger": { lemma: "vorherig", pos: "adjective", article: null, plural: null, english: "prior / previous", note: "nach vorheriger Vereinbarung = by prior appointment" }, + "Vereinbarung": { lemma: "Vereinbarung", pos: "noun", article: "die", plural: "die Vereinbarungen", english: "arrangement / appointment", note: "From vereinbaren (to arrange)" }, + "überprüfe": { lemma: "überprüfen", pos: "verb", article: null, plural: null, english: "check / verify / review", note: "Inseparable prefix über-" }, + "Unterlagen": { lemma: "Unterlagen", pos: "noun (Pl.)", article: "die (Pl.)", plural: "die Unterlagen", english: "documents / paperwork", note: "Standard word for official papers" }, + "Reisepass": { lemma: "Reisepass", pos: "noun", article: "der", plural: "die Reisepässe", english: "passport", note: "die Reise (journey) + der Pass" }, + "ausgefüllte": { lemma: "ausfüllen", pos: "verb (sep.)", article: null, plural: null, english: "filled-out / completed", note: "ein Formular ausfüllen = to fill out a form" }, + "Formular": { lemma: "Formular", pos: "noun", article: "das", plural: "die Formulare", english: "form (document)", note: "Neuter noun" }, + "Bestätigung": { lemma: "Bestätigung", pos: "noun", article: "die", plural: "die Bestätigungen", english: "confirmation / certificate", note: "Wohnungsgeberbestätigung = landlord confirmation" }, + "Vermieter": { lemma: "Vermieter", pos: "noun", article: "der", plural: "die Vermieter", english: "landlord", note: "Compare: der Mieter (tenant) vs. der Vermieter (landlord)" }, + "erscheint": { lemma: "erscheinen", pos: "verb", article: null, plural: null, english: "appears", note: "erscheint, erschien, ist erschienen" }, + "Bildschirm": { lemma: "Bildschirm", pos: "noun", article: "der", plural: "die Bildschirme", english: "screen / monitor", note: "das Bild (picture) + der Schirm (screen/shield)" }, + "Schalter": { lemma: "Schalter", pos: "noun", article: "der", plural: "die Schalter", english: "counter / service window (also: switch)", note: "am Schalter = at the service counter" }, + "Stempel": { lemma: "Stempel", pos: "noun", article: "der", plural: "die Stempel", english: "official stamp", note: "No German bureaucracy is complete without one!" }, + "Verständnis": { lemma: "Verständnis", pos: "noun", article: "das", plural: "die Verständnisse", english: "understanding", note: "Vielen Dank für Ihr Verständnis" } + }, + comprehensionQuestion: { + question: "Welche drei Unterlagen hat der Erzähler zum Termin mitgebracht?", + options: [ + "Den Reisepass, das ausgefüllte Formular und die Bestätigung vom Vermieter.", + "Die Krankenkassenkarte, den Kassenbon und einen Internetvertrag.", + "Nur seinen Führerschein und die letzte Nebenkostenabrechnung." + ], + correctIndex: 0, + explanation: "Im dritten Absatz steht: 'den Reisepass, das ausgefüllte Formular und die Bestätigung vom Vermieter.'" + } + }, + { + id: "story-arzttermin", + title: "In der Arztpraxis: Termin verschieben & Rezept abholen", + subtitle: "Navigating a doctor's visit, health insurance cards, and pharmacies", + level: "A2", + type: "Practical Situation", + readTime: "3 min", + targetVocabIds: ["v2", "v4", "v16", "v17", "v18", "v19"], + paragraphs: [ + { + de: "Seit gestern habe ich starke Halsschmerzen und leichtes Fieber. Eigentlich wollte ich heute Nachmittag eine Wohnung besichtigen, aber ich muss die Besichtigung leider auf nächste Woche verschieben.", + en: "Since yesterday I have had a bad sore throat and a slight fever. Originally I wanted to view an apartment this afternoon, but unfortunately I have to postpone the viewing to next week." + }, + { + de: "Ich rufe in meiner Hausarztpraxis an, um kurzfristig einen Termin zu vereinbaren. „Kommen Sie bitte um elf Uhr vorbei und vergessen Sie Ihre Versichertenkarte von der Krankenkasse nicht“, sagt der Arzthelfer am Telefon.", + en: "I call my family doctor's office to schedule an appointment on short notice. 'Please come by at eleven o'clock and don't forget your insurance card from your health insurance provider,' says the medical assistant on the phone." + }, + { + de: "In der Praxis untersucht die Ärztin meinen Hals und hört meine Lunge ab. „Keine Sorge, es ist nur ein grippaler Infekt“, erklärt sie ruhig. „Trinken Sie viel warmen Tee und ruhen Sie sich drei Tage lang aus.“", + en: "At the office, the doctor examines my throat and listens to my lungs. 'Don't worry, it's just a flu-like infection,' she explains calmly. 'Drink plenty of warm tea and rest for three days.'" + }, + { + de: "Sie gibt mir eine Krankschreibung für meinen Arbeitgeber und ein Rezept für Halstabletten. Auf dem Heimweg löse ich das Rezept direkt in der Apotheke ein und gebe meiner Chefin kurz per E-Mail Bescheid.", + en: "She gives me a sick note for my employer and a prescription for throat lozenges. On the way home, I fill the prescription directly at the pharmacy and briefly notify my boss by email." + } + ], + glossary: { + "Halsschmerzen": { lemma: "Halsschmerzen", pos: "noun (Pl.)", article: "die (Pl.)", plural: "die Halsschmerzen", english: "sore throat", note: "der Hals (throat/neck) + die Schmerzen (pains)" }, + "Fieber": { lemma: "Fieber", pos: "noun", article: "das", plural: "—", english: "fever", note: "Fieber haben / messen" }, + "besichtigen": { lemma: "besichtigen", pos: "verb", article: null, plural: null, english: "to view / inspect", note: "eine Wohnung besichtigen" }, + "Besichtigung": { lemma: "Besichtigung", pos: "noun", article: "die", plural: "die Besichtigungen", english: "viewing / inspection", note: "Feminine (-ung)" }, + "verschieben": { lemma: "verschieben", pos: "verb", article: null, plural: null, english: "to postpone / reschedule", note: "auf nächste Woche verschieben" }, + "kurzfristig": { lemma: "kurzfristig", pos: "adverb/adj", article: null, plural: null, english: "at short notice", note: "Opposite: langfristig (long-term)" }, + "vereinbaren": { lemma: "vereinbaren", pos: "verb", article: null, plural: null, english: "to schedule / arrange", note: "einen Termin vereinbaren" }, + "Krankenkasse": { lemma: "Krankenkasse", pos: "noun", article: "die", plural: "die Krankenkassen", english: "health insurance fund", note: "Statutory health insurance in Germany" }, + "untersucht": { lemma: "untersuchen", pos: "verb", article: null, plural: null, english: "examines", note: "Inseparable verb: hat untersucht" }, + "Lunge": { lemma: "Lunge", pos: "noun", article: "die", plural: "die Lungen", english: "lung", note: "die Lunge abhören = to listen to the lungs" }, + "ruhen": { lemma: "sich ausruhen", pos: "verb (sep.)", article: null, plural: null, english: "to rest / relax", note: "ruht sich aus, hat sich ausgeruht" }, + "Krankschreibung": { lemma: "Krankschreibung", pos: "noun", article: "die", plural: "die Krankschreibungen", english: "sick note for work", note: "Medical certificate for your employer" }, + "Arbeitgeber": { lemma: "Arbeitgeber", pos: "noun", article: "der", plural: "die Arbeitgeber", english: "employer", note: "Literally 'work-giver'; employee is 'Arbeitnehmer'" }, + "Rezept": { lemma: "Rezept", pos: "noun", article: "das", plural: "die Rezepte", english: "prescription (or recipe)", note: "ein Rezept einlösen = to redeem/fill a prescription" }, + "Apotheke": { lemma: "Apotheke", pos: "noun", article: "die", plural: "die Apotheken", english: "pharmacy", note: "Recognizable by the big red 'A' sign in Germany" }, + "Bescheid": { lemma: "Bescheid", pos: "noun", article: "der", plural: "die Bescheide", english: "notice / notification", note: "Bescheid geben/sagen = to let someone know" } + }, + comprehensionQuestion: { + question: "Was macht der Erzähler auf dem Heimweg von der Arztpraxis?", + options: [ + "Er geht zur Wohnungsbesichtigung.", + "Er löst das Rezept in der Apotheke ein und gibt seiner Chefin Bescheid.", + "Er beantragt eine neue Karte bei der Krankenkasse." + ], + correctIndex: 1, + explanation: "Auf dem Heimweg holt er die Medikamente mit dem Rezept in der Apotheke ab und informiert seine Chefin." + } + }, + { + id: "story-feierabend", + title: "Feierabend im Kiezcafé: Pläne fürs Wochenende", + subtitle: "Everyday social conversation with colleagues after work", + level: "A2 / B1", + type: "Practical Situation", + readTime: "4 min", + targetVocabIds: ["v21", "v22", "v24", "v29", "v30", "v34"], + paragraphs: [ + { + de: "Endlich ist es Freitag um siebzehn Uhr: Zeit für den wohlverdienten Feierabend! Meine Kollegin Mara und ich sitzen noch zusammen in einem kleinen Café um die Ecke, obwohl es draußen schon langsam dunkel wird.", + en: "Finally it is Friday at five p.m.: time for the well-deserved end of the workday! My colleague Mara and I are still sitting together in a small café around the corner, even though it is slowly getting dark outside." + }, + { + de: "„Hast du am Sonntag schon etwas vor?“, fragt Mara mich neugierig. „Ein paar Freunde und ich wollen an den Wannsee fahren. Ich schlage vor, dass du einfach mitkommst!“", + en: "'Do you already have plans on Sunday?' Mara asks me curiously. 'A few friends and I want to go to Lake Wannsee. I suggest that you simply come along!'" + }, + { + de: "„Das klingt super, ich bin sofort einverstanden!“, antworte ich begeistert. „Aber ob wir wirklich im See schwimmen können, hängt natürlich stark vom Wetter ab.“", + en: "'That sounds great, I agree right away!' I answer enthusiastically. 'But whether we can really swim in the lake naturally depends heavily on the weather.'" + }, + { + de: "Mara lacht: „Auf jeden Fall! Selbst wenn die Sonne nicht scheint, machen wir ein gemütliches Picknick am Ufer. Ich kümmere mich um frisches Brot vom Bäcker, und ich freue mich schon riesig auf unseren Ausflug.“", + en: "Mara laughs: 'Definitely! Even if the sun doesn't shine, we'll have a cozy picnic on the shore. I'll take care of fresh bread from the bakery, and I'm already looking forward hugely to our outing.'" + } + ], + glossary: { + "wohlverdienten": { lemma: "wohlverdient", pos: "adjective", article: null, plural: null, english: "well-deserved", note: "wohl (well) + verdient (earned)" }, + "Feierabend": { lemma: "Feierabend", pos: "noun", article: "der", plural: "die Feierabende", english: "time off after work", note: "Feierabend machen = to finish work for the day" }, + "Ecke": { lemma: "Ecke", pos: "noun", article: "die", plural: "die Ecken", english: "corner", note: "um die Ecke = around the corner" }, + "vor": { lemma: "etwas vorhaben", pos: "verb (sep.)", article: null, plural: null, english: "to have plans", note: "Hast du heute Abend schon etwas vor?" }, + "schlage": { lemma: "vorschlagen", pos: "verb (sep.)", article: null, plural: null, english: "suggest / propose", note: "schlägt vor, schlug vor, hat vorgeschlagen" }, + "einverstanden": { lemma: "einverstanden", pos: "adjective", article: null, plural: null, english: "agreed / okay with it", note: "einverstanden sein mit" }, + "begeistert": { lemma: "begeistert", pos: "adjective", article: null, plural: null, english: "enthusiastic / thrilled", note: "Ich bin begeistert!" }, + "hängt": { lemma: "abhängen von", pos: "verb (sep.)", article: null, plural: null, english: "depends on", note: "hängt vom Wetter ab = depends on the weather" }, + "gemütliches": { lemma: "gemütlich", pos: "adjective", article: null, plural: null, english: "cozy / relaxed / convivial", note: "Key German cultural word (die Gemütlichkeit)" }, + "Ufer": { lemma: "Ufer", pos: "noun", article: "das", plural: "die Ufer", english: "shore / riverbank", note: "am Ufer = on the shore/bank" }, + "kümmere": { lemma: "sich kümmern um", pos: "verb (refl.)", article: null, plural: null, english: "take care of / look after", note: "Always takes 'um + Accusative'" }, + "freue": { lemma: "sich freuen auf", pos: "verb (refl.)", article: null, plural: null, english: "look forward to", note: "sich freuen auf (+ Accusative future event)" }, + "Ausflug": { lemma: "Ausflug", pos: "noun", article: "der", plural: "die Ausflüge", english: "excursion / day trip / outing", note: "einen Ausflug machen = to go on a day trip" } + }, + comprehensionQuestion: { + question: "Worum kümmert sich Mara für den Ausflug am Sonntag?", + options: [ + "Sie reserviert einen Tisch in einem Restaurant.", + "Sie bringt frisches Brot vom Bäcker für das Picknick mit.", + "Sie kauft die Zugtickets für alle Freunde." + ], + correctIndex: 1, + explanation: "Mara sagt: 'Ich kümmere mich um frisches Brot vom Bäcker.'" + } + }, + { + id: "story-zugfahrt", + title: "Die Zugfahrt nach Hamburg: Wenn der ICE Verspätung hat", + subtitle: "Short story about train travel, platform changes, and unexpected conversations", + level: "A2 / B1", + type: "Short Story & Culture", + readTime: "4 min", + targetVocabIds: ["v35", "v36", "v37", "v38", "v39", "v40"], + paragraphs: [ + { + de: "Freitagnachmittag am Hauptbahnhof: Ich stehe mit meinem Rucksack auf Gleis 7, weil ich übers Wochenende nach Hamburg reisen möchte. Meine Unterkunft in der Nähe vom Hafen ist schon bezahlt.", + en: "Friday afternoon at the main train station: I am standing with my backpack on platform 7 because I want to travel to Hamburg for the weekend. My accommodation near the harbor is already paid for." + }, + { + de: "Plötzlich knackt der Lautsprecher: „Information zu ICE 612 nach Hamburg: Der Zug hat heute leider rund fünfundzwanzig Minuten Verspätung.“ Typisch! Dadurch verpasse ich bestimmt meine Verbindung in Hannover und muss später noch einmal umsteigen.", + en: "Suddenly the loudspeaker crackles: 'Information regarding ICE 612 to Hamburg: The train is unfortunately delayed by about twenty-five minutes today.' Typical! Because of that, I'll definitely miss my connection in Hanover and will have to transfer again later." + }, + { + de: "Trotzdem bleibt die Stimmung auf dem Bahnsteig überraschend entspannt. Ein älterer Herr neben mir lacht und bietet mir einen Keks an. „Bei der Bahn braucht man Geduld“, sagt er. „Aber so hat man wenigstens die Gelegenheit, sich nett zu unterhalten.“", + en: "Nevertheless, the mood on the platform remains surprisingly relaxed. An older gentleman next to me laughs and offers me a cookie. 'With the railway you need patience,' he says. 'But at least that way you have the opportunity to have a nice chat.'" + }, + { + de: "Wir sprechen zwanzig Minuten lang über Norddeutschland, alte Hafenstädte und gute Fischbrötchen. Als der Zug endlich einfährt, habe ich nicht nur fünf neue Tipps für Hamburg, sondern auch meine Angst vorm Deutschsprechen vergessen.", + en: "We talk for twenty minutes about Northern Germany, old harbor cities, and good fish rolls. When the train finally pulls in, I not only have five new tips for Hamburg, but have also forgotten my fear of speaking German." + } + ], + glossary: { + "Gleis": { lemma: "Gleis", pos: "noun", article: "das", plural: "die Gleise", english: "track / platform", note: "auf Gleis 7 = on platform/track 7" }, + "Unterkunft": { lemma: "Unterkunft", pos: "noun", article: "die", plural: "die Unterkünfte", english: "accommodation / lodging", note: "Always feminine 'die'" }, + "Hafen": { lemma: "Hafen", pos: "noun", article: "der", plural: "die Häfen", english: "harbor / port", note: "der Hamburger Hafen" }, + "Lautsprecher": { lemma: "Lautsprecher", pos: "noun", article: "der", plural: "die Lautsprecher", english: "loudspeaker", note: "laut (loud) + Sprecher (speaker)" }, + "Verspätung": { lemma: "Verspätung", pos: "noun", article: "die", plural: "die Verspätungen", english: "delay", note: "Verspätung haben = to be delayed" }, + "verpasse": { lemma: "verpassen", pos: "verb", article: null, plural: null, english: "miss (a train, bus, or opportunity)", note: "den Zug verpassen = to miss the train" }, + "Verbindung": { lemma: "Verbindung", pos: "noun", article: "die", plural: "die Verbindungen", english: "connection", note: "die Anschlussverbindung = connecting train" }, + "umsteigen": { lemma: "umsteigen", pos: "verb (sep.)", article: null, plural: null, english: "to transfer / change trains", note: "steigt um, stieg um, ist umgestiegen" }, + "Trotzdem": { lemma: "trotzdem", pos: "connector", article: null, plural: null, english: "nevertheless / despite that", note: "Followed immediately by the conjugated verb" }, + "Stimmung": { lemma: "Stimmung", pos: "noun", article: "die", plural: "die Stimmungen", english: "mood / atmosphere", note: "gute/entspannte Stimmung" }, + "Bahnsteig": { lemma: "Bahnsteig", pos: "noun", article: "der", plural: "die Bahnsteige", english: "station platform", note: "Physical platform where you stand" }, + "Geduld": { lemma: "Geduld", pos: "noun", article: "die", plural: "—", english: "patience", note: "Geduld haben = to have patience (uncountable)" }, + "Gelegenheit": { lemma: "Gelegenheit", pos: "noun", article: "die", plural: "die Gelegenheiten", english: "opportunity / chance", note: "die Gelegenheit nutzen = to seize the opportunity" }, + "unterhalten": { lemma: "sich unterhalten", pos: "verb (refl.)", article: null, plural: null, english: "to chat / converse", note: "unterhält sich, unterhielt sich, hat sich unterhalten" }, + "einfährt": { lemma: "einfahren", pos: "verb (sep.)", article: null, plural: null, english: "pulls into the station", note: "Der Zug fährt auf Gleis 7 ein" } + }, + comprehensionQuestion: { + question: "Welchen positiven Effekt hatte die Verspätung des Zuges am Ende?", + options: [ + "Der Erzähler hat sein Ticket kostenlos umgetauscht.", + "Er hatte die Gelegenheit, sich auf Deutsch mit einem netten Herrn über Hamburg zu unterhalten.", + "Er musste nicht in Hannover umsteigen." + ], + correctIndex: 1, + explanation: "Durch die Wartezeit kam er mit einem älteren Herrn ins Gespräch und verlor seine Angst vorm Deutschsprechen." + } + }, + { + id: "story-flohmarkt", + title: "Sonntags auf dem Flohmarkt & die Kunst von „Kaffee und Kuchen“", + subtitle: "Why Sundays in Germany are quiet—and how people spend them", + level: "A2 / B1", + type: "Short Story & Culture", + readTime: "4 min", + targetVocabIds: ["v11", "v15", "v44", "v47", "v48"], + paragraphs: [ + { + de: "Wer neu nach Deutschland kommt, bemerkt am ersten Sonntag sofort einen großen Unterschied: Fast alle Supermärkte und Geschäfte sind geschlossen. Für viele Neuankömmlinge ist diese Ruhe zuerst ungewohnt.", + en: "Anyone who comes to Germany for the first time immediately notices a big difference on their first Sunday: almost all supermarkets and shops are closed. For many newcomers, this quietness is unusual at first." + }, + { + de: "Doch nach ein paar Monaten wird der ruhige Sonntag schnell zu einer schönen Gewohnheit. Vormittags bummeln wir gerne über den Flohmarkt im Park, wo es natürlich keinen Kassenbon gibt, man aber wunderbar um ein paar Euro verhandeln kann.", + en: "Yet after a few months, the quiet Sunday quickly becomes a lovely habit. In the morning we like to stroll across the flea market in the park, where of course there is no receipt, but you can wonderfully haggle over a few euros." + }, + { + de: "Übrigens gilt auch auf Straßenfesten und Märkten fast überall das deutsche Pfand-System: Wenn man sich eine Apfelschorle oder einen Kaffee im Mehrwegbecher holt, zahlt man zwei Euro Pfand und bekommt das Geld bei der Rückgabe zurück.", + en: "By the way, at street festivals and markets almost everywhere, the German deposit system applies too: if you get an apple spritzer or a coffee in a reusable cup, you pay a two-euro deposit and get the money back when returning it." + }, + { + de: "Am Nachmittag gegen fünfzehn Uhr folgt dann die beste Tradition der Woche: „Kaffee und Kuchen“ mit Freunden. Bei einem Stück Apfelkuchen vergisst man den Stress der Arbeitswoche sofort.", + en: "In the afternoon around three o'clock, the best tradition of the week follows: 'coffee and cake' with friends. Over a piece of apple cake, you immediately forget the stress of the workweek." + } + ], + glossary: { + "bemerkt": { lemma: "bemerken", pos: "verb", article: null, plural: null, english: "notices / realizes", note: "Inseparable prefix be-" }, + "Unterschied": { lemma: "Unterschied", pos: "noun", article: "der", plural: "die Unterschiede", english: "difference", note: "einen Unterschied machen / bemerken" }, + "Geschäfte": { lemma: "Geschäft", pos: "noun", article: "das", plural: "die Geschäfte", english: "shops / stores / businesses", note: "die Geschäfte sind geschlossen = the shops are closed" }, + "ungewohnt": { lemma: "ungewohnt", pos: "adjective", article: null, plural: null, english: "unfamiliar / unusual / unaccustomed", note: "Opposite: gewohnt (used to)" }, + "Gewohnheit": { lemma: "Gewohnheit", pos: "noun", article: "die", plural: "die Gewohnheiten", english: "habit / custom", note: "zur Gewohnheit werden = to become a habit" }, + "bummeln": { lemma: "bummeln", pos: "verb", article: null, plural: null, english: "to stroll / browse leisurely", note: "über den Flohmarkt bummeln" }, + "Kassenbon": { lemma: "Kassenbon", pos: "noun", article: "der", plural: "die Kassenbons", english: "store receipt", note: "die Kasse + der Bon" }, + "verhandeln": { lemma: "verhandeln", pos: "verb", article: null, plural: null, english: "to negotiate / haggle", note: "über den Preis verhandeln" }, + "Übrigens": { lemma: "übrigens", pos: "adverb", article: null, plural: null, english: "by the way", note: "Great conversation starter" }, + "Pfand": { lemma: "Pfand", pos: "noun", article: "das", plural: "—", english: "deposit (on bottles/cups)", note: "2 Euro Pfand zahlen" }, + "Mehrwegbecher": { lemma: "Mehrwegbecher", pos: "noun", article: "der", plural: "die Mehrwegbecher", english: "reusable cup", note: "Mehrweg (reusable) vs. Einweg (single-use)" }, + "Rückgabe": { lemma: "Rückgabe", pos: "noun", article: "die", plural: "die Rückgaben", english: "return (of an item)", note: "bei der Rückgabe = upon returning" } + }, + comprehensionQuestion: { + question: "Wie funktioniert das Mehrwegbecher-System auf deutschen Märkten?", + options: [ + "Man muss seinen eigenen Becher von zu Hause mitbringen.", + "Man zahlt beim Kauf ein kleines Pfand und bekommt das Geld bei der Rückgabe des Bechers zurück.", + "Man bekommt das Pfand nur mit einem gedruckten Kassenbon vom Supermarkt zurück." + ], + correctIndex: 1, + explanation: "Man zahlt beim Kauf z.B. zwei Euro Pfand und erhält das Geld zurück, wenn man den Becher wieder abgibt." + } + } +]; + +// Supplemental high-frequency dictionary for common words clicked in stories or custom texts +window.COMMON_GERMAN_DICT = { + "wohnung": { lemma: "Wohnung", pos: "noun", article: "die", plural: "die Wohnungen", english: "apartment / flat", note: "eine Wohnung suchen / mieten" }, + "termin": { lemma: "Termin", pos: "noun", article: "der", plural: "die Termine", english: "appointment / date / deadline", note: "einen Termin haben / vereinbaren / verschieben" }, + "wochenende": { lemma: "Wochenende", pos: "noun", article: "das", plural: "die Wochenenden", english: "weekend", note: "Schönes Wochenende! / übers Wochenende" }, + "kaffee": { lemma: "Kaffee", pos: "noun", article: "der", plural: "die Kaffees", english: "coffee", note: "Masculine: einen Kaffee, bitte!" }, + "kuchen": { lemma: "Kuchen", pos: "noun", article: "der", plural: "die Kuchen", english: "cake", note: "ein Stück Kuchen" }, + "bahnhof": { lemma: "Bahnhof", pos: "noun", article: "der", plural: "die Bahnhöfe", english: "train station", note: "der Hauptbahnhof (main station)" }, + "zug": { lemma: "Zug", pos: "noun", article: "der", plural: "die Züge", english: "train", note: "mit dem Zug fahren (always Dative after 'mit')" }, + "freunde": { lemma: "Freund", pos: "noun", article: "der", plural: "die Freunde", english: "friends", note: "ein Freund von mir (a friend of mine)" }, + "wetter": { lemma: "Wetter", pos: "noun", article: "das", plural: "—", english: "weather", note: "vom Wetter abhängen" }, + "arbeit": { lemma: "Arbeit", pos: "noun", article: "die", plural: "die Arbeiten", english: "work / job", note: "zur Arbeit gehen" }, + "zeit": { lemma: "Zeit", pos: "noun", article: "die", plural: "die Zeiten", english: "time", note: "Zeit haben / sich Zeit nehmen" }, + "geld": { lemma: "Geld", pos: "noun", article: "das", plural: "die Gelder", english: "money", note: "Bargeld = cash" }, + "sprache": { lemma: "Sprache", pos: "noun", article: "die", plural: "die Sprachen", english: "language", note: "eine Sprache fließend sprechen" }, + "wartezeit": { lemma: "Wartezeit", pos: "noun", article: "die", plural: "die Wartezeiten", english: "waiting time", note: "warten (to wait) + die Zeit (time)" }, + "stunde": { lemma: "Stunde", pos: "noun", article: "die", plural: "die Stunden", english: "hour / lesson", note: "eine halbe Stunde (half an hour)" }, + "morgen": { lemma: "Morgen / morgen", pos: "noun / adverb", article: "der", plural: "die Morgen", english: "morning (der Morgen) / tomorrow (morgen)", note: "Capitalized = morning; lowercase = tomorrow!" } +}; diff --git a/data/vocabulary.js b/data/vocabulary.js new file mode 100644 index 0000000..d0ccad1 --- /dev/null +++ b/data/vocabulary.js @@ -0,0 +1,834 @@ +// High-utility A2/B1 German Vocabulary specifically curated to bridge the "Duolingo plateau" +// Covers practical everyday life, bureaucracy, health, housing, socializing, separable verbs, and connectors. + +window.INITIAL_VOCABULARY = [ + // === PRACTICAL LIFE & BUREAUCRACY (Alltag & Behörden) === + { + id: "v1", + word: "Vereinbarung", + article: "die", + plural: "die Vereinbarungen", + pos: "noun", + level: "A2", + category: "Everyday & Bureaucracy", + english: "agreement / appointment / arrangement", + breakdown: "vereinbaren (to agree upon) + -ung (noun suffix, always feminine 'die')", + forms: "nach Vereinbarung (by appointment)", + exampleDe: "Wir haben eine klare Vereinbarung mit dem Vermieter getroffen.", + exampleEn: "We made a clear agreement with the landlord.", + clozeSentence: "Termine sind nur nach vorheriger ___ möglich.", + clozeAnswer: "Vereinbarung", + usageTip: "Often seen on store or office doors as 'Termine nach Vereinbarung' (Appointments by arrangement)." + }, + { + id: "v2", + word: "vereinbaren", + article: null, + plural: null, + pos: "verb", + level: "A2", + category: "Everyday & Bureaucracy", + english: "to arrange / to schedule (an appointment)", + breakdown: "ver- (inseparable prefix) + ein + bar + -en", + forms: "vereinbart • vereinbarte • hat vereinbart", + exampleDe: "Ich möchte gerne einen Termin beim Zahnarzt vereinbaren.", + exampleEn: "I would like to schedule an appointment at the dentist.", + clozeSentence: "Können wir für nächste Woche einen neuen Termin ___?", + clozeAnswer: "vereinbaren", + usageTip: "Much more natural in German than 'machen' when talking about official or medical appointments ('einen Termin vereinbaren')." + }, + { + id: "v3", + word: "Anmeldung", + article: "die", + plural: "die Anmeldungen", + pos: "noun", + level: "A2", + category: "Everyday & Bureaucracy", + english: "registration (especially address or course registration)", + breakdown: "anmelden (to register) + -ung (always feminine 'die')", + forms: "die Wohnungsanmeldung", + exampleDe: "Für das Bankkonto brauche ich die Bestätigung meiner Anmeldung.", + exampleEn: "For the bank account I need the confirmation of my address registration.", + clozeSentence: "Beim Bürgeramt dauert die ___ manchmal mehrere Wochen.", + clozeAnswer: "Anmeldung", + usageTip: "In Germany, registering your address at the Bürgeramt ('die Anmeldung') within 14 days of moving is a key life event." + }, + { + id: "v4", + word: "verschieben", + article: null, + plural: null, + pos: "verb", + level: "A2", + category: "Everyday & Bureaucracy", + english: "to postpone / to reschedule / to move", + breakdown: "ver- (inseparable prefix) + schieben (to push)", + forms: "verschiebt • verschob • hat verschoben", + exampleDe: "Leider muss ich unseren Termin auf Freitag verschieben.", + exampleEn: "Unfortunately I have to postpone our appointment to Friday.", + clozeSentence: "Wegen der Erkältung musste er das Treffen auf nächste Woche ___.", + clozeAnswer: "verschieben", + usageTip: "Use with 'auf + Accusative' for time: 'den Termin auf morgen verschieben'." + }, + { + id: "v5", + word: "absagen", + article: null, + plural: null, + pos: "verb (separable)", + level: "A2", + category: "Everyday & Bureaucracy", + english: "to cancel (an appointment, event, or visit)", + breakdown: "ab- (separable prefix: off/away) + sagen (to say)", + forms: "sagt ab • sagte ab • hat abgesagt", + exampleDe: "Ich muss den Termin heute leider kurzfristig absagen.", + exampleEn: "Unfortunately I have to cancel the appointment today on short notice.", + clozeSentence: "Wenn du nicht kommen kannst, ___ bitte rechtzeitig ___!", + clozeAnswer: "sag ab", + usageTip: "Use 'absagen' for appointments, meetings, and parties. Use 'stornieren' for bookings, flights, or hotel reservations." + }, + { + id: "v6", + word: "Unterlagen", + article: "die (Pl.)", + plural: "die Unterlagen", + pos: "noun (plural)", + level: "A2", + category: "Everyday & Bureaucracy", + english: "documents / paperwork / records", + breakdown: "unter (under) + Lage (layer/position) — almost always used in plural", + forms: "Bewerbungsunterlagen (application documents)", + exampleDe: "Bitte bringen Sie alle wichtigen Unterlagen zum Termin mit.", + exampleEn: "Please bring all important documents with you to the appointment.", + clozeSentence: "Hast du schon alle ___ für das Finanzamt zusammengesucht?", + clozeAnswer: "Unterlagen", + usageTip: "Germans rarely say 'Dokumente' in everyday bureaucracy; 'die Unterlagen' is the go-to word for your folder of official papers." + }, + { + id: "v7", + word: "Bescheid", + article: "der", + plural: "die Bescheide", + pos: "noun", + level: "A2", + category: "Everyday & Bureaucracy", + english: "notification / official notice (idiom: 'Bescheid sagen/wissen')", + breakdown: "Related to 'bescheiden' (to inform/assign)", + forms: "Bescheid sagen (to let someone know) • Bescheid wissen (to be aware)", + exampleDe: "Sag mir bitte kurz Bescheid, wenn du gut zu Hause angekommen bist.", + exampleEn: "Please let me know briefly when you've arrived home safely.", + clozeSentence: "Ich gebe dir morgen ___, ob es bei mir klappt.", + clozeAnswer: "Bescheid", + usageTip: "Essential everyday expressions: 'Bescheid sagen / geben' (let someone know) and 'Ich weiß Bescheid' (I know / got it)." + }, + { + id: "v8", + word: "beantragen", + article: null, + plural: null, + pos: "verb", + level: "B1", + category: "Everyday & Bureaucracy", + english: "to apply for (officially: passport, visa, permit)", + breakdown: "be- + Antrag (application) + -en", + forms: "beantragt • beantragte • hat beantragt", + exampleDe: "Ich muss diesen Monat unbedingt einen neuen Reisepass beantragen.", + exampleEn: "I definitely have to apply for a new passport this month.", + clozeSentence: "Wo kann man in dieser Stadt ein neues Visum ___?", + clozeAnswer: "beantragen", + usageTip: "Use 'beantragen' for official things (Pass, Visum, Urlaub). For jobs, use 'sich bewerben um'." + }, + + // === HOUSING, SHOPPING & DAILY ERRANDS (Wohnen & Einkaufen) === + { + id: "v9", + word: "Miete", + article: "die", + plural: "die Mieten", + pos: "noun", + level: "A2", + category: "Housing & Errands", + english: "rent", + breakdown: "Warmmiete (rent incl. utilities) vs. Kaltmiete (base rent)", + forms: "zur Miete wohnen (to rent)", + exampleDe: "In den großen Städten ist die Miete in den letzten Jahren stark gestiegen.", + exampleEn: "In the big cities, rent has risen sharply in recent years.", + clozeSentence: "Überweist du die ___ immer am ersten Tag des Monats?", + clozeAnswer: "Miete", + usageTip: "Know the difference: 'Kaltmiete' (base rent) + 'Nebenkosten' (utilities/fees) = 'Warmmiete' (total rent)." + }, + { + id: "v10", + word: "Nebenkosten", + article: "die (Pl.)", + plural: "die Nebenkosten", + pos: "noun (plural)", + level: "A2", + category: "Housing & Errands", + english: "utility costs / additional housing expenses", + breakdown: "neben (beside/additional) + Kosten (costs)", + forms: "die Nebenkostenabrechnung (annual utility bill statement)", + exampleDe: "Sind Strom und Heizung schon in den Nebenkosten enthalten?", + exampleEn: "Are electricity and heating already included in the utility costs?", + clozeSentence: "Die Wohnung kostet 750 Euro kalt plus 200 Euro ___.", + clozeAnswer: "Nebenkosten", + usageTip: "Electricity ('Strom') and internet are usually paid separately in Germany, even when 'Nebenkosten' are included!" + }, + { + id: "v11", + word: "Kassenbon", + article: "der", + plural: "die Kassenbons", + pos: "noun", + level: "A2", + category: "Housing & Errands", + english: "receipt (at a store or supermarket)", + breakdown: "Kasse (checkout/register) + Bon (voucher/slip)", + forms: "Synonym: der Beleg / die Quittung", + exampleDe: "Brauchen Sie den Kassenbon, oder kann der weg?", + exampleEn: "Do you need the receipt, or can I throw it away?", + clozeSentence: "Ohne den ___ können wir die Jacke leider nicht umtauschen.", + clozeAnswer: "Kassenbon", + usageTip: "At every German supermarket checkout, you'll hear: 'Brauchen Sie den Bon / Beleg?'" + }, + { + id: "v12", + word: "umtauschen", + article: null, + plural: null, + pos: "verb (separable)", + level: "A2", + category: "Housing & Errands", + english: "to exchange / return (an item at a store)", + breakdown: "um- (separable: around/change) + tauschen (to swap)", + forms: "tauscht um • tauschte um • hat umgetauscht", + exampleDe: "Der Pullover ist mir zu klein; ich möchte ihn gerne umtauschen.", + exampleEn: "The sweater is too small for me; I would like to exchange it.", + clozeSentence: "Kann ich diese Schuhe innerhalb von 14 Tagen ___?", + clozeAnswer: "umtauschen", + usageTip: "Noun form: 'der Umtausch' ('Vom Umtausch ausgeschlossen' = Final sale / no exchanges)." + }, + { + id: "v13", + word: "Besichtigung", + article: "die", + plural: "die Besichtigungen", + pos: "noun", + level: "A2", + category: "Housing & Errands", + english: "viewing / inspection / sightseeing tour", + breakdown: "besichtigen (to view/inspect) + -ung (feminine)", + forms: "die Wohnungsbesichtigung (apartment viewing)", + exampleDe: "Am Samstag haben wir endlich eine Besichtigung für eine Dreizimmerwohnung.", + exampleEn: "On Saturday we finally have a viewing for a three-room apartment.", + clozeSentence: "Zur ___ der Wohnung kamen über dreißig Interessenten.", + clozeAnswer: "Besichtigung", + usageTip: "Used both for apartments ('Wohnungsbesichtigung') and tourist sights ('Stadtbesichtigung')." + }, + { + id: "v14", + word: "Schlange", + article: "die", + plural: "die Schlangen", + pos: "noun", + level: "A2", + category: "Housing & Errands", + english: "queue / line (also: snake)", + breakdown: "Schlange stehen (to stand in line / queue up)", + forms: "die Warteschlange (waiting line)", + exampleDe: "Vor der Bäckerei stand heute Morgen eine lange Schlange.", + exampleEn: "In front of the bakery there was a long line this morning.", + clozeSentence: "An der Kasse mussten wir fast zehn Minuten in der ___ stehen.", + clozeAnswer: "Schlange", + usageTip: "When a second cash register opens in Germany, people rush when they hear: 'Wir öffnen Kasse zwei!'" + }, + { + id: "v15", + word: "Pfand", + article: "das", + plural: "die Pfänder (rare)", + pos: "noun", + level: "A2", + category: "Housing & Errands", + english: "bottle/container deposit", + breakdown: "Pfandflasche (deposit bottle) • Pfandbon (deposit voucher)", + forms: "Pfand zurückgeben (to return deposit bottles)", + exampleDe: "Vergiss nicht, den Pfandbon an der Kasse abzugeben!", + exampleEn: "Don't forget to hand in the deposit slip at the checkout!", + clozeSentence: "Auf diese Glasflasche gibt es 15 Cent ___.", + clozeAnswer: "Pfand", + usageTip: "In German supermarkets, you feed empty bottles into the 'Pfandautomat' and get a 'Pfandbon' to deduct from your groceries." + }, + + // === HEALTH & WELLBEING (Gesundheit & Körper) === + { + id: "v16", + word: "Krankenkasse", + article: "die", + plural: "die Krankenkassen", + pos: "noun", + level: "A2", + category: "Health & Wellbeing", + english: "health insurance provider (statutory)", + breakdown: "Kranken (sick people's) + Kasse (fund/register)", + forms: "die Krankenversicherung (health insurance in general)", + exampleDe: "Welche Krankenkasse kannst du in Deutschland empfehlen?", + exampleEn: "Which health insurance provider can you recommend in Germany?", + clozeSentence: "Übernimmt deine ___ die Kosten für diese Behandlung?", + clozeAnswer: "Krankenkasse", + usageTip: "At a doctor's office, the receptionist will always ask for your 'Versichertenkarte' (health insurance card)." + }, + { + id: "v17", + word: "Rezept", + article: "das", + plural: "die Rezepte", + pos: "noun", + level: "A2", + category: "Health & Wellbeing", + english: "prescription (medical) OR recipe (cooking)", + breakdown: "rezeptfrei (over-the-counter) vs. rezeptpflichtig (prescription-only)", + forms: "ein Rezept einlösen (to fill a prescription)", + exampleDe: "Für dieses Medikament brauchen Sie ein Rezept vom Arzt.", + exampleEn: "For this medication you need a prescription from the doctor.", + clozeSentence: "Ich gehe kurz in die Apotheke, um mein ___ einzulösen.", + clozeAnswer: "Rezept", + usageTip: "Context tells you whether 'das Rezept' means a doctor's prescription or a cake recipe!" + }, + { + id: "v18", + word: "Krankschreibung", + article: "die", + plural: "die Krankschreibungen", + pos: "noun", + level: "B1", + category: "Health & Wellbeing", + english: "sick note / medical certificate for work", + breakdown: "krank (sick) + Schreibung (writing/note)", + forms: "sich krankschreiben lassen (to get a sick note from a doctor)", + exampleDe: "Ab dem dritten Tag braucht mein Arbeitgeber eine Krankschreibung.", + exampleEn: "Starting on the third day, my employer needs a sick note.", + clozeSentence: "Die Ärztin hat mir eine ___ für die ganze Woche gegeben.", + clozeAnswer: "Krankschreibung", + usageTip: "Also officially called 'Arbeitsunfähigkeitsbescheinigung' (AU), but everyone says 'Krankschreibung' in daily life." + }, + { + id: "v19", + word: "untersuchen", + article: null, + plural: null, + pos: "verb", + level: "A2", + category: "Health & Wellbeing", + english: "to examine (a patient) / to investigate", + breakdown: "unter- (inseparable here) + suchen (to seek)", + forms: "untersucht • untersuchte • hat untersucht", + exampleDe: "Der Arzt hat meinen Hals gründlich untersucht.", + exampleEn: "The doctor examined my throat thoroughly.", + clozeSentence: "Die Ärztin wird Ihr Knie gleich genau ___.", + clozeAnswer: "untersuchen", + usageTip: "Note that 'unter-' is inseparable here: stress is on '-suchen' -> 'Er hat mich untersucht' (no -ge-!)." + }, + { + id: "v20", + word: "Verletzung", + article: "die", + plural: "die Verletzungen", + pos: "noun", + level: "A2", + category: "Health & Wellbeing", + english: "injury", + breakdown: "verletzen (to injure) + -ung (feminine)", + forms: "sich verletzen (to injure oneself)", + exampleDe: "Zum Glück war die Verletzung beim Fußballspielen nicht schlimm.", + exampleEn: "Fortunately, the injury while playing soccer wasn't serious.", + clozeSentence: "Wegen einer leichten ___ am Fuß kann ich heute nicht joggen.", + clozeAnswer: "Verletzung", + usageTip: "Reflective verb: 'Ich habe mich an der Hand verletzt' (I hurt/injured my hand)." + }, + + // === SOCIALIZING, WORK & CONVERSATION (Soziales & Beruf) === + { + id: "v21", + word: "Feierabend", + article: "der", + plural: "die Feierabende", + pos: "noun", + level: "A2", + category: "Social & Work", + english: "end of the workday / evening off after work", + breakdown: "Feier (celebration/rest) + Abend (evening)", + forms: "Feierabend machen (to call it a day / finish work)", + exampleDe: "Schönen Feierabend! Wir sehen uns morgen früh im Büro.", + exampleEn: "Enjoy your evening off work! See you tomorrow morning at the office.", + clozeSentence: "Es ist schon 18 Uhr — lass uns endlich ___ machen!", + clozeAnswer: "Feierabend", + usageTip: "One of the most quintessential German cultural words. When leaving work, always wish colleagues 'Schönen Feierabend!'" + }, + { + id: "v22", + word: "vorschlagen", + article: null, + plural: null, + pos: "verb (separable)", + level: "A2", + category: "Social & Work", + english: "to suggest / to propose", + breakdown: "vor- (separable: forward/before) + schlagen (to hit/strike)", + forms: "schlägt vor • schlug vor • hat vorgeschlagen", + exampleDe: "Was schlägst du für heute Abend vor? Wollen wir essen gehen?", + exampleEn: "What do you suggest for tonight? Do we want to go out to eat?", + clozeSentence: "Ich ___ ___, dass wir uns schon um 17 Uhr am Bahnhof treffen.", + clozeAnswer: "schlage vor", + usageTip: "The noun is 'der Vorschlag' (die Vorschläge) — 'Das ist ein super Vorschlag!' (That's a great suggestion!)." + }, + { + id: "v23", + word: "Vorschlag", + article: "der", + plural: "die Vorschläge", + pos: "noun", + level: "A2", + category: "Social & Work", + english: "suggestion / proposal", + breakdown: "vor + Schlag (from vorschlagen)", + forms: "einen Vorschlag machen (to make a suggestion)", + exampleDe: "Ich bin mit deinem Vorschlag vollkommen einverstanden.", + exampleEn: "I completely agree with your suggestion.", + clozeSentence: "Hat noch jemand einen besseren ___ für unser Wochenendprogramm?", + clozeAnswer: "Vorschlag", + usageTip: "Notice how masculine nouns derived from strong verbs often take an Umlaut in the plural: der Vorschlag -> die Vorschläge." + }, + { + id: "v24", + word: "einverstanden", + article: null, + plural: null, + pos: "adjective / adverb", + level: "A2", + category: "Social & Work", + english: "agreed / okay with something", + breakdown: "einverstanden sein mit (+ Dative)", + forms: "Einverstanden! (Agreed! / Deal!)", + exampleDe: "Bist du damit einverstanden, wenn wir uns etwas später treffen?", + exampleEn: "Are you okay with it if we meet a little later?", + clozeSentence: "Wir teilen uns die Rechnung fünfzig-fünfzig — bist du ___?", + clozeAnswer: "einverstanden", + usageTip: "Do NOT say 'Ich stimme dir einverstanden' — say 'Ich bin (damit) einverstanden' or 'Ich stimme dir zu'." + }, + { + id: "v25", + word: "begleiten", + article: null, + plural: null, + pos: "verb", + level: "A2", + category: "Social & Work", + english: "to accompany / to go along with someone", + breakdown: "be- (inseparable prefix) + gleiten/leiten", + forms: "begleitet • begleitete • hat begleitet", + exampleDe: "Soll ich dich zum Bürgeramt begleiten und beim Übersetzen helfen?", + exampleEn: "Should I accompany you to the civic office and help translate?", + clozeSentence: "Darf ich dich noch ein Stück bis zur U-Bahn-Station ___?", + clozeAnswer: "begleiten", + usageTip: "Inseparable verb (`be-`), so the past participle has no `ge-`: 'Sie hat mich nach Hause begleitet.'" + }, + { + id: "v26", + word: "erledigen", + article: null, + plural: null, + pos: "verb", + level: "A2", + category: "Social & Work", + english: "to take care of / to get done / to finish (tasks or errands)", + breakdown: "er- (inseparable: achieve/complete) + ledig (free/done)", + forms: "erledigt • erledigte • hat erledigt", + exampleDe: "Vor dem Urlaub muss ich noch ein paar wichtige Dinge erledigen.", + exampleEn: "Before the vacation I still have to take care of a few important things.", + clozeSentence: "Ich habe heute Vormittag schon alle meine Einkäufe ___.", + clozeAnswer: "erledigt", + usageTip: "Colloquial bonus: 'Ich bin völlig erledigt!' means 'I am completely exhausted/wiped out!'" + }, + { + id: "v27", + word: "Verständnis", + article: "das", + plural: "die Verständnisse (rare)", + pos: "noun", + level: "B1", + category: "Social & Work", + english: "understanding / sympathy / comprehension", + breakdown: "verstehen (to understand) + -nis (usually neuter 'das')", + forms: "Verständnis haben für (+ Accusative)", + exampleDe: "Vielen Dank für Ihr Verständnis wegen der kurzen Verspätung.", + exampleEn: "Thank you very much for your understanding regarding the brief delay.", + clozeSentence: "Auf jedem deutschen Bahnhof hört man: 'Wir bitten um Ihr ___.'", + clozeAnswer: "Verständnis", + usageTip: "Nouns ending in `-nis` are almost always `das` (das Verständnis, das Ergebnis, das Erlebnis), except 'die Erlaubnis' and 'die Kenntnis'." + }, + { + id: "v28", + word: "Erfahrung", + article: "die", + plural: "die Erfahrungen", + pos: "noun", + level: "A2", + category: "Social & Work", + english: "experience (knowledge/skill gained over time)", + breakdown: "erfahren (to experience/learn) + -ung (feminine)", + forms: "Erfahrung sammeln (to gain experience)", + exampleDe: "In meinem neuen Job konnte ich schon viele gute Erfahrungen sammeln.", + exampleEn: "In my new job I've already been able to gain lots of good experience.", + clozeSentence: "Hast du schon ___ mit deutschen Behörden gemacht?", + clozeAnswer: "Erfahrung", + usageTip: "Compare 'die Erfahrung' (life/work experience gained over time) vs. 'das Erlebnis' (a single exciting event or adventure)." + }, + + // === HIGH-VALUE VERBS WITH PREPOSITIONS & SEPARABLE VERBS === + { + id: "v29", + word: "sich kümmern um", + article: null, + plural: null, + pos: "reflexive verb + prep", + level: "A2", + category: "Verbs & Expressions", + english: "to take care of / to look after", + breakdown: "sich (Accusative) + kümmern + um (+ Accusative)", + forms: "kümmert sich • kümmerte sich • hat sich gekümmert", + exampleDe: "Keine Sorge, ich kümmere mich um die Reservierung im Restaurant.", + exampleEn: "Don't worry, I'll take care of the reservation at the restaurant.", + clozeSentence: "Wer ___ sich im Urlaub ___ deine Pflanzen?", + clozeAnswer: "kümmert um", + usageTip: "Always takes 'um + Accusative': 'Ich kümmere mich darum' (I'll take care of it)." + }, + { + id: "v30", + word: "sich freuen auf", + article: null, + plural: null, + pos: "reflexive verb + prep", + level: "A2", + category: "Verbs & Expressions", + english: "to look forward to (something in the future)", + breakdown: "sich freuen + AUF (future) vs. ÜBER (present/past)", + forms: "freut sich auf • freute sich auf • hat sich gefreut", + exampleDe: "Ich freue mich schon sehr auf unser gemeinsames Wochenende in München!", + exampleEn: "I am already looking forward very much to our weekend together in Munich!", + clozeSentence: "Worauf ___ du dich in diesem Sommer am meisten?", + clozeAnswer: "freust", + usageTip: "Crucial distinction: 'sich freuen AUF' = looking forward to (future); 'sich freuen ÜBER' = happy about (something that already happened)." + }, + { + id: "v31", + word: "teilnehmen an", + article: null, + plural: null, + pos: "verb (separable)", + level: "A2", + category: "Verbs & Expressions", + english: "to participate in / to take part in / to attend", + breakdown: "teil- (part) + nehmen (to take) + an (+ Dative)", + forms: "nimmt teil • nahm teil • hat teilgenommen", + exampleDe: "Möchtest du nächste Woche an unserem Deutsch-Stammtisch teilnehmen?", + exampleEn: "Would you like to take part in our German language meetup next week?", + clozeSentence: "Über zwanzig Personen ___ heute ___ dem Sprachkurs ___.", + clozeAnswer: "nehmen an teil", + usageTip: "Takes 'an + Dative': 'an der Besprechung teilnehmen' (to attend/participate in the meeting)." + }, + { + id: "v32", + word: "stattfinden", + article: null, + plural: null, + pos: "verb (separable)", + level: "A2", + category: "Verbs & Expressions", + english: "to take place / to happen (for planned events)", + breakdown: "statt- (place/stead) + finden (to find)", + forms: "findet statt • fand statt • hat stattgefunden", + exampleDe: "Bei schlechtem Wetter findet das Konzert drinnen statt.", + exampleEn: "In bad weather, the concert takes place indoors.", + clozeSentence: "Wann und wo ___ das Straßenfest dieses Jahr ___?", + clozeAnswer: "findet statt", + usageTip: "Use 'stattfinden' for scheduled events (meetings, concerts, markets) and 'passieren' for unexpected accidents/events." + }, + { + id: "v33", + word: "Bescheid geben", + article: null, + plural: null, + pos: "verb phrase", + level: "A2", + category: "Verbs & Expressions", + english: "to notify / to let someone know", + breakdown: "Bescheid (notice) + geben (+ Dative person)", + forms: "gibt Bescheid • gab Bescheid • hat Bescheid gegeben", + exampleDe: "Gibst du mir bitte kurz Bescheid, sobald du am Bahnhof bist?", + exampleEn: "Will you please let me know briefly as soon as you're at the station?", + clozeSentence: "Ich ___ dir spätestens morgen früh telefonisch ___.", + clozeAnswer: "gebe Bescheid", + usageTip: "Takes Dative for the person: 'Gib MIR Bescheid!' (Let me know!)." + }, + { + id: "v34", + word: "abhängen von", + article: null, + plural: null, + pos: "verb (separable)", + level: "B1", + category: "Verbs & Expressions", + english: "to depend on", + breakdown: "ab- (separable) + hängen (to hang) + von (+ Dative)", + forms: "hängt ab • hing ab • hat abgehangen", + exampleDe: "Ob wir grillen können, hängt natürlich vom Wetter ab.", + exampleEn: "Whether we can barbecue naturally depends on the weather.", + clozeSentence: "Der Preis für das Zugticket ___ stark ___ der Uhrzeit ___.", + clozeAnswer: "hängt von ab", + usageTip: "Also super common: 'Es kommt darauf an' (It depends)." + }, + + // === TRAVEL, TRANSPORT & CULTURE (Reisen & Kultur) === + { + id: "v35", + word: "Verbindung", + article: "die", + plural: "die Verbindungen", + pos: "noun", + level: "A2", + category: "Travel & Culture", + english: "connection (train/bus/internet/phone)", + breakdown: "verbinden (to connect) + -ung (feminine)", + forms: "eine gute Zugverbindung (a good train connection)", + exampleDe: "Ich suche gerade in der App nach einer schnellen Verbindung nach Hamburg.", + exampleEn: "I'm currently looking in the app for a fast connection to Hamburg.", + clozeSentence: "Wegen der Verspätung habe ich leider meine Anschluss-___ verpasst.", + clozeAnswer: "Verbindung", + usageTip: "Used constantly when traveling by Deutsche Bahn or checking Wi-Fi ('Internetverbindung')." + }, + { + id: "v36", + word: "Verspätung", + article: "die", + plural: "die Verspätungen", + pos: "noun", + level: "A2", + category: "Travel & Culture", + english: "delay / lateness", + breakdown: "verspäten (to be late) + -ung (feminine)", + forms: "Verspätung haben (to be delayed)", + exampleDe: "Der ICE nach Berlin hat heute leider zwanzig Minuten Verspätung.", + exampleEn: "The ICE train to Berlin unfortunately has a twenty-minute delay today.", + clozeSentence: "Entschuldigung für meine kurze ___, die S-Bahn kam nicht.", + clozeAnswer: "Verspätung", + usageTip: "Trains 'haben Verspätung'; people can also say 'Ich verspäte mich um 10 Minuten' (I'll be 10 minutes late)." + }, + { + id: "v37", + word: "umsteigen", + article: null, + plural: null, + pos: "verb (separable)", + level: "A2", + category: "Travel & Culture", + english: "to transfer / to change trains or buses", + breakdown: "um- (separable: change) + steigen (to climb/step)", + forms: "steigt um • stieg um • ist umgestiegen (uses 'sein'!)", + exampleDe: "In Frankfurt müssen wir einmal in den Regionalzug umsteigen.", + exampleEn: "In Frankfurt we have to transfer once to the regional train.", + clozeSentence: "Die ganze Familie: einsteigen (board), aussteigen (exit), ___ (transfer)!", + clozeAnswer: "umsteigen", + usageTip: "Master the trio: 'einsteigen' (get on), 'aussteigen' (get off), 'umsteigen' (transfer). All use 'sein' in the perfect tense!" + }, + { + id: "v38", + word: "Unterkunft", + article: "die", + plural: "die Unterkünfte", + pos: "noun", + level: "B1", + category: "Travel & Culture", + english: "accommodation / lodging", + breakdown: "unter + Kunft (coming under roof — feminine like 'Ankunft' & 'Zukunft')", + forms: "eine Unterkunft buchen (to book accommodation)", + exampleDe: "Wir haben eine gemütliche Unterkunft direkt am Bodensee gefunden.", + exampleEn: "We found a cozy accommodation right on Lake Constance.", + clozeSentence: "Suchst du für das Wochenende noch eine günstige ___?", + clozeAnswer: "Unterkunft", + usageTip: "Remember: 'die Ankunft' (arrival), 'die Auskunft' (information), 'die Unterkunft' (accommodation), 'die Zukunft' (future) are ALL feminine (`die`)!" + }, + { + id: "v39", + word: "Gelegenheit", + article: "die", + plural: "die Gelegenheiten", + pos: "noun", + level: "B1", + category: "Travel & Culture", + english: "opportunity / chance / occasion", + breakdown: "gelegen (situated/convenient) + -heit (always feminine 'die')", + forms: "bei Gelegenheit (when you get a chance)", + exampleDe: "Auf der Reise hatte ich endlich die Gelegenheit, viel Deutsch zu sprechen.", + exampleEn: "On the trip I finally had the opportunity to speak a lot of German.", + clozeSentence: "Kannst du mir das Buch bei ___ wieder zurückgeben?", + clozeAnswer: "Gelegenheit", + usageTip: "'Bei Gelegenheit' is a super natural phrase meaning 'whenever it's convenient / when you get around to it'." + }, + { + id: "v40", + word: "Stimmung", + article: "die", + plural: "die Stimmungen", + pos: "noun", + level: "A2", + category: "Travel & Culture", + english: "atmosphere / mood / vibe", + breakdown: "stimmen (to tune) + -ung (feminine)", + forms: "gute Stimmung (good mood / great atmosphere)", + exampleDe: "Auf dem Weihnachtsmarkt war trotz der Kälte eine wunderbare Stimmung.", + exampleEn: "At the Christmas market there was a wonderful atmosphere despite the cold.", + clozeSentence: "Die Musik hat auf der Feier für richtig gute ___ gesorgt.", + clozeAnswer: "Stimmung", + usageTip: "When Germans want to say 'The vibe/atmosphere was great', they say 'Die Stimmung war super!'" + }, + + // === CONNECTORS, ADVERBS & FLUENCY BOOSTERS (Konnektoren & Redemittel) === + { + id: "v41", + word: "obwohl", + article: null, + plural: null, + pos: "conjunction", + level: "A2", + category: "Fluency Connectors", + english: "although / even though (sends conjugated verb to the end!)", + breakdown: "Subordinating conjunction (Nebensatz -> verb at the end)", + forms: "Compare with: trotzdem (nevertheless, verb in position 2)", + exampleDe: "Er geht heute joggen, obwohl es draußen stark regnet.", + exampleEn: "He is going jogging today, although it is raining heavily outside.", + clozeSentence: "Ich habe noch großen Hunger, ___ ich schon zu Mittag gegessen habe.", + clozeAnswer: "obwohl", + usageTip: "Just like 'weil' and 'dass', 'obwohl' kicks the conjugated verb to the very end of its clause!" + }, + { + id: "v42", + word: "trotzdem", + article: null, + plural: null, + pos: "adverb / connector", + level: "A2", + category: "Fluency Connectors", + english: "nevertheless / anyway / despite that", + breakdown: "trotz (despite) + dem (that) — takes position 1, followed immediately by the verb!", + forms: "Es regnet; trotzdem gehe ich spazieren.", + exampleDe: "Die Prüfung war ziemlich schwer, aber ich habe sie trotzdem bestanden.", + exampleEn: "The exam was quite hard, but I passed it anyway.", + clozeSentence: "Ich war gestern sehr müde; ___ bin ich zum Deutschkurs gegangen.", + clozeAnswer: "trotzdem", + usageTip: "Unlike 'obwohl', when 'trotzdem' starts a clause, the verb comes immediately after it (Position 2): 'Trotzdem gehe ich...'" + }, + { + id: "v43", + word: "deshalb", + article: null, + plural: null, + pos: "adverb / connector", + level: "A2", + category: "Fluency Connectors", + english: "therefore / that's why / for that reason", + breakdown: "Synonyms: deswegen, darum, daher (all keep verb in position 2)", + forms: "Deshalb muss ich... (That's why I have to...)", + exampleDe: "Mein Fahrrad hat einen Platten, deshalb fahre ich heute mit dem Bus.", + exampleEn: "My bike has a flat tire, that's why I'm taking the bus today.", + clozeSentence: "Ich möchte mein Deutsch verbessern; ___ lerne ich jeden Tag im Kontext.", + clozeAnswer: "deshalb", + usageTip: "Remember the inverted word order after 'deshalb': 'Deshalb FAHRE ich...' (NOT 'Deshalb ich fahre')." + }, + { + id: "v44", + word: "übrigens", + article: null, + plural: null, + pos: "adverb", + level: "A2", + category: "Fluency Connectors", + english: "by the way / incidentally", + breakdown: "From 'übrig' (remaining/left over)", + forms: "Übrigens, hast du schon gehört...?", + exampleDe: "Übrigens, das neue Café an der Ecke hat wirklich hervorragenden Kuchen!", + exampleEn: "By the way, the new café on the corner has really excellent cake!", + clozeSentence: "___ habe ich gestern zufällig deinen Bruder in der Stadt getroffen.", + clozeAnswer: "Übrigens", + usageTip: "Instant conversational fluency booster when changing or adding a topic." + }, + { + id: "v45", + word: "auf jeden Fall", + article: null, + plural: null, + pos: "phrase", + level: "A2", + category: "Fluency Connectors", + english: "definitely / by all means / in any case", + breakdown: "auf (on) + jeden (every, Acc.) + Fall (case)", + forms: "Opposite: auf keinen Fall (no way / under no circumstances)", + exampleDe: "Wenn du in Berlin bist, musst du mich auf jeden Fall besuchen!", + exampleEn: "When you're in Berlin, you definitely have to visit me!", + clozeSentence: "Kommst du morgen mit ins Kino? — Ja, ___!", + clozeAnswer: "auf jeden Fall", + usageTip: "Learn the pair together: 'auf jeden Fall' (definitely yes!) and 'auf keinen Fall' (definitely not!)." + }, + { + id: "v46", + word: "Bescheid wissen", + article: null, + plural: null, + pos: "verb phrase", + level: "A2", + category: "Fluency Connectors", + english: "to know about it / to be in the picture", + breakdown: "Bescheid + wissen (weiß, wusste, hat gewusst) + über (+ Acc.)", + forms: "Ich weiß Bescheid! (Got it! / I know!)", + exampleDe: "Danke für die Info, dann weiß ich jetzt Bescheid!", + exampleEn: "Thanks for the info, then I know what's going on now!", + clozeSentence: "Hast du Lisa schon angerufen, damit sie wegen morgen ___?", + clozeAnswer: "Bescheid weiß", + usageTip: "Instead of saying 'Ich weiß' (which can sound blunt), Germans love saying 'Alles klar, ich weiß Bescheid!'" + }, + { + id: "v47", + word: "Gewohnheit", + article: "die", + plural: "die Gewohnheiten", + pos: "noun", + level: "B1", + category: "Fluency Connectors", + english: "habit / custom", + breakdown: "gewohnt (accustomed) + -heit (always feminine 'die')", + forms: "aus Gewohnheit (out of habit)", + exampleDe: "Jeden Morgen zehn Minuten laut Deutsch zu lesen ist eine gute Gewohnheit.", + exampleEn: "Reading German aloud for ten minutes every morning is a good habit.", + clozeSentence: "Trinkst du nachmittags aus ___ immer noch eine Tasse Kaffee?", + clozeAnswer: "Gewohnheit", + usageTip: "Related verb: 'sich gewöhnen an (+ Acc.)' = to get used to ('Ich habe mich schnell an das Wetter gewöhnt')." + }, + { + id: "v48", + word: "Unterschied", + article: "der", + plural: "die Unterschiede", + pos: "noun", + level: "A2", + category: "Fluency Connectors", + english: "difference / distinction", + breakdown: "unter + schied (from unterscheiden = to distinguish)", + forms: "der Unterschied zwischen (+ Dative)", + exampleDe: "Kannst du mir den Unterschied zwischen diesen beiden Wörtern erklären?", + exampleEn: "Can you explain the difference between these two words to me?", + clozeSentence: "Es macht einen großen ___, ob man Wörter nur passiv oder aktiv lernt.", + clozeAnswer: "Unterschied", + usageTip: "Essential question for learners: 'Was ist der Unterschied zwischen X und Y?'" + } +]; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b42931d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +services: + wortkraft: + build: + context: . + dockerfile: Dockerfile + container_name: wortkraft + restart: unless-stopped + volumes: + - /home/chrisf/docker/appdata/wortkraft/data:/app/data + networks: + - t3_proxy + ports: + - "127.0.0.1:8479:80" + healthcheck: + test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://127.0.0.1:80/api/health || exit 1"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 5s + labels: + - "traefik.enable=true" + - "traefik.http.routers.wortkraft.rule=Host(`wortkraft.vloser.us`)" + - "traefik.http.routers.wortkraft.entrypoints=websecure" + - "traefik.http.routers.wortkraft.tls.certresolver=letsencrypt" + - "traefik.http.services.wortkraft.loadbalancer.server.port=80" + +networks: + t3_proxy: + external: true diff --git a/index.html b/index.html new file mode 100644 index 0000000..d4e9b7a --- /dev/null +++ b/index.html @@ -0,0 +1,410 @@ + + + + + + WortKraft — Beyond Duolingo German A2/B1 + + + + + + + + +
+
+
+ +
+
+ WortKraft + A2 → B1 +
+
Active German Vocabulary & Context Studio
+
+
+ + + +
+
+ 🔥 1401 Tage +
+ + +
+
+
+ + +
+ + +
+
+
+

Vom passiven Wiedererkennen zum aktiven Wortschatz.

+

+ After 1,400 days on Duolingo, you already have a strong intuition for basic German structure. + WortKraft bridges the A2→B1 plateau by training active recall with articles, + compound word logic, and real-world production. +

+
+ der • Maskulin + die • Feminin (-ung, -heit, -keit) + das • Neutrum (-chen, -nis, -um) + die • Plural +
+
+
+ + +
+
+ +
+
+
Fällig zur Wiederholung
+
0
+
Bereit für aktiven Abruf (SRS)
+
+
+
Aktiver Wortschatz
+
0 / 48
+
0 gemeistert • 0 im Training
+
+
+
Artikel-Trefferquote
+
—
+
der / die / das & aktive Eingabe
+
+
+
Aktive Sätze geschrieben
+
0
+
In der Sprachpraxis produziert
+
+
+ +
+
+
+

Dein täglicher 3-Schritte-Workflow

+
+
+
+
+
🎯
+
+
1. Aktiver Abruf im Kontext (SRS-Trainer)
+
Lückensätze, Englisch → Deutsch mit Artikel & Plural-Drill statt Multiple-Choice.
+
+
+ → +
+ +
+
+
📖
+
+
2. Interaktive Leseecke (A2/B1 Geschichten & Alltag)
+
Klicke auf jedes Wort für Artikel, Stammform, Audio & 1-Klick-Speichern ins SRS-Deck.
+
+
+ → +
+ +
+
+
💬
+
+
3. Sprachpraxis mit Zielwörtern
+
Löse echte Alltagssituationen (Arzt, Bürgeramt, Feierabend) und nutze gezielt 5 Zielwörter.
+
+
+ → +
+
+
+ +
+
+

Fokus-Wörter des Tages

+ +
+
+
+
+
+ + +
+
+
+ + + + +
+ +
+ +
+
+ +
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+

Mein A2/B1 Wortschatz-Archiv

+

+ Farbcodiert nach Genus (der/die/das), inklusive Wortfamilien, Pluralformen und SRS-Status. +

+
+ +
+ +
+ + + +
+ +
+
+ +
+ + + + + + + + + + + +
+ + + + + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..2466a10 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "wortkraft", + "version": "1.0.0", + "description": "WortKraft — Active German A2/B1 Vocabulary, Graded Reader & Conversation Studio", + "type": "module", + "main": "server/index.js", + "scripts": { + "start": "node server/index.js" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.21.2" + } +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..d8a6c74 --- /dev/null +++ b/server/index.js @@ -0,0 +1,88 @@ +import express from 'express'; +import cors from 'cors'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const app = express(); +const PORT = process.env.PORT || 80; +const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, '../data_store'); +const STATE_FILE = path.join(DATA_DIR, 'wortkraft_state.json'); + +if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); +} + +function readState() { + try { + if (fs.existsSync(STATE_FILE)) { + const raw = fs.readFileSync(STATE_FILE, 'utf8'); + return JSON.parse(raw); + } + } catch (err) { + console.error('[WORTKRAFT] Error reading state file:', err); + } + return null; +} + +function writeState(stateObj) { + try { + fs.writeFileSync(STATE_FILE, JSON.stringify(stateObj, null, 2), 'utf8'); + return true; + } catch (err) { + console.error('[WORTKRAFT] Error writing state file:', err); + return false; + } +} + +app.use(cors()); +app.use(express.json({ limit: '10mb' })); + +// Healthcheck endpoint for Docker & Homepage +app.get('/api/health', (_req, res) => { + res.json({ + status: 'ok', + app: 'wortkraft', + timestamp: new Date().toISOString(), + uptimeSeconds: Math.floor(process.uptime()) + }); +}); + +// Get persisted learning state (SRS deck, streak, custom words & stories) +app.get('/api/state', (_req, res) => { + const state = readState(); + res.json({ exists: Boolean(state), state }); +}); + +// Save learning state +app.post('/api/state', (req, res) => { + const payload = req.body; + if (!payload || typeof payload !== 'object') { + return res.status(400).json({ error: 'Invalid state payload' }); + } + payload.updatedAt = new Date().toISOString(); + const ok = writeState(payload); + res.json({ success: ok, updatedAt: payload.updatedAt }); +}); + +// Serve static frontend files from project root +const PUBLIC_DIR = path.resolve(__dirname, '..'); +app.use('/data', express.static(path.join(PUBLIC_DIR, 'data'))); +app.get('/styles.css', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'styles.css'))); +app.get('/app.js', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'app.js'))); +app.get('/', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'index.html'))); + +app.use((req, res, next) => { + if (req.method === 'GET' && !req.path.startsWith('/api')) { + return res.sendFile(path.join(PUBLIC_DIR, 'index.html')); + } + next(); +}); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`[WORTKRAFT] Server listening on port ${PORT}`); + console.log(`[WORTKRAFT] Persistent data directory: ${DATA_DIR}`); +}); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..fc843b4 --- /dev/null +++ b/styles.css @@ -0,0 +1,1035 @@ +:root { + --bg-main: #f8f7f4; + --bg-card: #ffffff; + --bg-subtle: #f1efe9; + --bg-accent: #18181b; + --text-main: #18181b; + --text-secondary: #52525b; + --text-muted: #71717a; + --border-color: #e4e2dd; + --border-strong: #d4d1c8; + + /* German Gender Color System */ + --der-color: #1d4ed8; + --der-bg: #dbeafe; + --der-border: #93c5fd; + + --die-color: #be123c; + --die-bg: #ffe4e6; + --die-border: #fda4af; + + --das-color: #047857; + --das-bg: #d1fae5; + --das-border: #6ee7b7; + + --pl-color: #b45309; + --pl-bg: #fef3c7; + --pl-border: #fcd34d; + + /* Brand & Action Colors */ + --primary: #d97706; + --primary-dark: #b45309; + --primary-light: #fffbeb; + --indigo: #4f46e5; + --indigo-light: #eef2ff; + --success: #059669; + --success-bg: #ecfdf5; + --danger: #dc2626; + --danger-bg: #fef2f2; + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 18px; + --shadow-sm: 0 1px 3px rgba(24, 24, 27, 0.06); + --shadow-md: 0 6px 20px -4px rgba(24, 24, 27, 0.08); + --shadow-lg: 0 14px 32px -6px rgba(24, 24, 27, 0.14); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background-color: var(--bg-main); + color: var(--text-main); + line-height: 1.6; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* === TOP NAVIGATION === */ +.top-header { + background: var(--bg-card); + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + z-index: 100; +} + +.header-inner { + max-width: 1220px; + margin: 0 auto; + padding: 0.75rem 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.brand { + display: flex; + align-items: center; + gap: 0.75rem; + cursor: pointer; +} + +.brand-flag { + width: 36px; + height: 36px; + border-radius: 10px; + background: linear-gradient(180deg, #18181b 33%, #dc2626 33%, #dc2626 66%, #f59e0b 66%); + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow-sm); + flex-shrink: 0; +} + +.brand-title { + font-weight: 800; + font-size: 1.15rem; + letter-spacing: -0.02em; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.level-pill { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + padding: 0.15rem 0.5rem; + border-radius: 999px; + background: var(--primary-light); + color: var(--primary-dark); + border: 1px solid #fde68a; +} + +.brand-subtitle { + font-size: 0.76rem; + color: var(--text-muted); +} + +.nav-tabs { + display: flex; + gap: 0.35rem; + background: var(--bg-subtle); + padding: 0.3rem; + border-radius: 12px; + overflow-x: auto; +} + +.nav-tab { + border: none; + background: transparent; + color: var(--text-secondary); + font-weight: 600; + font-size: 0.88rem; + padding: 0.5rem 0.9rem; + border-radius: 9px; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.45rem; + transition: all 0.15s ease; + white-space: nowrap; +} + +.nav-tab:hover { + color: var(--text-main); + background: rgba(255, 255, 255, 0.5); +} + +.nav-tab.active { + background: var(--bg-card); + color: var(--text-main); + box-shadow: var(--shadow-sm); +} + +.nav-badge { + background: var(--primary); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + padding: 0.08rem 0.42rem; + border-radius: 999px; +} + +.header-actions { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.streak-chip { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.82rem; + font-weight: 700; + padding: 0.4rem 0.75rem; + border-radius: 999px; + background: #fff7ed; + color: #c2410c; + border: 1px solid #fed7aa; +} + +.icon-btn { + background: var(--bg-subtle); + border: 1px solid var(--border-color); + color: var(--text-main); + width: 38px; + height: 38px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 1rem; + transition: all 0.15s ease; +} + +.icon-btn:hover { + background: #e4e2dd; +} + +/* === MAIN CONTAINER === */ +.main-container { + max-width: 1220px; + width: 100%; + margin: 0 auto; + padding: 1.75rem 1.5rem 4rem; + flex: 1; +} + +.view-section { + display: none; + animation: fadeIn 0.2s ease; +} + +.view-section.active { + display: block; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* === GENDER BADGES & COLOR SYSTEM === */ +.gender-badge { + display: inline-flex; + align-items: center; + font-weight: 700; + font-size: 0.78rem; + padding: 0.15rem 0.55rem; + border-radius: 6px; + letter-spacing: 0.01em; +} + +.gender-der { + background: var(--der-bg); + color: var(--der-color); + border: 1px solid var(--der-border); +} + +.gender-die { + background: var(--die-bg); + color: var(--die-color); + border: 1px solid var(--die-border); +} + +.gender-das { + background: var(--das-bg); + color: var(--das-color); + border: 1px solid var(--das-border); +} + +.gender-pl { + background: var(--pl-bg); + color: var(--pl-color); + border: 1px solid var(--pl-border); +} + +.gender-none { + background: var(--bg-subtle); + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + +/* === DASHBOARD === */ +.hero-banner { + background: linear-gradient(135deg, #18181b 0%, #27272a 100%); + color: #fff; + border-radius: var(--radius-lg); + padding: 2rem; + margin-bottom: 1.75rem; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1.5rem; + flex-wrap: wrap; + box-shadow: var(--shadow-md); +} + +.hero-text h1 { + font-size: 1.65rem; + font-weight: 800; + margin-bottom: 0.45rem; + letter-spacing: -0.02em; +} + +.hero-text p { + color: #d4d4d8; + max-width: 640px; + font-size: 0.96rem; +} + +.gender-legend-bar { + display: flex; + gap: 0.55rem; + margin-top: 1rem; + flex-wrap: wrap; +} + +.hero-cta-group { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.btn { + border: none; + font-family: inherit; + font-weight: 600; + font-size: 0.92rem; + padding: 0.68rem 1.2rem; + border-radius: 10px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.5rem; + transition: all 0.15s ease; +} + +.btn-primary { + background: var(--primary); + color: #fff; +} + +.btn-primary:hover { + background: var(--primary-dark); +} + +.btn-secondary { + background: var(--bg-subtle); + color: var(--text-main); + border: 1px solid var(--border-strong); +} + +.btn-secondary:hover { + background: #e4e2dd; +} + +.btn-light { + background: rgba(255, 255, 255, 0.12); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.22); +} + +.btn-light:hover { + background: rgba(255, 255, 255, 0.2); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; + margin-bottom: 1.75rem; +} + +.stat-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 1.25rem; + box-shadow: var(--shadow-sm); +} + +.stat-label { + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + margin-bottom: 0.35rem; +} + +.stat-value { + font-size: 1.85rem; + font-weight: 800; + color: var(--text-main); + line-height: 1.2; +} + +.stat-sub { + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: 0.3rem; +} + +.dashboard-columns { + display: grid; + grid-template-columns: 1.3fr 1fr; + gap: 1.5rem; +} + +@media (max-width: 900px) { + .dashboard-columns { + grid-template-columns: 1fr; + } +} + +.panel-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 1.5rem; + box-shadow: var(--shadow-sm); +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + gap: 0.5rem; +} + +.panel-title { + font-size: 1.1rem; + font-weight: 700; +} + +.module-launcher-list { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.launcher-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.15rem; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-main); + cursor: pointer; + transition: all 0.15s ease; + gap: 1rem; +} + +.launcher-item:hover { + border-color: var(--primary); + background: #fff; + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.launcher-left { + display: flex; + align-items: center; + gap: 0.9rem; +} + +.launcher-icon { + width: 44px; + height: 44px; + border-radius: 11px; + background: var(--bg-card); + border: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.3rem; + flex-shrink: 0; +} + +.launcher-title { + font-weight: 700; + font-size: 0.98rem; +} + +.launcher-desc { + font-size: 0.84rem; + color: var(--text-secondary); +} + +/* === ACTIVE RECALL SRS TRAINER === */ +.trainer-controls { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.mode-pills { + display: flex; + gap: 0.45rem; + flex-wrap: wrap; +} + +.mode-pill { + border: 1px solid var(--border-color); + background: var(--bg-card); + color: var(--text-secondary); + font-weight: 600; + font-size: 0.85rem; + padding: 0.5rem 0.95rem; + border-radius: 999px; + cursor: pointer; + transition: all 0.15s ease; +} + +.mode-pill.active { + background: var(--bg-accent); + color: #fff; + border-color: var(--bg-accent); +} + +.srs-card-stage { + max-width: 760px; + margin: 0 auto; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 2.25rem; + box-shadow: var(--shadow-md); +} + +.card-meta-top { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.25rem; + gap: 0.5rem; + flex-wrap: wrap; +} + +.category-tag { + font-size: 0.76rem; + font-weight: 600; + padding: 0.2rem 0.65rem; + border-radius: 999px; + background: var(--bg-subtle); + color: var(--text-secondary); +} + +.prompt-instruction { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 0.5rem; +} + +.prompt-main { + font-size: 1.5rem; + font-weight: 800; + color: var(--text-main); + margin-bottom: 0.75rem; +} + +.cloze-box { + background: var(--bg-main); + border-left: 4px solid var(--primary); + padding: 1rem 1.25rem; + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + margin-bottom: 1.25rem; +} + +.cloze-de { + font-size: 1.15rem; + font-weight: 600; + margin-bottom: 0.25rem; +} + +.cloze-en { + font-size: 0.9rem; + color: var(--text-secondary); +} + +.answer-input-row { + display: flex; + gap: 0.6rem; + margin-bottom: 0.65rem; +} + +.german-input { + flex: 1; + font-family: inherit; + font-size: 1.08rem; + font-weight: 500; + padding: 0.75rem 1rem; + border: 2px solid var(--border-strong); + border-radius: 10px; + outline: none; + transition: border-color 0.15s ease; +} + +.german-input:focus { + border-color: var(--primary); +} + +.umlaut-bar { + display: flex; + align-items: center; + gap: 0.35rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; +} + +.umlaut-label { + font-size: 0.75rem; + color: var(--text-muted); + margin-right: 0.25rem; +} + +.umlaut-key { + background: var(--bg-subtle); + border: 1px solid var(--border-strong); + border-radius: 6px; + padding: 0.2rem 0.55rem; + font-weight: 700; + font-size: 0.85rem; + cursor: pointer; + user-select: none; +} + +.umlaut-key:hover { + background: #e4e2dd; +} + +.gender-choice-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; + margin: 1.25rem 0; +} + +.gender-choice-btn { + padding: 1rem; + font-size: 1.1rem; + font-weight: 800; + border-radius: 12px; + cursor: pointer; + border: 2px solid transparent; + transition: transform 0.1s ease; +} + +.gender-choice-btn:hover { + transform: translateY(-2px); +} + +.feedback-panel { + margin-top: 1.5rem; + padding: 1.25rem; + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + background: var(--bg-main); +} + +.feedback-panel.correct { + background: var(--success-bg); + border-color: #6ee7b7; +} + +.feedback-panel.incorrect { + background: var(--danger-bg); + border-color: #fca5a5; +} + +.word-reveal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.75rem; + flex-wrap: wrap; +} + +.word-reveal-title { + font-size: 1.4rem; + font-weight: 800; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.word-detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.75rem; + margin: 0.85rem 0; + font-size: 0.88rem; +} + +.detail-item { + background: rgba(255, 255, 255, 0.75); + padding: 0.65rem 0.85rem; + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.06); +} + +.detail-label { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + color: var(--text-muted); + display: block; +} + +.srs-grade-buttons { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.6rem; + margin-top: 1.2rem; +} + +.grade-btn { + padding: 0.65rem 0.5rem; + border-radius: 9px; + border: 1px solid var(--border-strong); + background: #fff; + cursor: pointer; + font-weight: 700; + font-size: 0.84rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; + transition: all 0.15s ease; +} + +.grade-btn:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.grade-btn span.interval { + font-size: 0.7rem; + font-weight: 500; + color: var(--text-muted); +} + +.grade-again { border-color: #fca5a5; color: #b91c1c; } +.grade-hard { border-color: #fed7aa; color: #c2410c; } +.grade-good { border-color: #86efac; color: #15803d; } +.grade-easy { border-color: #93c5fd; color: #1d4ed8; } + +/* === INTERACTIVE READER (LESEECKE) === */ +.reader-layout { + display: grid; + grid-template-columns: 310px 1fr; + gap: 1.5rem; + align-items: start; +} + +@media (max-width: 940px) { + .reader-layout { + grid-template-columns: 1fr; + } +} + +.story-sidebar { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.story-card-item { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 1rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.story-card-item:hover { + border-color: var(--border-strong); +} + +.story-card-item.active { + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(217, 119, 6, 0.15); +} + +.story-card-meta { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 0.3rem; +} + +.story-card-title { + font-weight: 700; + font-size: 0.95rem; + margin-bottom: 0.25rem; +} + +.story-card-sub { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.reader-main { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 2rem; + box-shadow: var(--shadow-sm); + position: relative; +} + +.reader-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; + padding-bottom: 1.25rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--border-color); +} + +.story-paragraph { + margin-bottom: 1.35rem; +} + +.story-de-line { + font-size: 1.16rem; + line-height: 1.85; + color: var(--text-main); +} + +.story-en-line { + font-size: 0.94rem; + color: var(--text-secondary); + margin-top: 0.35rem; + padding-left: 0.75rem; + border-left: 3px solid var(--border-strong); +} + +.word-token { + cursor: pointer; + padding: 0.08rem 0.16rem; + border-radius: 4px; + transition: background 0.12s ease; +} + +.word-token:hover { + background: #fef3c7; +} + +.word-token.target-vocab { + background: #fef3c7; + border-bottom: 2px solid var(--primary); + font-weight: 600; +} + +.word-token.in-deck { + border-bottom: 2px dotted var(--indigo); +} + +.word-inspector-drawer { + margin-top: 1.5rem; + padding: 1.25rem; + background: var(--bg-subtle); + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); +} + +/* === CONVERSATION & WRITING PRACTICE (SPRACHPRAXIS) === */ +.scenario-layout { + display: grid; + grid-template-columns: 320px 1fr; + gap: 1.5rem; + align-items: start; +} + +@media (max-width: 940px) { + .scenario-layout { + grid-template-columns: 1fr; + } +} + +.target-words-box { + background: var(--bg-subtle); + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + padding: 1rem 1.2rem; + margin-bottom: 1.25rem; +} + +.target-chips-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.6rem; +} + +.target-word-chip { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.75rem; + border-radius: 999px; + background: var(--bg-card); + border: 1px solid var(--border-strong); + font-size: 0.84rem; + font-weight: 600; + transition: all 0.2s ease; +} + +.target-word-chip.used { + background: var(--success-bg); + border-color: #34d399; + color: #065f46; +} + +.chat-stream { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 1.25rem; + max-height: 460px; + overflow-y: auto; + padding-right: 0.25rem; +} + +.chat-bubble { + max-width: 85%; + padding: 1rem 1.2rem; + border-radius: 14px; + font-size: 0.96rem; +} + +.chat-partner { + align-self: flex-start; + background: var(--bg-subtle); + border: 1px solid var(--border-color); + border-bottom-left-radius: 4px; +} + +.chat-user { + align-self: flex-end; + background: var(--bg-accent); + color: #fff; + border-bottom-right-radius: 4px; +} + +.chat-feedback { + align-self: stretch; + max-width: 100%; + background: #fffbeb; + border: 1px solid #fde68a; + color: #78350f; + font-size: 0.86rem; + padding: 0.75rem 1rem; + border-radius: 10px; +} + +/* === VOCABULARY BANK TABLE === */ +.vocab-filter-bar { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 1.25rem; +} + +.vocab-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); + gap: 1rem; +} + +.vocab-item-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 1.15rem; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 0.65rem; +} + +/* === MODAL === */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(24, 24, 27, 0.55); + display: none; + align-items: center; + justify-content: center; + z-index: 200; + padding: 1rem; +} + +.modal-backdrop.open { + display: flex; +} + +.modal-card { + background: var(--bg-card); + border-radius: var(--radius-lg); + max-width: 540px; + width: 100%; + padding: 1.75rem; + box-shadow: var(--shadow-lg); +} + +.toast-notification { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + background: var(--bg-accent); + color: #fff; + padding: 0.8rem 1.2rem; + border-radius: 10px; + font-size: 0.88rem; + font-weight: 600; + box-shadow: var(--shadow-lg); + z-index: 300; + opacity: 0; + transform: translateY(10px); + pointer-events: none; + transition: all 0.2s ease; +} + +.toast-notification.show { + opacity: 1; + transform: translateY(0); +}