Files
wortkraft/app.js
T

2781 lines
106 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
? `<span class="gender-badge ${
info.article.startsWith("der")
? "gender-der"
: info.article.startsWith("die (Pl")
? "gender-pl"
: info.article.startsWith("die")
? "gender-die"
: "gender-das"
}" style="font-size:0.68rem;padding:0.05rem 0.4rem;">${info.article}</span>`
: "";
tooltip.innerHTML = `
<div class="hover-tt-top" style="display:flex;align-items:center;gap:0.4rem;margin-bottom:0.18rem;font-weight:700;color:#f4f4f5;">
${articleHtml}
<span>${info.lemma || rawWord}</span>
</div>
<div class="hover-tt-en" id="hover-tt-en-text" style="color:#fde68a;font-weight:700;font-size:0.92rem;">${info.english}</div>
${info.plural ? `<div class="hover-tt-note" style="color:#a1a1aa;font-size:0.74rem;margin-top:0.15rem;">Plural: ${info.plural}</div>` : ""}
`;
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 `<span class="gender-badge gender-none">${pos || "Wort"}</span>`;
}
const clean = article.toLowerCase();
if (clean === "der") return `<span class="gender-badge gender-der">der</span>`;
if (clean === "die") return `<span class="gender-badge gender-die">die</span>`;
if (clean === "das") return `<span class="gender-badge gender-das">das</span>`;
if (clean.includes("pl")) return `<span class="gender-badge gender-pl">die (Pl.)</span>`;
return `<span class="gender-badge gender-none">${article}</span>`;
}
// === 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 => `
<div style="padding: 0.85rem 1rem; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-main); display: flex; justify-content: space-between; align-items: flex-start; gap: 0.75rem;">
<div>
<div style="display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap;">
${this.getGenderBadgeHtml(w.article, w.pos)}
<strong style="font-size: 1rem;">${w.word}</strong>
<span style="font-size: 0.84rem; color: var(--text-secondary);">— ${w.english}</span>
</div>
<div style="font-size: 0.88rem; color: var(--text-muted); margin-top: 0.35rem;">
„${this.tokenizeSentenceForHover(w.exampleDe)}“
</div>
</div>
<button class="icon-btn" style="width: 32px; height: 32px; flex-shrink: 0;" onclick="app.speak('${this.escapeJs(
(w.article ? w.article.replace(" (Pl.)", "") + " " : "") + w.word + ". " + w.exampleDe
)}')" title="Vorlesen">🔊</button>
</div>
`
)
.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 = `
<div class="srs-card-stage" style="text-align: center;">
<h2>🎉 Alle Karten in dieser Auswahl erledigt!</h2>
<p style="color: var(--text-secondary); margin: 0.75rem 0 1.25rem;">Wähle oben eine andere Kategorie oder lies eine Geschichte in der Leseecke.</p>
<button class="btn btn-primary" onclick="app.switchView('reader')">📖 Zur Leseecke</button>
</div>
`;
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('<span style="color:var(--primary);border-bottom:2px solid var(--primary);padding:0 0.4rem;">______</span>');
promptHtml = `
<div class="prompt-instruction">🧩 Lückensatz im Kontext • Ergänze das fehlende Wort</div>
<div class="prompt-main">${w.english}</div>
<div class="cloze-box">
<div class="cloze-de">${clozeInteractiveHtml}</div>
<div class="cloze-en">${w.exampleEn}</div>
</div>
${
!this.cardRevealed
? `
<form onsubmit="app.checkTrainerTextAnswer(event)">
<div class="answer-input-row">
<input type="text" id="trainer-answer-input" class="german-input" placeholder="Fehlendes Wort eingeben (z.B. ${w.clozeAnswer ? w.clozeAnswer.slice(0, 2) + "..." : w.word.slice(0, 2) + "..."})" autocomplete="off" autofocus />
<button type="submit" class="btn btn-primary">Prüfen</button>
<button type="button" class="btn btn-secondary" onclick="app.revealCardWithoutAnswer()">Lösung zeigen</button>
</div>
${this.renderUmlautBar("trainer-answer-input")}
</form>
`
: ""
}
`;
} else if (this.trainerMode === "production") {
const needsArticle = Boolean(w.article);
promptHtml = `
<div class="prompt-instruction">✍️ Aktiver Abruf • Übersetze auf Deutsch ${needsArticle ? "(MIT Artikel der/die/das!)" : ""}</div>
<div class="prompt-main">${w.english}</div>
<div style="font-size: 0.9rem; color: var(--text-secondary); margin-bottom: 1rem;">
Wortart: <strong>${w.pos}</strong> • Kontext: <em>„${w.exampleEn}“</em>
</div>
${
!this.cardRevealed
? `
<form onsubmit="app.checkTrainerTextAnswer(event)">
<div class="answer-input-row">
<input type="text" id="trainer-answer-input" class="german-input" placeholder="${needsArticle ? "z.B. die Vereinbarung" : "Deutsches Wort eingeben..."}" autocomplete="off" autofocus />
<button type="submit" class="btn btn-primary">Prüfen</button>
<button type="button" class="btn btn-secondary" onclick="app.revealCardWithoutAnswer()">Lösung zeigen</button>
</div>
${this.renderUmlautBar("trainer-answer-input")}
</form>
`
: ""
}
`;
} else if (this.trainerMode === "gender") {
promptHtml = `
<div class="prompt-instruction">🎨 Artikel & Plural-Blitz • Welcher Artikel gehört zu diesem Nomen?</div>
<div class="prompt-main" style="font-size: 2rem;">___ ${w.word}</div>
<div style="font-size: 0.95rem; color: var(--text-secondary); margin-bottom: 1rem;">
Bedeutung: <strong>${w.english}</strong>
</div>
${
!this.cardRevealed
? `
<div class="gender-choice-grid">
<button class="gender-choice-btn gender-der" onclick="app.checkGenderChoice('der')">der</button>
<button class="gender-choice-btn gender-die" onclick="app.checkGenderChoice('die')">die</button>
<button class="gender-choice-btn gender-das" onclick="app.checkGenderChoice('das')">das</button>
<button class="gender-choice-btn gender-pl" onclick="app.checkGenderChoice('die (Pl.)')">die (Pl.)</button>
</div>
`
: ""
}
`;
} else {
promptHtml = `
<div class="prompt-instruction">🃏 Karteikarte & Wortbau • Überprüfe Artikel, Aussprache & Satz</div>
<div class="prompt-main">${w.english}</div>
<div style="font-size: 0.92rem; color: var(--text-secondary); margin-bottom: 1.25rem;">
Versuche das deutsche Wort (inkl. Artikel & Beispielsatz) laut auszusprechen, bevor du umdrehst.
</div>
${
!this.cardRevealed
? `
<button class="btn btn-primary" onclick="app.revealCardWithoutAnswer()">🔄 Karte umdrehen & Audio hören</button>
`
: ""
}
`;
}
let revealHtml = "";
if (this.cardRevealed) {
const statusClass = this.lastAnswerStatus
? this.lastAnswerStatus.isCorrect
? "correct"
: "incorrect"
: "";
revealHtml = `
<div class="feedback-panel ${statusClass}">
${
this.lastAnswerStatus
? `<div style="font-weight: 700; margin-bottom: 0.65rem; font-size: 0.95rem;">
${this.lastAnswerStatus.message}
</div>`
: ""
}
<div class="word-reveal-header">
<div class="word-reveal-title">
${this.getGenderBadgeHtml(w.article, w.pos)}
<span>${w.word}</span>
${w.plural ? `<span style="font-size: 0.9rem; font-weight: 500; color: var(--text-secondary);">(${w.plural})</span>` : ""}
</div>
<button class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.84rem;" onclick="app.speak('${this.escapeJs(
fullDisplayWord + ". " + w.exampleDe
)}')">
🔊 Aussprache & Satz
</button>
</div>
<div style="font-size: 1.02rem; margin-bottom: 0.65rem;">
<strong>${w.english}</strong>
</div>
<div class="cloze-box" style="background: rgba(255,255,255,0.85); margin-bottom: 0.85rem;">
<div class="cloze-de">„${this.tokenizeSentenceForHover(w.exampleDe)}“</div>
<div class="cloze-en">${w.exampleEn}</div>
</div>
<div class="word-detail-grid">
${
w.breakdown
? `<div class="detail-item">
<span class="detail-label">🧱 Wortbau & Eselsbrücke</span>
<span>${w.breakdown}</span>
</div>`
: ""
}
${
w.forms
? `<div class="detail-item">
<span class="detail-label">🔄 Formen & Kollokationen</span>
<span>${w.forms}</span>
</div>`
: ""
}
${
w.usageTip
? `<div class="detail-item" style="grid-column: 1 / -1;">
<span class="detail-label">💡 Alltagstipp (Beyond Duolingo)</span>
<span>${w.usageTip}</span>
</div>`
: ""
}
</div>
<div style="margin-top: 1rem; font-size: 0.8rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase;">
Wie gut saß das Wort aktiv? (SM-2 Spaced Repetition)
</div>
<div class="srs-grade-buttons">
<button class="grade-btn grade-again" onclick="app.gradeCurrentCard(1)">
Nochmal
<span class="interval">&lt; 10 Min</span>
</button>
<button class="grade-btn grade-hard" onclick="app.gradeCurrentCard(2)">
Schwer
<span class="interval">1 Tag</span>
</button>
<button class="grade-btn grade-good" onclick="app.gradeCurrentCard(3)">
Gut
<span class="interval">3 Tage</span>
</button>
<button class="grade-btn grade-easy" onclick="app.gradeCurrentCard(4)">
Leicht
<span class="interval">7+ Tage</span>
</button>
</div>
</div>
`;
}
container.innerHTML = `
<div class="srs-card-stage">
<div class="card-meta-top">
<span class="category-tag">${w.category} • ${w.level}</span>
<span style="font-size: 0.8rem; color: var(--text-muted);">
Wiederholungen: ${w.srs.reviews} • Status: <strong>${w.srs.status.toUpperCase()}</strong>
</span>
</div>
${promptHtml}
${revealHtml}
</div>
`;
const inputEl = document.getElementById("trainer-answer-input");
if (inputEl && !this.cardRevealed && typeof inputEl.focus === "function") {
setTimeout(() => inputEl.focus(), 30);
}
}
renderUmlautBar(inputId) {
const chars = ["ä", "ö", "ü", "ß", "Ä", "Ö", "Ü"];
return `
<div class="umlaut-bar">
<span class="umlaut-label">Umlaute:</span>
${chars
.map(
ch =>
`<button type="button" class="umlaut-key" onclick="app.insertUmlaut('${inputId}', '${ch}')">${ch}</button>`
)
.join("")}
</div>
`;
}
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 <strong>${w.article} ${w.word}</strong>! (${w.breakdown || "Merk dir Wort + Artikel immer als Einheit."})`
};
} else {
this.lastAnswerStatus = {
isCorrect: false,
message: `❌ Deine Eingabe: „${userRaw}“ — Richtige Antwort: <strong>${w.article ? w.article + " " : ""}${w.word}</strong>.`
};
}
}
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 <strong>${w.article} ${w.word}</strong> (${w.plural || "kein Plural"}).`
: `❌ Nicht ganz: Du hast „${chosenArticle}“ gewählt, aber es heißt <strong>${w.article} ${w.word}</strong>!`
};
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 => `
<div class="story-card-item ${s.id === this.activeStoryId ? "active" : ""}" onclick="app.selectStory('${s.id}')">
<div class="story-card-meta">
<span>${s.type}</span>
<span>${s.level} • ${s.readTime}</span>
</div>
<div class="story-card-title">${s.title}</div>
<div class="story-card-sub">${s.subtitle}</div>
</div>
`
)
.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 `<span class="word-token" data-word="${this.escapeJs(token)}" onclick="app.speak('${this.escapeJs(token)}')">${token}</span>`;
})
.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 `<span class="${classes.join(" ")}" data-word="${this.escapeJs(token)}" onclick="app.inspectReaderWord('${this.escapeJs(token)}')">${token}</span>`;
})
.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 => `
<div class="story-paragraph">
<div class="story-de-line">
${this.tokenizeParagraphToInteractiveHtml(p.de, story)}
<button class="icon-btn" style="width: 28px; height: 28px; font-size: 0.8rem; margin-left: 0.35rem; vertical-align: middle;" onclick="app.speak('${this.escapeJs(p.de)}')" title="Absatz anhören">🔊</button>
</div>
${
this.showReaderTranslation && p.en
? `<div class="story-en-line">${p.en}</div>`
: ""
}
</div>
`
)
.join("");
let inspectorHtml = "";
if (this.selectedReaderWord) {
const w = this.selectedReaderWord;
inspectorHtml = `
<div class="word-inspector-drawer">
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; flex-wrap: wrap;">
<div>
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
${this.getGenderBadgeHtml(w.article, w.pos)}
<strong style="font-size: 1.25rem;">${w.lemma}</strong>
${w.plural ? `<span style="font-size: 0.86rem; color: var(--text-secondary);">(${w.plural})</span>` : ""}
<button class="icon-btn" style="width: 30px; height: 30px;" onclick="app.speak('${this.escapeJs(
(w.article ? w.article.replace(" (Pl.)", "") + " " : "") + w.lemma
)}')">🔊</button>
</div>
<div style="font-size: 1rem; font-weight: 600; color: var(--text-main); margin-top: 0.25rem;">
${w.english}
</div>
${
w.note
? `<div style="font-size: 0.84rem; color: var(--text-secondary); margin-top: 0.25rem;">💡 ${w.note}</div>`
: ""
}
</div>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-primary" style="padding: 0.5rem 0.9rem; font-size: 0.84rem;" onclick="app.saveInspectedWordToDeck()">
${w.inDeck ? "⚡ Im SRS-Trainer priorisieren" : "➕ Ins SRS-Deck speichern"}
</button>
</div>
</div>
</div>
`;
} else {
inspectorHtml = `
<div class="word-inspector-drawer" style="text-align: center; color: var(--text-secondary); font-size: 0.9rem;">
👆 <strong>Tipp:</strong> Fahre mit der Maus über ein Wort für die Sofort-Übersetzung oder klicke darauf, um es mit Audio ins SRS-Deck zu speichern!
</div>
`;
}
let quizHtml = "";
if (story.comprehensionQuestion) {
const q = story.comprehensionQuestion;
const chosen = this.quizSelection[story.id];
quizHtml = `
<div style="margin-top: 1.75rem; padding-top: 1.5rem; border-top: 1px solid var(--border-color);">
<div class="prompt-instruction">🧠 Verständnis-Check zur Geschichte</div>
<div style="font-weight: 700; font-size: 1.02rem; margin-bottom: 0.75rem;">${this.tokenizeSentenceForHover(q.question)}</div>
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
${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 `
<button
style="text-align: left; padding: 0.75rem 1rem; border-radius: 10px; border: 1px solid ${border}; background: ${bg}; cursor: pointer; font-family: inherit; font-size: 0.92rem; font-weight: 500;"
onclick="app.answerStoryQuiz('${story.id}', ${idx})"
>
${opt}
</button>
`;
})
.join("")}
</div>
${
chosen !== undefined
? `<div style="margin-top: 0.75rem; font-size: 0.88rem; color: var(--text-secondary);">
${chosen === q.correctIndex ? "✅ Richtig! " : "❌ Nicht ganz: "}${q.explanation}
</div>`
: ""
}
</div>
`;
}
container.innerHTML = `
<div class="reader-toolbar">
<div>
<span class="category-tag">${story.type} • ${story.level}</span>
<h2 style="font-size: 1.4rem; font-weight: 800; margin-top: 0.35rem;">${story.title}</h2>
<p style="font-size: 0.88rem; color: var(--text-secondary);">${story.subtitle}</p>
</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<button class="btn ${this.hoverTranslateEnabled ? "btn-primary" : "btn-secondary"}" style="padding: 0.45rem 0.85rem; font-size: 0.84rem;" onclick="app.toggleHoverTranslate()">
🔍 Hover-Übersetzer: ${this.hoverTranslateEnabled ? "AN" : "AUS"}
</button>
<button class="btn btn-secondary" style="padding: 0.45rem 0.85rem; font-size: 0.84rem;" onclick="app.readWholeStoryAloud(0.85)">
🐢 Langsam (0.85x)
</button>
<button class="btn btn-secondary" style="padding: 0.45rem 0.85rem; font-size: 0.84rem;" onclick="app.readWholeStoryAloud(1.0)">
🔊 Normal (1.0x)
</button>
<button class="btn ${this.showReaderTranslation ? "btn-primary" : "btn-secondary"}" style="padding: 0.45rem 0.85rem; font-size: 0.84rem;" onclick="app.toggleReaderTranslation()">
🌐 Satz-Übersetzung ${this.showReaderTranslation ? "aus" : "an"}
</button>
</div>
</div>
${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 `
<div class="story-card-item ${scen.id === this.activeScenarioId ? "active" : ""}" onclick="app.selectScenario('${scen.id}')">
<div class="story-card-meta">
<span>${scen.avatar} ${scen.rolePartner}</span>
<span>${usedCount}/${scen.targetWords.length} Zielwörter</span>
</div>
<div class="story-card-title">${scen.title}</div>
<div class="story-card-sub">Level ${scen.level}</div>
</div>
`;
}).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(`🎯 <strong>Zielwörter aktiv eingebaut:</strong> ${newlyUsedTargetWords.join(", ")}!`);
} else {
tips.push(`💡 <strong>Tipp:</strong> Versuche in deiner Antwort mindestens eines der oben gelisteten <em>Zielwörter</em> zu verwenden.`);
}
const lower = trimmed.toLowerCase();
if (lower.includes("weil ") || lower.includes("obwohl ") || lower.includes("dass ")) {
tips.push(`🧱 <strong>Nebensatz-Struktur genutzt (weil/obwohl/dass):</strong> 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(`⚠️ <strong>Wortstellung nach „deshalb / trotzdem“:</strong> Im Deutschen steht das Verb direkt auf Position 2: <em>„Deshalb <u>möchte ich</u>...“</em> (nicht „Deshalb ich möchte“).`);
} else {
tips.push(`✨ <strong>Starker Konnektor (deshalb/trotzdem) mit richtiger Verb-Zweitstellung!</strong>`);
}
}
if (currentTurn && currentTurn.sampleResponse) {
tips.push(`🇩🇪 <strong>Muster-Formulierung zum Vergleich:</strong> <em>„${this.tokenizeSentenceForHover(currentTurn.sampleResponse)}“</em>`);
}
return tips.join("<br/>");
}
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 = `🤖 <strong>Live-KI-Feedback:</strong> ${aiCoachNote}<br/>` + 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 `
<div class="target-word-chip ${isUsed ? "used" : ""}" data-word="${tw.word}" title="${tw.hint}">
<span class="chip-check">${isUsed ? "✅" : "⭕"}</span>
<span>${tw.word}</span>
<span style="font-size: 0.75rem; font-weight: 400; color: var(--text-muted);">(${tw.hint})</span>
</div>
`;
})
.join("");
const messagesHtml = st.messages
.map(m => {
if (m.role === "partner") {
return `
<div class="chat-bubble chat-partner">
<div style="display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem;">
<strong style="font-size: 0.8rem; color: var(--text-muted);">${scen.avatar} ${scen.rolePartner}</strong>
<button class="icon-btn" style="width: 26px; height: 26px; font-size: 0.75rem;" onclick="app.speak('${this.escapeJs(m.de)}')">🔊</button>
</div>
<div>${this.tokenizeSentenceForHover(m.de)}</div>
${m.en ? `<div style="font-size: 0.82rem; color: var(--text-secondary); margin-top: 0.25rem;">${m.en}</div>` : ""}
</div>
`;
} else if (m.role === "user") {
return `
<div class="chat-bubble chat-user">
<div style="font-size: 0.76rem; opacity: 0.75; margin-bottom: 0.2rem;">Du (Aktiv produziert)</div>
<div>${m.de}</div>
</div>
`;
} else {
return `<div class="chat-feedback">${m.html}</div>`;
}
})
.join("");
container.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap;">
<div>
<span class="category-tag">Rollenspiel • ${scen.level}</span>
<h2 style="font-size: 1.3rem; font-weight: 800; margin-top: 0.25rem;">${scen.avatar} ${scen.title}</h2>
<p style="font-size: 0.88rem; color: var(--text-secondary); margin-top: 0.2rem;">${scen.situation}</p>
</div>
<button class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.82rem;" onclick="app.resetScenario('${scen.id}')">
🔄 Dialog neu starten
</button>
</div>
<div class="target-words-box">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 0.8rem; font-weight: 700; text-transform: uppercase; color: var(--text-secondary);">
🎯 Deine 5 Zielwörter für dieses Gespräch (${st.usedWords.length}/${scen.targetWords.length} genutzt)
</span>
</div>
<div class="target-chips-row">
${targetChipsHtml}
</div>
</div>
<div class="chat-stream" id="chat-stream-box">
${messagesHtml}
</div>
${
!st.completed
? `
<div style="background: var(--bg-main); padding: 1rem; border-radius: var(--radius-md); border: 1px solid var(--border-color);">
<div style="font-size: 0.84rem; color: var(--text-secondary); margin-bottom: 0.6rem;">
<strong>Aufgabe (Schritt ${st.turnIndex + 1}/${scen.turns.length}):</strong> ${currentTurn.promptHint}
</div>
<form onsubmit="app.handlePracticeSubmit(event)">
<div class="answer-input-row">
<input
type="text"
id="practice-user-input"
class="german-input"
placeholder="Schreibe deine deutsche Antwort (nutze die Zielwörter)..."
autocomplete="off"
oninput="app.updateLiveTargetWordHighlights(this.value)"
/>
<button type="submit" class="btn btn-primary">Senden ↵</button>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
${this.renderUmlautBar("practice-user-input")}
<button type="button" class="btn btn-secondary" style="padding: 0.3rem 0.7rem; font-size: 0.78rem;" onclick="app.insertSampleHint()">
💡 Starthilfe / Mustersatz einfügen
</button>
</div>
</form>
</div>
`
: `
<div style="text-align: center; padding: 1rem; background: var(--success-bg); border: 1px solid #6ee7b7; border-radius: var(--radius-md);">
<strong>🎉 Szenario abgeschlossen!</strong> Du hast ${st.usedWords.length} von ${scen.targetWords.length} Zielwörtern aktiv im Kontext verwendet.
<div style="margin-top: 0.65rem;">
<button class="btn btn-primary" onclick="app.resetScenario('${scen.id}')">Nochmal üben</button>
</div>
</div>
`
}
`;
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 => `
<div class="vocab-item-card">
<div>
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem;">
<div style="display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap;">
${this.getGenderBadgeHtml(w.article, w.pos)}
<strong style="font-size: 1.08rem;">${w.word}</strong>
</div>
<button class="icon-btn" style="width: 30px; height: 30px; font-size: 0.82rem;" onclick="app.speak('${this.escapeJs(
(w.article ? w.article.replace(" (Pl.)", "") + " " : "") + w.word + ". " + w.exampleDe
)}')">🔊</button>
</div>
${w.plural ? `<div style="font-size: 0.78rem; color: var(--text-muted); margin-top: 0.1rem;">Plural: ${w.plural}</div>` : ""}
<div style="font-weight: 600; color: var(--text-main); margin: 0.45rem 0 0.35rem;">${w.english}</div>
<div style="font-size: 0.85rem; color: var(--text-secondary); background: var(--bg-main); padding: 0.5rem 0.65rem; border-radius: 8px;">
„${this.tokenizeSentenceForHover(w.exampleDe)}“
</div>
${w.breakdown ? `<div style="font-size: 0.78rem; color: var(--text-muted); margin-top: 0.45rem;">🧱 ${w.breakdown}</div>` : ""}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding-top: 0.5rem; border-top: 1px solid var(--border-color); font-size: 0.75rem; color: var(--text-muted);">
<span>${w.category}</span>
<span>SRS: <strong>${w.srs.status.toUpperCase()}</strong> (${w.srs.reviews}x)</span>
</div>
</div>
`
)
.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, "&quot;")
.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 class="prep-options-grid">
${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 class="dawo-options-grid">
${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();
}
}
function initWortKraft() {
if (!window.app) {
window.app = new WortKraftApp();
}
}
if (document.readyState === "loading") {
window.addEventListener("DOMContentLoaded", initWortKraft);
} else {
initWortKraft();
}