// 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 } const stories = window.GRADED_STORIES || []; const scenarios = window.CONVERSATION_SCENARIOS || []; this.activeStoryId = stories[0]?.id || ""; this.showReaderTranslation = false; this.selectedReaderWord = null; this.quizSelection = {}; this.activeScenarioId = scenarios[0]?.id || ""; this.scenarioState = {}; this.spotlightWords = []; this.pendingTranslations = new Set(); // 1) Wortbaukasten (Compound & Prefix Lab) state this.builderSubmode = "prefixes"; // 'prefixes' | 'compounds' | 'quiz' this.selectedVerbRoot = "nehmen"; this.builderQuizIndex = 0; this.builderQuizResult = null; // 2) Verben mit Präposition (Rektion & Da-/Wo-) state this.prepSubmode = "drill"; // 'drill' | 'dawo' | 'reference' this.prepIndex = 0; this.prepResult = null; this.daWoIndex = 0; this.daWoResult = null; // 3) Hörtraining (Listening Dictation & Shadowing) state this.listeningIndex = 0; this.listeningShowHint = false; this.listeningResult = null; this.speechResult = null; this.isRecordingMic = false; this.loadState(); this.hoverTranslateEnabled = this.settings.hoverTranslateEnabled !== false; this.initScenarioStates(); this.setupHoverListeners(); this.updateHoverToggleUI(); 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; this.hoverTranslateEnabled = this.settings.hoverTranslateEnabled !== false; this.updateHoverToggleUI(); } localStorage.setItem(STORAGE_KEY, JSON.stringify(parsed)); this.renderAll(); } else { this.pushStateToServer(); } } catch (_e) { // Offline or local file:// mode — 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 || []; 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 = { aiProvider: "builtin", apiKey: "", hoverTranslateEnabled: true, ...(parsed.settings || {}) }; return; } catch (e) { console.warn("Could not parse saved state, initializing fresh state.", e); } } const now = Date.now(); this.vocab = (window.INITIAL_VOCABULARY || []).map((item, idx) => { const entry = this.createVocabEntry(item); 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: "", hoverTranslateEnabled: true }; this.saveState(); } createVocabEntry(raw) { return { ...raw, srs: raw.srs || { interval: 0, repetition: 0, efactor: 2.5, dueDate: Date.now(), status: "new", 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 || [])]; } // === HOVER-TO-TRANSLATE MODE === toggleHoverTranslate() { this.hoverTranslateEnabled = !this.hoverTranslateEnabled; this.settings.hoverTranslateEnabled = this.hoverTranslateEnabled; this.updateHoverToggleUI(); this.hideHoverTooltip(); this.saveState(); if (this.currentView === "reader") { this.renderReaderMain(); } this.showToast( this.hoverTranslateEnabled ? "🔍 Hover-Übersetzer aktiviert: Fahre mit der Maus über deutsche Wörter!" : "🔍 Hover-Übersetzer deaktiviert." ); } updateHoverToggleUI() { document.body.classList.toggle("hover-translate-on", this.hoverTranslateEnabled); const btn = document.getElementById("hover-translate-toggle"); const statusSpan = document.getElementById("hover-translate-status"); if (btn) btn.classList.toggle("active", this.hoverTranslateEnabled); if (statusSpan) statusSpan.textContent = this.hoverTranslateEnabled ? "AN" : "AUS"; } setupHoverListeners() { let lastHoveredWord = null; const handlePointLookup = (clientX, clientY, targetEl) => { if (!this.hoverTranslateEnabled) return false; // 1. Check if directly over/tapping an explicit .word-token span const tokenEl = targetEl?.closest?.(".word-token"); if (tokenEl) { const word = tokenEl.dataset.word || tokenEl.textContent.trim(); if (word) { this.showHoverTooltipForRect(tokenEl.getBoundingClientRect(), word, true); lastHoveredWord = word; return true; } } // 2. Universal German word detector under cursor/tap const hit = this.getWordAtPoint(clientX, clientY); if (hit && hit.word) { const info = this.lookupWordInfo(hit.word, null); if (!info.needsLiveTranslate) { this.showHoverTooltipForRect(hit.rect, hit.word, false); lastHoveredWord = hit.word; return true; } } return false; }; document.addEventListener("mousemove", e => { if (!this.hoverTranslateEnabled) return; const matched = handlePointLookup(e.clientX, e.clientY, e.target); if (!matched && lastHoveredWord) { lastHoveredWord = null; this.hideHoverTooltip(); } }); // Mobile & Touch support: tap a word to show translation tooltip, tap elsewhere to dismiss document.addEventListener( "touchstart", e => { if (!this.hoverTranslateEnabled) return; const touch = e.touches?.[0]; if (!touch) return; // Ignore taps on interactive controls like buttons/inputs unless it's a .word-token const isToken = e.target?.closest?.(".word-token"); const isInteractiveControl = e.target?.closest?.("button, input, select, textarea, a"); if (isInteractiveControl && !isToken) { this.hideHoverTooltip(); return; } const matched = handlePointLookup(touch.clientX, touch.clientY, e.target); if (!matched) { lastHoveredWord = null; this.hideHoverTooltip(); } }, { passive: true } ); window.addEventListener("scroll", () => this.hideHoverTooltip(), { passive: true }); } getWordAtPoint(x, y) { try { let textNode = null; let offset = 0; if (document.caretRangeFromPoint) { const range = document.caretRangeFromPoint(x, y); if (!range) return null; textNode = range.startContainer; offset = range.startOffset; } else if (document.caretPositionFromPoint) { const pos = document.caretPositionFromPoint(x, y); if (!pos) return null; textNode = pos.offsetNode; offset = pos.offset; } if (!textNode || textNode.nodeType !== Node.TEXT_NODE) return null; const parentEl = textNode.parentElement; if (!parentEl || parentEl.closest("#hover-translate-tooltip, input, textarea, select, script, style")) { return null; } const text = textNode.textContent || ""; if (!text.trim()) return null; const isGermanChar = ch => /[A-Za-zÄÖÜäöüß]/.test(ch); let idx = offset; if (idx >= text.length || !isGermanChar(text[idx])) { if (idx > 0 && isGermanChar(text[idx - 1])) { idx = idx - 1; } else { return null; } } let start = idx; let end = idx + 1; while (start > 0 && isGermanChar(text[start - 1])) start--; while (end < text.length && isGermanChar(text[end])) end++; const word = text.slice(start, end); if (word.length < 2) return null; const wordRange = document.createRange(); wordRange.setStart(textNode, start); wordRange.setEnd(textNode, end); const rect = wordRange.getBoundingClientRect(); // Verify mouse pointer is actually inside/near the word's bounding box if ( x < rect.left - 6 || x > rect.right + 6 || y < rect.top - 6 || y > rect.bottom + 6 ) { return null; } return { word, rect }; } catch (_e) { return null; } } showHoverTooltipForRect(rect, rawWord, allowLiveFetch = true) { let tooltip = document.getElementById("hover-translate-tooltip"); if (!tooltip) { tooltip = document.createElement("div"); tooltip.id = "hover-translate-tooltip"; tooltip.className = "hover-translate-tooltip"; document.body.appendChild(tooltip); } const story = this.currentView === "reader" ? this.getAllStories().find(s => s.id === this.activeStoryId) : null; const info = this.lookupWordInfo(rawWord, story); const articleHtml = info.article ? `${info.article}` : ""; tooltip.innerHTML = `
${articleHtml} ${info.lemma || rawWord}
${info.english}
${info.plural ? `
Plural: ${info.plural}
` : ""} `; const vw = window.innerWidth || 360; const maxW = Math.min(300, vw - 24); const halfW = Math.round(maxW / 2) + 12; const left = Math.min( Math.max(halfW, vw - halfW), Math.max(halfW, rect.left + rect.width / 2) ); const top = Math.max(64, rect.top); // Apply explicit inline critical styles in addition to CSS class so it is 100% immune to stale CSS cache Object.assign(tooltip.style, { position: "fixed", zIndex: "9999", background: "#18181b", color: "#ffffff", padding: "0.55rem 0.85rem", borderRadius: "10px", boxShadow: "0 10px 25px rgba(0,0,0,0.3)", pointerEvents: "none", left: `${left}px`, top: `${top}px`, transform: "translate(-50%, -100%) translateY(-8px)", opacity: "1", display: "block", maxWidth: `${maxW}px`, fontSize: "0.84rem", lineHeight: "1.35", border: "1px solid rgba(255,255,255,0.16)" }); tooltip.classList.add("visible"); if (allowLiveFetch && info.needsLiveTranslate) { this.fetchLiveWordTranslation(rawWord); } } async fetchLiveWordTranslation(rawWord) { const lower = rawWord.toLowerCase(); if (this.pendingTranslations.has(lower)) return; this.pendingTranslations.add(lower); try { const res = await fetch(`/api/translate?word=${encodeURIComponent(rawWord)}`); if (!res.ok) return; const data = await res.json(); if (data && data.english) { if (!window.HOVER_DICT) window.HOVER_DICT = {}; window.HOVER_DICT[lower] = data.english; const enEl = document.getElementById("hover-tt-en-text"); if (enEl) enEl.textContent = data.english; } } catch (_e) { // Ignore offline errors } finally { this.pendingTranslations.delete(lower); } } hideHoverTooltip() { const tooltip = document.getElementById("hover-translate-tooltip"); if (tooltip) { tooltip.classList.remove("visible"); tooltip.style.opacity = "0"; tooltip.style.display = "none"; } } // === AUDIO PRONUNCIATION (Server Native German Neural TTS + Browser Fallback) === speak(text, rate = 0.96) { if (!text) return; // Stop any currently playing audio if (this.currentAudio) { try { this.currentAudio.pause(); this.currentAudio.currentTime = 0; } catch (_e) {} this.currentAudio = null; } if ("speechSynthesis" in window) { window.speechSynthesis.cancel(); } // Primary: Stream authentic native German (de-DE) audio from backend /api/tts if (window.location.protocol.startsWith("http")) { const audio = new Audio(`/api/tts?text=${encodeURIComponent(text)}`); audio.playbackRate = rate; if ("preservesPitch" in audio) audio.preservesPitch = true; this.currentAudio = audio; audio.play().catch(err => { console.warn("Server TTS playback error, falling back to browser TTS:", err); this.speakBrowserFallback(text, rate); }); return; } this.speakBrowserFallback(text, rate); } speakBrowserFallback(text, rate = 0.92) { if (!("speechSynthesis" in window)) { this.showToast("Audio wird von diesem Browser leider nicht unterstützt."); return; } const utterance = new SpeechSynthesisUtterance(text); utterance.lang = "de-DE"; utterance.rate = rate; const voices = window.speechSynthesis.getVoices(); const germanVoice = voices.find(v => v.lang.toLowerCase().startsWith("de") && (v.name.includes("Natural") || v.name.includes("Google") || v.name.includes("Anna") || v.name.includes("Katja") || v.name.includes("Hedda") || v.name.includes("Stefan"))) || voices.find(v => v.lang.toLowerCase().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; this.hideHoverTooltip(); 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(); this.renderDailySpotlight(); } else if (viewName === "trainer") { if (!this.currentCard) this.pickNextCard(); this.renderTrainerStage(); } else if (viewName === "builder") { this.renderBuilderStage(); } else if (viewName === "prepositions") { this.renderPrepositionsStage(); } else if (viewName === "listening") { this.renderListeningStage(); } 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}
„${this.tokenizeSentenceForHover(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 (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; } 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; } 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 rawCloze = w.clozeSentence || w.exampleDe.replace(new RegExp(w.word, "i"), "___"); const clozeParts = rawCloze.split("___"); const clozeInteractiveHtml = clozeParts .map(part => this.tokenizeSentenceForHover(part)) .join('______'); promptHtml = `
🧩 Lückensatz im Kontext • Ergänze das fehlende Wort
${w.english}
${clozeInteractiveHtml}
${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 { 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}
„${this.tokenizeSentenceForHover(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 && typeof inputEl.focus === "function") { 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)) { 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) { 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; 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); } tokenizeSentenceForHover(deText) { if (!deText) return ""; const tokens = deText.split(/([A-Za-zÄÖÜäöüß]+)/g); return tokens .map(token => { if (!/^[A-Za-zÄÖÜäöüß]+$/.test(token)) { return token; } return `${token}`; }) .join(""); } tokenizeParagraphToInteractiveHtml(deText, story) { 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 plural match) const deckMatch = this.vocab.find( v => v.word.toLowerCase() === lower || (v.plural && v.plural.toLowerCase().endsWith(" " + lower)) ); 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 && 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. Check comprehensive Hover Dictionary (100% of story/scenario/example words) if (window.HOVER_DICT && window.HOVER_DICT[lower]) { const hoverEn = window.HOVER_DICT[lower]; const isCap = clean[0] === clean[0].toUpperCase() && clean[0] !== clean[0].toLowerCase(); let inferredArt = null; if (hoverEn.includes("(der ")) inferredArt = "der"; else if (hoverEn.includes("(die ")) inferredArt = "die"; else if (hoverEn.includes("(das ")) inferredArt = "das"; return { surface: clean, lemma: clean, article: inferredArt, plural: null, pos: isCap ? "noun" : "word", english: hoverEn, note: null, isTarget: false, inDeck: false }; } // 6. Smart Morphological Analyzer + Live Translation trigger for custom imported words 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: "Übersetze...", note: morphNote, isTarget: false, inDeck: false, needsLiveTranslate: true }; } inspectReaderWord(rawToken) { const story = this.getAllStories().find(s => s.id === this.activeStoryId); const info = this.lookupWordInfo(rawToken, story); this.selectedReaderWord = info; if (info.needsLiveTranslate) { this.fetchLiveWordTranslation(rawToken).then(() => { if (window.HOVER_DICT && window.HOVER_DICT[rawToken.toLowerCase()]) { this.selectedReaderWord.english = window.HOVER_DICT[rawToken.toLowerCase()]; this.renderReaderMain(); } }); } 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 === "Übersetze..." ? "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 => `
${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: Fahre mit der Maus über ein Wort für die Sofort-Übersetzung oder klicke darauf, um es mit Audio ins SRS-Deck zu speichern!
`; } let quizHtml = ""; if (story.comprehensionQuestion) { const q = story.comprehensionQuestion; const chosen = this.quizSelection[story.id]; quizHtml = `
🧠 Verständnis-Check zur Geschichte
${this.tokenizeSentenceForHover(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(); 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.`); } 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.`); } 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: „${this.tokenizeSentenceForHover(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 = ""; 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 }); 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}
${this.tokenizeSentenceForHover(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}
„${this.tokenizeSentenceForHover(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 & hoverbar)", 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, " "); } // === 1) WORTBAUKASTEN (COMPOUND WORD & PREFIX LAB) === setBuilderSubmode(submode) { this.builderSubmode = submode; this.builderQuizResult = null; ["prefixes", "compounds", "quiz"].forEach(m => { const btn = document.getElementById(`builder-tab-${m}`); if (btn) btn.classList.toggle("active", m === submode); }); this.renderBuilderStage(); } selectVerbRoot(rootName) { this.selectedVerbRoot = rootName; this.renderBuilderStage(); } saveLabItemToDeck(word, article, plural, english, exampleDe, exampleEn, note) { const existing = this.vocab.find(v => v.word.toLowerCase() === word.toLowerCase()); if (existing) { existing.srs.dueDate = Date.now() - 1000; this.saveState(); this.showToast(`⚡ „${word}“ wurde im SRS-Trainer priorisiert!`); return; } const newEntry = this.createVocabEntry({ id: "lab_" + Date.now() + "_" + Math.floor(Math.random() * 1000), word, article: article || null, plural: plural || null, pos: article ? "noun" : "verb", level: "A2/B1", category: "Everyday & Bureaucracy", english, breakdown: note, forms: plural, exampleDe, exampleEn: exampleEn || "", clozeSentence: exampleDe.replace(new RegExp(word, "i"), "___"), clozeAnswer: word, usageTip: note }); this.vocab.unshift(newEntry); this.saveState(); this.showToast(`➕ „${word}“ zum SRS-Trainer hinzugefügt!`); } getBuilderQuizItems() { const data = window.WORD_BUILDER_DATA; if (!data) return []; const items = []; data.verbRoots.forEach(r => { r.derivatives.forEach(d => { items.push({ type: "prefix", root: r.root, word: d.word, separable: d.separable, english: d.english, promptDe: d.quizCloze, answer: d.quizAnswer, explanation: `${d.word} (${d.forms}) — ${d.logic}` }); }); }); data.compounds.forEach(c => { items.push({ type: "compound", compound: c.compound, finalArticle: c.finalArticle, english: c.english, parts: c.parts, explanation: c.ruleNote, exampleDe: c.exampleDe }); }); return items; } checkBuilderQuizAnswer(userVal) { const items = this.getBuilderQuizItems(); const item = items[this.builderQuizIndex % items.length]; if (!item) return; this.stats.totalChecks++; if (item.type === "prefix") { const isCorrect = this.normalizeGerman(userVal) === this.normalizeGerman(item.answer); if (isCorrect) this.stats.correctChecks++; this.builderQuizResult = { isCorrect, message: isCorrect ? `✅ Richtig! „${item.answer}“ (${item.word} — ${item.separable ? "trennbar" : "untrennbar"}).` : `❌ Fast! Gesucht war: „${item.answer}“ (${item.word}).` }; this.speak(item.promptDe.replace("___", item.answer)); } else { const isCorrect = userVal === item.finalArticle; if (isCorrect) this.stats.correctChecks++; this.builderQuizResult = { isCorrect, message: isCorrect ? `✅ Perfekt! Das letzte Wort bestimmt den Artikel: ${item.finalArticle} ${item.compound}!` : `❌ Nicht ganz: Das letzte Wort in „${item.compound}“ bestimmt den Artikel → ${item.finalArticle} ${item.compound}!` }; this.speak(`${item.finalArticle} ${item.compound}`); } this.saveState(); this.renderBuilderStage(); } nextBuilderQuiz() { const items = this.getBuilderQuizItems(); this.builderQuizIndex = (this.builderQuizIndex + 1) % items.length; this.builderQuizResult = null; this.renderBuilderStage(); } renderBuilderStage() { const container = document.getElementById("builder-stage-container"); const data = window.WORD_BUILDER_DATA; if (!container || !data) return; if (this.builderSubmode === "prefixes") { const activeRoot = data.verbRoots.find(r => r.root === this.selectedVerbRoot) || data.verbRoots[0]; const rootPills = data.verbRoots .map( r => ` ` ) .join(""); const cardsHtml = activeRoot.derivatives .map( d => `
${d.separable ? "✂️ Trennbar (mit -ge-)" : "🔒 Untrennbar (ohne -ge-)"}
${d.prefix}${activeRoot.root} → ${d.word}
${d.forms}
${d.english}
💡 Logik: ${d.logic}
„${this.tokenizeSentenceForHover(d.exampleDe)}“
${d.exampleEn}
` ) .join(""); container.innerHTML = `

🔀 Verb-Stämme & Präfix-Labor

Ein einziger deutscher Verbstamm bildet durch Vorsilben (Präfixe) dutzende neue Wörter. Trennbare Präfixe (an-, ab-, auf-, aus-, mit-, vor-, teil-) sind betont und bekommen im Perfekt -ge- in der Mitte. Untrennbare Präfixe (be-, ver-, ent-, er-, über-, unter-) bekommen kein -ge-!

${rootPills}
Grundverb: ${activeRoot.root} — ${activeRoot.baseMeaning}
${cardsHtml}
`; } else if (this.builderSubmode === "compounds") { const compoundsHtml = data.compounds .map(c => { const blocksHtml = c.parts .map((part, idx) => { const isLast = idx === c.parts.length - 1; return ` ${idx > 0 ? `
+
` : ""}
${part.article ? this.getGenderBadgeHtml(part.article) : ""} ${part.word}
${part.meaning}
`; }) .join(""); return `
${this.getGenderBadgeHtml(c.finalArticle)} ${this.tokenizeSentenceForHover(c.compound)} (${c.plural}) — ${c.english}
${blocksHtml}
🎯 Artikel-Regel: ${c.ruleNote}
„${this.tokenizeSentenceForHover(c.exampleDe)}“
`; }) .join(""); container.innerHTML = `

🧱 Komposita-Knacker: Keine Angst vor langen deutschen Wörtern!

Goldene Regel: Egal wie lang ein zusammengesetztes Nomen ist — das allerletzte Baustein-Wort (gelb umrandet) bestimmt immer 100% den Artikel (der/die/das)!

${compoundsHtml} `; } else { // Quiz submode const items = this.getBuilderQuizItems(); const item = items[this.builderQuizIndex % items.length]; let quizBody = ""; if (item.type === "prefix") { quizBody = `
🔀 Präfix-Verb im Kontext • Stamm: „${item.root}“
${item.english}
${this.tokenizeSentenceForHover(item.promptDe)}
${this.renderUmlautBar("builder-quiz-input")}
`; } else { const partsLine = item.parts.map(p => `${p.article ? p.article + " " : ""}${p.word}`).join(" + "); quizBody = `
🧱 Komposita-Artikel-Challenge • Welcher Artikel gewinnt?
___ ${item.compound}
Bausteine: ${partsLine} (${item.english})
`; } container.innerHTML = `
Wortbaukasten-Quiz • Frage ${(this.builderQuizIndex % items.length) + 1} / ${items.length}
${quizBody} ${ this.builderQuizResult ? `
${this.builderQuizResult.message}
${item.explanation}
` : "" }
`; } } // === 2) VERBEN MIT PRÄPOSITION & KASUS (REKTION & DA-/WO-) === setPrepSubmode(submode) { this.prepSubmode = submode; this.prepResult = null; this.daWoResult = null; ["drill", "dawo", "reference"].forEach(m => { const btn = document.getElementById(`prep-tab-${m}`); if (btn) btn.classList.toggle("active", m === submode); }); this.renderPrepositionsStage(); } checkPrepDrillChoice(chosenOption) { const drills = window.PREPOSITION_DRILLS || []; const d = drills[this.prepIndex % drills.length]; if (!d) return; this.stats.totalChecks++; const cleanChosen = chosenOption.split(" (")[0].trim().toLowerCase(); const isCorrect = cleanChosen === d.correctCombo.toLowerCase() || (d.acceptedAnswers || []).some(a => a.toLowerCase() === cleanChosen); if (isCorrect) this.stats.correctChecks++; const fullSentence = `${d.sentenceBefore} ${d.correctCombo} ${d.sentenceAfter}`; this.prepResult = { isCorrect, fullSentence, message: isCorrect ? `✅ Perfekt! „${d.verb} + ${d.preposition} + ${d.grammaticalCase}“ → „${d.correctCombo}“.` : `❌ Fast! Du hast „${chosenOption}“ gewählt, aber richtig ist: „${d.correctCombo}“ (${d.verb} + ${d.preposition} + ${d.grammaticalCase}).` }; this.saveState(); this.speak(fullSentence); this.renderPrepositionsStage(); } nextPrepDrill() { const drills = window.PREPOSITION_DRILLS || []; this.prepIndex = (this.prepIndex + 1) % drills.length; this.prepResult = null; this.renderPrepositionsStage(); } checkDaWoAnswer(chosenWord) { const drills = window.PREPOSITION_DRILLS || []; const d = drills[this.daWoIndex % drills.length]; if (!d) return; this.stats.totalChecks++; const isCorrect = chosenWord.toLowerCase() === d.daForm.toLowerCase(); if (isCorrect) this.stats.correctChecks++; this.daWoResult = { isCorrect, message: isCorrect ? `✅ Genau richtig! da(r) + ${d.preposition} = „${d.daForm}“ (Fragewort: „${d.woForm}“).` : `❌ Nicht ganz: Zu „${d.verb} + ${d.preposition}“ gehört „${d.daForm}“ (und als Frage: „${d.woForm}“).` }; this.saveState(); this.speak(d.daExampleDe); this.renderPrepositionsStage(); } nextDaWoDrill() { const drills = window.PREPOSITION_DRILLS || []; this.daWoIndex = (this.daWoIndex + 1) % drills.length; this.daWoResult = null; this.renderPrepositionsStage(); } renderPrepositionsStage() { const container = document.getElementById("prepositions-stage-container"); const drills = window.PREPOSITION_DRILLS || []; if (!container || drills.length === 0) return; if (this.prepSubmode === "drill") { const d = drills[this.prepIndex % drills.length]; const optionsHtml = d.options .map( opt => ` ` ) .join(""); container.innerHTML = `
Präposition & Kasus • ${(this.prepIndex % drills.length) + 1} / ${drills.length} Verb: ${d.verb} (${d.english})
🎯 Wähle die richtige Präposition + Artikel im richtigen Fall (Akkusativ / Dativ)
${this.tokenizeSentenceForHover(d.sentenceBefore)} [ ___ ] ${this.tokenizeSentenceForHover(d.sentenceAfter)}
Nomen-Hinweis: ${d.nounHint}
${optionsHtml}
${ this.prepResult ? `
${this.prepResult.message}
🗣️ „${this.tokenizeSentenceForHover(this.prepResult.fullSentence)}“
💡 Erklärung: ${d.explanation}
🔗 Da-/Wo-Form: ${d.daForm} / ${d.woForm} — „${this.tokenizeSentenceForHover(d.daExampleDe)}“
` : "" }
`; } else if (this.prepSubmode === "dawo") { const d = drills[this.daWoIndex % drills.length]; const daChoices = ["darauf", "darüber", "darum", "daran", "davon", "dafür"]; const clozeDaSentence = d.daExampleDe.replace(new RegExp(d.daForm, "i"), "___"); container.innerHTML = `
Da- & Wo-Baukasten • ${(this.daWoIndex % drills.length) + 1} / ${drills.length} ${d.verb} + ${d.preposition} (${d.grammaticalCase})
🔗 Wie ersetzen Deutsche einen ganzen Satzteil durch ein „da-Wort“?

Regel: da + Präposition (bei Vokal a/o/u/ü mit -r- dazwischen: da + r + auf = darauf).

${this.tokenizeSentenceForHover(clozeDaSentence)}
Gesuchtes Verb: ${d.verb} (${d.english})
${daChoices .map( ch => ` ` ) .join("")}
${ this.daWoResult ? `
${this.daWoResult.message}
„${this.tokenizeSentenceForHover(d.daExampleDe)}“
` : "" }
`; } else { // Reference table container.innerHTML = `

📋 Die wichtigsten A2/B1 Verben mit fester Präposition

Lerne jedes Verb immer zusammen mit seiner Präposition und dem Fall (Akkusativ oder Dativ).

${drills .map( d => `
${d.verb} ${d.preposition} + ${d.grammaticalCase}
${d.english}
Da-/Wo-Form: ${d.daForm} • ${d.woForm}?
„${this.tokenizeSentenceForHover(`${d.sentenceBefore} ${d.correctCombo} ${d.sentenceAfter}`)}“
` ) .join("")}
`; } } // === 3) HÖRTRAINING & AUSSPRACHE (LISTENING DICTATION & SHADOWING) === computeWordDiffHtml(expectedSentence, userSentence) { const cleanWord = w => w.toLowerCase().replace(/[.,!?„“"']/g, ""); const expectedWords = expectedSentence.split(/\s+/).filter(Boolean); const userWords = (userSentence || "").split(/\s+/).filter(Boolean).map(cleanWord); let matchedCount = 0; const diffHtml = expectedWords .map(expW => { const normExp = cleanWord(expW); const foundIdx = userWords.indexOf(normExp); if (foundIdx !== -1) { userWords.splice(foundIdx, 1); matchedCount++; return `${expW}`; } return `${expW}`; }) .join(" "); const scorePct = Math.round((matchedCount / expectedWords.length) * 100); return { diffHtml, scorePct, matchedCount, total: expectedWords.length }; } checkListeningDictation(e) { e.preventDefault(); const input = document.getElementById("listening-dictation-input"); if (!input) return; const userText = input.value.trim(); if (!userText) return; const list = window.LISTENING_EXERCISES || []; const ex = list[this.listeningIndex % list.length]; if (!ex) return; const { diffHtml, scorePct, matchedCount, total } = this.computeWordDiffHtml(ex.de, userText); this.stats.totalChecks++; this.stats.sentencesWritten++; if (scorePct >= 80) this.stats.correctChecks++; this.listeningResult = { userText, diffHtml, scorePct, matchedCount, total }; this.saveState(); this.renderListeningStage(); } toggleListeningHint() { this.listeningShowHint = !this.listeningShowHint; this.renderListeningStage(); } nextListeningExercise() { const list = window.LISTENING_EXERCISES || []; this.listeningIndex = (this.listeningIndex + 1) % list.length; this.listeningShowHint = false; this.listeningResult = null; this.speechResult = null; this.renderListeningStage(); const nextEx = list[this.listeningIndex % list.length]; if (nextEx) this.speak(nextEx.de, 0.96); } startMicrophonePronunciationCheck() { const SpeechRec = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRec) { this.showToast("Dein Browser unterstützt die Web Speech Mikrofon-Erkennung leider nicht (am besten Chrome oder Edge nutzen)."); return; } const list = window.LISTENING_EXERCISES || []; const ex = list[this.listeningIndex % list.length]; if (!ex) return; if (this.isRecordingMic && this.activeRecognition) { this.activeRecognition.stop(); return; } const recognition = new SpeechRec(); recognition.lang = "de-DE"; recognition.interimResults = false; recognition.maxAlternatives = 1; this.isRecordingMic = true; this.activeRecognition = recognition; this.renderListeningStage(); recognition.onresult = event => { const transcript = event.results?.[0]?.[0]?.transcript || ""; const { diffHtml, scorePct } = this.computeWordDiffHtml(ex.de, transcript); this.speechResult = { transcript, diffHtml, scorePct }; this.stats.sentencesWritten++; this.saveState(); }; recognition.onerror = event => { this.showToast(`Mikrofon-Hinweis: ${event.error}`); }; recognition.onend = () => { this.isRecordingMic = false; this.renderListeningStage(); }; recognition.start(); } renderListeningStage() { const container = document.getElementById("listening-stage-container"); const list = window.LISTENING_EXERCISES || []; if (!container || list.length === 0) return; const ex = list[this.listeningIndex % list.length]; const wordInitialsHint = ex.de .split(" ") .map(w => w[0] + "___") .join(" "); container.innerHTML = `
🎧 Hörtraining & Aussprache • ${(this.listeningIndex % list.length) + 1} / ${list.length} (${ex.level}) ${ex.situation}
Schritt 1: Höre den deutschen Satz (ohne Text!)
Klicke auf Play und tippe unten genau, was du hörst.
${ this.listeningShowHint ? `
🇬🇧 Bedeutung: „${ex.en}“
🔤 Wort-Struktur (${ex.de.split(" ").length} Wörter): ${wordInitialsHint}
` : "" }
✍️ Diktat: Tippe den gehörten deutschen Satz
${this.renderUmlautBar("listening-dictation-input")}
${ this.listeningResult ? `
🎯 Diktat-Ergebnis: ${this.listeningResult.scorePct}% (${this.listeningResult.matchedCount}/${this.listeningResult.total} Wörter erkannt)
${this.listeningResult.diffHtml}
🇬🇧 „${ex.en}“
💡 Hör-Fokus: ${ex.focusNote}
` : "" }
🎙️ Schritt 2: Shadowing & Aussprache-Check (Optional)
Sprich den Satz laut auf Deutsch nach — die Spracherkennung prüft deine Aussprache Wort für Wort!
${ this.speechResult ? ` ` : "" }
`; } renderAll() { this.updateDashboardCounters(); this.renderDailySpotlight(); } } function initWortKraft() { if (!window.app) { window.app = new WortKraftApp(); } } if (document.readyState === "loading") { window.addEventListener("DOMContentLoaded", initWortKraft); } else { initWortKraft(); }