Add Wortbaukasten, Preposition+Case trainer, and Listening Dictation modules

This commit is contained in:
chrisf committed 2026-10-21 04:53:52 +00:00
1 parent e636350940
commit 5f013dbdfe
5 files changed
+1646 -23

No files matched your search

+807
View File
@@ -25,6 +25,26 @@ class WortKraftApp {
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();
@@ -477,6 +497,12 @@ class WortKraftApp {
} 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();
@@ -1924,6 +1950,787 @@ class WortKraftApp {
.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 => `
<button class="btn ${r.root === activeRoot.root ? "btn-primary" : "btn-secondary"}" onclick="app.selectVerbRoot('${r.root}')">
Stamm: <strong>${r.root}</strong>
</button>
`
)
.join("");
const cardsHtml = activeRoot.derivatives
.map(
d => `
<div class="prefix-card">
<div style="display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;">
<div>
<span class="sep-pill ${d.separable ? "sep-yes" : "sep-no"}">
${d.separable ? "✂️ Trennbar (mit -ge-)" : "🔒 Untrennbar (ohne -ge-)"}
</span>
</div>
<div style="display: flex; gap: 0.35rem;">
<button class="icon-btn" style="width: 30px; height: 30px; font-size: 0.8rem;" onclick="app.speak('${this.escapeJs(d.word + ". " + d.exampleDe)}')" title="Anhören">🔊</button>
<button class="icon-btn" style="width: 30px; height: 30px; font-size: 0.8rem;" onclick="app.saveLabItemToDeck('${this.escapeJs(d.word)}', null, '${this.escapeJs(d.forms)}', '${this.escapeJs(d.english)}', '${this.escapeJs(d.exampleDe)}', '${this.escapeJs(d.exampleEn)}', '${this.escapeJs(d.logic)}')" title="Ins SRS-Deck speichern">➕</button>
</div>
</div>
<div style="font-size: 1.2rem; font-weight: 800; margin-top: 0.2rem;">
<span style="color: var(--primary);">${d.prefix}</span>${activeRoot.root} → ${d.word}
</div>
<div style="font-size: 0.8rem; color: var(--text-muted); font-weight: 600;">${d.forms}</div>
<div style="font-weight: 700; color: var(--text-main);">${d.english}</div>
<div style="font-size: 0.82rem; color: var(--text-secondary);">💡 <strong>Logik:</strong> ${d.logic}</div>
<div style="font-size: 0.88rem; background: var(--bg-main); padding: 0.6rem 0.75rem; border-radius: 8px; margin-top: 0.25rem;">
„${this.tokenizeSentenceForHover(d.exampleDe)}“
<div style="font-size: 0.78rem; color: var(--text-muted); margin-top: 0.2rem;">${d.exampleEn}</div>
</div>
</div>
`
)
.join("");
container.innerHTML = `
<div class="panel-card">
<div style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 1rem; margin-bottom: 1rem;">
<div>
<h2 style="font-size: 1.35rem; font-weight: 800;">🔀 Verb-Stämme & Präfix-Labor</h2>
<p style="font-size: 0.9rem; color: var(--text-secondary);">
Ein einziger deutscher Verbstamm bildet durch Vorsilben (Präfixe) dutzende neue Wörter.
<strong>Trennbare</strong> Präfixe (an-, ab-, auf-, aus-, mit-, vor-, teil-) sind betont und bekommen im Perfekt <code>-ge-</code> in der Mitte.
<strong>Untrennbare</strong> Präfixe (be-, ver-, ent-, er-, über-, unter-) bekommen <u>kein</u> <code>-ge-</code>!
</p>
</div>
</div>
<div style="display: flex; gap: 0.6rem; flex-wrap: wrap; margin-bottom: 1rem;">
${rootPills}
</div>
<div style="padding: 0.75rem 1rem; background: var(--bg-subtle); border-radius: 10px; font-weight: 600; font-size: 0.92rem;">
Grundverb: <strong>${activeRoot.root}</strong> — ${activeRoot.baseMeaning}
</div>
<div class="prefix-grid">
${cardsHtml}
</div>
</div>
`;
} 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 ? `<div class="compound-plus">+</div>` : ""}
<div class="compound-block ${isLast ? "head-word" : ""}">
<div style="display: flex; align-items: center; gap: 0.35rem;">
${part.article ? this.getGenderBadgeHtml(part.article) : ""}
<strong style="font-size: 1.02rem;">${part.word}</strong>
</div>
<span style="font-size: 0.78rem; color: var(--text-secondary);">${part.meaning}</span>
</div>
`;
})
.join("");
return `
<div class="panel-card" style="margin-bottom: 1rem;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.75rem;">
<div style="display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap;">
${this.getGenderBadgeHtml(c.finalArticle)}
<strong style="font-size: 1.35rem;">${this.tokenizeSentenceForHover(c.compound)}</strong>
<span style="font-size: 0.85rem; color: var(--text-muted);">(${c.plural})</span>
<span style="font-weight: 600; color: var(--text-secondary);">— ${c.english}</span>
</div>
<div style="display: flex; gap: 0.45rem;">
<button class="btn btn-secondary" style="padding: 0.35rem 0.75rem; font-size: 0.82rem;" onclick="app.speak('${this.escapeJs(c.finalArticle + " " + c.compound + ". " + c.exampleDe)}')">🔊 Anhören</button>
<button class="btn btn-primary" style="padding: 0.35rem 0.75rem; font-size: 0.82rem;" onclick="app.saveLabItemToDeck('${this.escapeJs(c.compound)}', '${c.finalArticle}', '${this.escapeJs(c.plural)}', '${this.escapeJs(c.english)}', '${this.escapeJs(c.exampleDe)}', '', '${this.escapeJs(c.ruleNote)}')">➕ Ins SRS-Deck</button>
</div>
</div>
<div class="compound-blocks-row">
${blocksHtml}
</div>
<div style="font-size: 0.86rem; color: var(--text-secondary); margin-bottom: 0.45rem;">
🎯 <strong>Artikel-Regel:</strong> ${c.ruleNote}
</div>
<div style="font-size: 0.9rem; background: var(--bg-main); padding: 0.6rem 0.85rem; border-radius: 8px;">
„${this.tokenizeSentenceForHover(c.exampleDe)}“
</div>
</div>
`;
})
.join("");
container.innerHTML = `
<div style="margin-bottom: 1.25rem;">
<h2 style="font-size: 1.35rem; font-weight: 800;">🧱 Komposita-Knacker: Keine Angst vor langen deutschen Wörtern!</h2>
<p style="font-size: 0.92rem; color: var(--text-secondary);">
Goldene Regel: Egal wie lang ein zusammengesetztes Nomen ist — <strong>das allerletzte Baustein-Wort (gelb umrandet) bestimmt immer 100% den Artikel (der/die/das)!</strong>
</p>
</div>
${compoundsHtml}
`;
} else {
// Quiz submode
const items = this.getBuilderQuizItems();
const item = items[this.builderQuizIndex % items.length];
let quizBody = "";
if (item.type === "prefix") {
quizBody = `
<div class="prompt-instruction">🔀 Präfix-Verb im Kontext • Stamm: „${item.root}“</div>
<div class="prompt-main">${item.english}</div>
<div class="cloze-box">
<div class="cloze-de">${this.tokenizeSentenceForHover(item.promptDe)}</div>
</div>
<form onsubmit="event.preventDefault(); app.checkBuilderQuizAnswer(document.getElementById('builder-quiz-input').value)">
<div class="answer-input-row">
<input type="text" id="builder-quiz-input" class="german-input" placeholder="Fehlende Verbform eingeben (z.B. ${item.answer.slice(0, 3)}...)" autocomplete="off" />
<button type="submit" class="btn btn-primary">Prüfen</button>
<button type="button" class="btn btn-secondary" onclick="app.nextBuilderQuiz()">Nächste Frage →</button>
</div>
${this.renderUmlautBar("builder-quiz-input")}
</form>
`;
} else {
const partsLine = item.parts.map(p => `${p.article ? p.article + " " : ""}${p.word}`).join(" + ");
quizBody = `
<div class="prompt-instruction">🧱 Komposita-Artikel-Challenge • Welcher Artikel gewinnt?</div>
<div class="prompt-main">___ ${item.compound}</div>
<div style="font-size: 0.95rem; color: var(--text-secondary); margin-bottom: 1rem;">
Bausteine: <strong>${partsLine}</strong> (${item.english})
</div>
<div class="gender-choice-grid" style="grid-template-columns: repeat(3, 1fr);">
<button class="gender-choice-btn gender-der" onclick="app.checkBuilderQuizAnswer('der')">der</button>
<button class="gender-choice-btn gender-die" onclick="app.checkBuilderQuizAnswer('die')">die</button>
<button class="gender-choice-btn gender-das" onclick="app.checkBuilderQuizAnswer('das')">das</button>
</div>
`;
}
container.innerHTML = `
<div class="srs-card-stage">
<div class="card-meta-top">
<span class="category-tag">Wortbaukasten-Quiz • Frage ${(this.builderQuizIndex % items.length) + 1} / ${items.length}</span>
</div>
${quizBody}
${
this.builderQuizResult
? `
<div class="feedback-panel ${this.builderQuizResult.isCorrect ? "correct" : "incorrect"}">
<div style="font-weight: 700; margin-bottom: 0.45rem;">${this.builderQuizResult.message}</div>
<div style="font-size: 0.9rem; color: var(--text-secondary);">${item.explanation}</div>
<div style="margin-top: 0.85rem;">
<button class="btn btn-primary" onclick="app.nextBuilderQuiz()">Weiter zur nächsten Aufgabe →</button>
</div>
</div>
`
: ""
}
</div>
`;
}
}
// === 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 => `
<button
class="btn btn-secondary"
style="justify-content: center; font-size: 1.05rem; padding: 0.85rem;"
onclick="app.checkPrepDrillChoice('${this.escapeJs(opt)}')"
>
${opt}
</button>
`
)
.join("");
container.innerHTML = `
<div class="srs-card-stage">
<div class="card-meta-top">
<span class="category-tag">Präposition & Kasus • ${(this.prepIndex % drills.length) + 1} / ${drills.length}</span>
<span class="${d.grammaticalCase === "Akkusativ" ? "case-badge-akk" : "case-badge-dat"}">
Verb: ${d.verb} (${d.english})
</span>
</div>
<div class="prompt-instruction">🎯 Wähle die richtige Präposition + Artikel im richtigen Fall (Akkusativ / Dativ)</div>
<div class="cloze-box" style="margin: 1rem 0;">
<div class="cloze-de">
${this.tokenizeSentenceForHover(d.sentenceBefore)}
<span style="color:var(--primary);border-bottom:2px solid var(--primary);padding:0 0.5rem;font-weight:800;">[ ___ ]</span>
${this.tokenizeSentenceForHover(d.sentenceAfter)}
</div>
<div class="cloze-en" style="margin-top: 0.35rem;">
Nomen-Hinweis: <strong>${d.nounHint}</strong>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem; margin: 1.25rem 0;">
${optionsHtml}
</div>
${
this.prepResult
? `
<div class="feedback-panel ${this.prepResult.isCorrect ? "correct" : "incorrect"}">
<div style="font-weight: 700; margin-bottom: 0.5rem;">${this.prepResult.message}</div>
<div style="font-size: 1rem; font-weight: 600; margin-bottom: 0.45rem;">
🗣️ „${this.tokenizeSentenceForHover(this.prepResult.fullSentence)}“
<button class="icon-btn" style="width: 28px; height: 28px; margin-left: 0.35rem;" onclick="app.speak('${this.escapeJs(this.prepResult.fullSentence)}')">🔊</button>
</div>
<div style="font-size: 0.88rem; color: var(--text-secondary); margin-bottom: 0.5rem;">
💡 <strong>Erklärung:</strong> ${d.explanation}
</div>
<div style="font-size: 0.86rem; background: rgba(255,255,255,0.75); padding: 0.55rem 0.8rem; border-radius: 8px; margin-bottom: 0.85rem;">
🔗 <strong>Da-/Wo-Form:</strong> <code>${d.daForm}</code> / <code>${d.woForm}</code> — <em>„${this.tokenizeSentenceForHover(d.daExampleDe)}“</em>
</div>
<button class="btn btn-primary" onclick="app.nextPrepDrill()">Nächster Satz →</button>
</div>
`
: ""
}
</div>
`;
} 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 = `
<div class="srs-card-stage">
<div class="card-meta-top">
<span class="category-tag">Da- & Wo-Baukasten • ${(this.daWoIndex % drills.length) + 1} / ${drills.length}</span>
<span class="gender-badge gender-none">${d.verb} + ${d.preposition} (${d.grammaticalCase})</span>
</div>
<div class="prompt-instruction">🔗 Wie ersetzen Deutsche einen ganzen Satzteil durch ein „da-Wort“?</div>
<p style="font-size: 0.88rem; color: var(--text-secondary); margin-bottom: 1rem;">
Regel: <code>da + Präposition</code> (bei Vokal a/o/u/ü mit <strong>-r-</strong> dazwischen: <em>da + r + auf = darauf</em>).
</p>
<div class="cloze-box">
<div class="cloze-de">${this.tokenizeSentenceForHover(clozeDaSentence)}</div>
<div class="cloze-en" style="margin-top: 0.3rem;">Gesuchtes Verb: <strong>${d.verb} (${d.english})</strong></div>
</div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.65rem; margin: 1.25rem 0;">
${daChoices
.map(
ch => `
<button class="btn btn-secondary" style="justify-content: center; font-weight: 700;" onclick="app.checkDaWoAnswer('${ch}')">
${ch}
</button>
`
)
.join("")}
</div>
${
this.daWoResult
? `
<div class="feedback-panel ${this.daWoResult.isCorrect ? "correct" : "incorrect"}">
<div style="font-weight: 700; margin-bottom: 0.45rem;">${this.daWoResult.message}</div>
<div style="font-size: 0.95rem; margin-bottom: 0.75rem;">
„${this.tokenizeSentenceForHover(d.daExampleDe)}“
<button class="icon-btn" style="width: 28px; height: 28px; margin-left: 0.35rem;" onclick="app.speak('${this.escapeJs(d.daExampleDe)}')">🔊</button>
</div>
<button class="btn btn-primary" onclick="app.nextDaWoDrill()">Weiter →</button>
</div>
`
: ""
}
</div>
`;
} else {
// Reference table
container.innerHTML = `
<div class="panel-card">
<h2 style="font-size: 1.3rem; font-weight: 800; margin-bottom: 0.35rem;">📋 Die wichtigsten A2/B1 Verben mit fester Präposition</h2>
<p style="font-size: 0.88rem; color: var(--text-secondary); margin-bottom: 1.25rem;">
Lerne jedes Verb immer zusammen mit seiner Präposition und dem Fall (<span class="case-badge-akk">Akkusativ</span> oder <span class="case-badge-dat">Dativ</span>).
</p>
<div class="vocab-grid">
${drills
.map(
d => `
<div class="vocab-item-card">
<div>
<div style="display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;">
<strong style="font-size: 1.08rem;">${d.verb} <span style="color: var(--primary);">${d.preposition}</span></strong>
<span class="${d.grammaticalCase === "Akkusativ" ? "case-badge-akk" : "case-badge-dat"}">+ ${d.grammaticalCase}</span>
</div>
<div style="font-weight: 600; margin: 0.35rem 0;">${d.english}</div>
<div style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.45rem;">
Da-/Wo-Form: <code>${d.daForm}</code> • <code>${d.woForm}?</code>
</div>
<div style="font-size: 0.86rem; background: var(--bg-main); padding: 0.55rem 0.7rem; border-radius: 8px;">
„${this.tokenizeSentenceForHover(`${d.sentenceBefore} ${d.correctCombo} ${d.sentenceAfter}`)}“
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding-top: 0.5rem; border-top: 1px solid var(--border-color);">
<button class="btn btn-secondary" style="padding: 0.3rem 0.65rem; font-size: 0.78rem;" onclick="app.speak('${this.escapeJs(`${d.sentenceBefore} ${d.correctCombo} ${d.sentenceAfter}`)}')">🔊 Anhören</button>
<button class="btn btn-primary" style="padding: 0.3rem 0.65rem; font-size: 0.78rem;" onclick="app.saveLabItemToDeck('${this.escapeJs(d.verb + " " + d.preposition)}', null, '+ ${d.grammaticalCase} (${d.daForm})', '${this.escapeJs(d.english)}', '${this.escapeJs(`${d.sentenceBefore} ${d.correctCombo} ${d.sentenceAfter}`)}', '', '${this.escapeJs(d.explanation)}')">➕ Ins SRS-Deck</button>
</div>
</div>
`
)
.join("")}
</div>
</div>
`;
}
}
// === 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 `<span class="diff-word diff-ok word-token" data-word="${this.escapeJs(normExp)}">${expW}</span>`;
}
return `<span class="diff-word diff-miss word-token" data-word="${this.escapeJs(normExp)}">${expW}</span>`;
})
.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 = `
<div class="srs-card-stage">
<div class="card-meta-top">
<span class="category-tag">🎧 Hörtraining & Aussprache • ${(this.listeningIndex % list.length) + 1} / ${list.length} (${ex.level})</span>
<span style="font-size: 0.82rem; font-weight: 600; color: var(--text-secondary);">${ex.situation}</span>
</div>
<div class="audio-hero-box">
<div>
<div style="font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.85;">
Schritt 1: Höre den deutschen Satz (ohne Text!)
</div>
<div style="font-size: 1.15rem; font-weight: 800; margin-top: 0.2rem;">
Klicke auf Play und tippe unten genau, was du hörst.
</div>
</div>
<div style="display: flex; gap: 0.6rem; flex-wrap: wrap;">
<button class="btn btn-primary" onclick="app.speak('${this.escapeJs(ex.de)}', 0.96)">
🔊 Normal abspielen (1.0x)
</button>
<button class="btn btn-light" onclick="app.speak('${this.escapeJs(ex.de)}', 0.78)">
🐢 Langsam (0.8x)
</button>
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.5rem;">
<button type="button" class="btn btn-secondary" style="padding: 0.35rem 0.75rem; font-size: 0.8rem;" onclick="app.toggleListeningHint()">
💡 ${this.listeningShowHint ? "Hinweis ausblenden" : "Englische Bedeutung & Anfangsbuchstaben zeigen"}
</button>
<button type="button" class="btn btn-secondary" style="padding: 0.35rem 0.75rem; font-size: 0.8rem;" onclick="app.nextListeningExercise()">
⏭️ Nächster Hör-Satz →
</button>
</div>
${
this.listeningShowHint
? `
<div class="cloze-box" style="margin-bottom: 1rem;">
<div style="font-weight: 700; font-size: 0.92rem;">🇬🇧 Bedeutung: „${ex.en}“</div>
<div style="font-size: 0.84rem; color: var(--text-secondary); margin-top: 0.25rem;">
🔤 Wort-Struktur (${ex.de.split(" ").length} Wörter): <code>${wordInitialsHint}</code>
</div>
</div>
`
: ""
}
<form onsubmit="app.checkListeningDictation(event)">
<div class="prompt-instruction">✍️ Diktat: Tippe den gehörten deutschen Satz</div>
<div class="answer-input-row">
<input
type="text"
id="listening-dictation-input"
class="german-input"
placeholder="Tippe hier, was du gehört hast..."
value="${this.listeningResult ? this.escapeJs(this.listeningResult.userText) : ""}"
autocomplete="off"
/>
<button type="submit" class="btn btn-primary">Diktat prüfen</button>
</div>
${this.renderUmlautBar("listening-dictation-input")}
</form>
${
this.listeningResult
? `
<div class="feedback-panel ${this.listeningResult.scorePct >= 80 ? "correct" : "incorrect"}">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; flex-wrap: wrap;">
<strong>🎯 Diktat-Ergebnis: ${this.listeningResult.scorePct}% (${this.listeningResult.matchedCount}/${this.listeningResult.total} Wörter erkannt)</strong>
<button class="btn btn-secondary" style="padding: 0.3rem 0.7rem; font-size: 0.8rem;" onclick="app.speak('${this.escapeJs(ex.de)}', 0.96)">🔊 Nochmal hören</button>
</div>
<div style="margin-bottom: 0.5rem; line-height: 1.9;">
${this.listeningResult.diffHtml}
</div>
<div style="font-size: 0.86rem; color: var(--text-secondary); margin-bottom: 0.45rem;">
🇬🇧 „${ex.en}“
</div>
<div style="font-size: 0.84rem; color: var(--text-muted);">
💡 <strong>Hör-Fokus:</strong> ${ex.focusNote}
</div>
</div>
`
: ""
}
<div style="margin-top: 1.5rem; padding-top: 1.25rem; border-top: 1px solid var(--border-color);">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.75rem;">
<div>
<div class="prompt-instruction">🎙️ Schritt 2: Shadowing & Aussprache-Check (Optional)</div>
<div style="font-size: 0.88rem; color: var(--text-secondary);">
Sprich den Satz laut auf Deutsch nach — die Spracherkennung prüft deine Aussprache Wort für Wort!
</div>
</div>
<button
type="button"
class="btn btn-secondary ${this.isRecordingMic ? "mic-btn-recording" : ""}"
onclick="app.startMicrophonePronunciationCheck()"
>
${this.isRecordingMic ? "🔴 Höre zu... (Jetzt sprechen)" : "🎙️ Satz laut sprechen"}
</button>
</div>
${
this.speechResult
? `
<div class="feedback-panel ${this.speechResult.scorePct >= 75 ? "correct" : ""}" style="margin-top: 0.85rem;">
<div style="font-weight: 700; margin-bottom: 0.35rem;">
🗣️ Erkannt: „${this.speechResult.transcript}“ (${this.speechResult.scorePct}% Übereinstimmung)
</div>
<div>${this.speechResult.diffHtml}</div>
</div>
`
: ""
}
</div>
</div>
`;
}
renderAll() {
this.updateDashboardCounters();
this.renderDailySpotlight();