Add Wortbaukasten, Preposition+Case trainer, and Listening Dictation modules
This commit is contained in:
1 parent
e636350940
commit
5f013dbdfe
5 files changed
+1646
-23
No files matched your search
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user