// WortKraft — Active German A2/B1 Vocabulary, Reader & Conversation Studio
const STORAGE_KEY = "wortkraft_state_v1";
class WortKraftApp {
constructor() {
this.currentView = "dashboard";
this.trainerMode = "cloze"; // 'cloze' | 'production' | 'gender' | 'flashcard'
this.trainerFilter = "due";
this.currentCard = null;
this.cardRevealed = false;
this.lastAnswerStatus = null; // { isCorrect, message, userText }
const stories = window.GRADED_STORIES || [];
const scenarios = window.CONVERSATION_SCENARIOS || [];
this.activeStoryId = stories[0]?.id || "";
this.showReaderTranslation = false;
this.selectedReaderWord = null;
this.quizSelection = {};
this.activeScenarioId = scenarios[0]?.id || "";
this.scenarioState = {};
this.spotlightWords = [];
this.pendingTranslations = new Set();
// 1) Wortbaukasten (Compound & Prefix Lab) state
this.builderSubmode = "prefixes"; // 'prefixes' | 'compounds' | 'quiz'
this.selectedVerbRoot = "nehmen";
this.builderQuizIndex = 0;
this.builderQuizResult = null;
// 2) Verben mit Präposition (Rektion & Da-/Wo-) state
this.prepSubmode = "drill"; // 'drill' | 'dawo' | 'reference'
this.prepIndex = 0;
this.prepResult = null;
this.daWoIndex = 0;
this.daWoResult = null;
// 3) Hörtraining (Listening Dictation & Shadowing) state
this.listeningIndex = 0;
this.listeningShowHint = false;
this.listeningResult = null;
this.speechResult = null;
this.isRecordingMic = false;
this.loadState();
this.hoverTranslateEnabled = this.settings.hoverTranslateEnabled !== false;
this.initScenarioStates();
this.setupHoverListeners();
this.updateHoverToggleUI();
this.refreshDailySpotlight();
this.renderAll();
this.syncFromServer();
}
async syncFromServer() {
try {
const res = await fetch("/api/state", { cache: "no-store" });
if (!res.ok) return;
const data = await res.json();
if (data && data.exists && data.state) {
const parsed = data.state;
if (Array.isArray(parsed.vocab) && parsed.vocab.length > 0) {
this.vocab = parsed.vocab;
const existingIds = new Set(this.vocab.map(v => v.id));
(window.INITIAL_VOCABULARY || []).forEach(item => {
if (!existingIds.has(item.id)) {
this.vocab.push(this.createVocabEntry(item));
}
});
}
if (Array.isArray(parsed.customStories)) this.customStories = parsed.customStories;
if (parsed.stats) this.stats = parsed.stats;
if (parsed.settings) {
this.settings = parsed.settings;
this.hoverTranslateEnabled = this.settings.hoverTranslateEnabled !== false;
this.updateHoverToggleUI();
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(parsed));
this.renderAll();
} else {
this.pushStateToServer();
}
} catch (_e) {
// Offline or local file:// mode — localStorage handles persistence
}
}
async pushStateToServer() {
try {
await fetch("/api/state", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
vocab: this.vocab,
customStories: this.customStories,
stats: this.stats,
settings: this.settings
})
});
} catch (_e) {
// Ignore network errors when running locally
}
}
loadState() {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
this.vocab = parsed.vocab || [];
const existingIds = new Set(this.vocab.map(v => v.id));
(window.INITIAL_VOCABULARY || []).forEach(item => {
if (!existingIds.has(item.id)) {
this.vocab.push(this.createVocabEntry(item));
}
});
this.customStories = parsed.customStories || [];
this.stats = parsed.stats || {
streak: 1401,
totalChecks: 0,
correctChecks: 0,
sentencesWritten: 0
};
this.settings = {
aiProvider: "builtin",
apiKey: "",
hoverTranslateEnabled: true,
...(parsed.settings || {})
};
return;
} catch (e) {
console.warn("Could not parse saved state, initializing fresh state.", e);
}
}
const now = Date.now();
this.vocab = (window.INITIAL_VOCABULARY || []).map((item, idx) => {
const entry = this.createVocabEntry(item);
entry.srs.dueDate = idx < 18 ? now - 1000 : now;
return entry;
});
this.customStories = [];
this.stats = {
streak: 1401,
totalChecks: 0,
correctChecks: 0,
sentencesWritten: 0
};
this.settings = {
aiProvider: "builtin",
apiKey: "",
hoverTranslateEnabled: true
};
this.saveState();
}
createVocabEntry(raw) {
return {
...raw,
srs: raw.srs || {
interval: 0,
repetition: 0,
efactor: 2.5,
dueDate: Date.now(),
status: "new",
reviews: 0
}
};
}
saveState() {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
vocab: this.vocab,
customStories: this.customStories,
stats: this.stats,
settings: this.settings
})
);
this.updateDashboardCounters();
this.pushStateToServer();
}
getAllStories() {
return [...this.customStories, ...(window.GRADED_STORIES || [])];
}
// === HOVER-TO-TRANSLATE MODE ===
toggleHoverTranslate() {
this.hoverTranslateEnabled = !this.hoverTranslateEnabled;
this.settings.hoverTranslateEnabled = this.hoverTranslateEnabled;
this.updateHoverToggleUI();
this.hideHoverTooltip();
this.saveState();
if (this.currentView === "reader") {
this.renderReaderMain();
}
this.showToast(
this.hoverTranslateEnabled
? "🔍 Hover-Übersetzer aktiviert: Fahre mit der Maus über deutsche Wörter!"
: "🔍 Hover-Übersetzer deaktiviert."
);
}
updateHoverToggleUI() {
document.body.classList.toggle("hover-translate-on", this.hoverTranslateEnabled);
const btn = document.getElementById("hover-translate-toggle");
const statusSpan = document.getElementById("hover-translate-status");
if (btn) btn.classList.toggle("active", this.hoverTranslateEnabled);
if (statusSpan) statusSpan.textContent = this.hoverTranslateEnabled ? "AN" : "AUS";
}
setupHoverListeners() {
let lastHoveredWord = null;
const handlePointLookup = (clientX, clientY, targetEl) => {
if (!this.hoverTranslateEnabled) return false;
// 1. Check if directly over/tapping an explicit .word-token span
const tokenEl = targetEl?.closest?.(".word-token");
if (tokenEl) {
const word = tokenEl.dataset.word || tokenEl.textContent.trim();
if (word) {
this.showHoverTooltipForRect(tokenEl.getBoundingClientRect(), word, true);
lastHoveredWord = word;
return true;
}
}
// 2. Universal German word detector under cursor/tap
const hit = this.getWordAtPoint(clientX, clientY);
if (hit && hit.word) {
const info = this.lookupWordInfo(hit.word, null);
if (!info.needsLiveTranslate) {
this.showHoverTooltipForRect(hit.rect, hit.word, false);
lastHoveredWord = hit.word;
return true;
}
}
return false;
};
document.addEventListener("mousemove", e => {
if (!this.hoverTranslateEnabled) return;
const matched = handlePointLookup(e.clientX, e.clientY, e.target);
if (!matched && lastHoveredWord) {
lastHoveredWord = null;
this.hideHoverTooltip();
}
});
// Mobile & Touch support: tap a word to show translation tooltip, tap elsewhere to dismiss
document.addEventListener(
"touchstart",
e => {
if (!this.hoverTranslateEnabled) return;
const touch = e.touches?.[0];
if (!touch) return;
// Ignore taps on interactive controls like buttons/inputs unless it's a .word-token
const isToken = e.target?.closest?.(".word-token");
const isInteractiveControl = e.target?.closest?.("button, input, select, textarea, a");
if (isInteractiveControl && !isToken) {
this.hideHoverTooltip();
return;
}
const matched = handlePointLookup(touch.clientX, touch.clientY, e.target);
if (!matched) {
lastHoveredWord = null;
this.hideHoverTooltip();
}
},
{ passive: true }
);
window.addEventListener("scroll", () => this.hideHoverTooltip(), { passive: true });
}
getWordAtPoint(x, y) {
try {
let textNode = null;
let offset = 0;
if (document.caretRangeFromPoint) {
const range = document.caretRangeFromPoint(x, y);
if (!range) return null;
textNode = range.startContainer;
offset = range.startOffset;
} else if (document.caretPositionFromPoint) {
const pos = document.caretPositionFromPoint(x, y);
if (!pos) return null;
textNode = pos.offsetNode;
offset = pos.offset;
}
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) return null;
const parentEl = textNode.parentElement;
if (!parentEl || parentEl.closest("#hover-translate-tooltip, input, textarea, select, script, style")) {
return null;
}
const text = textNode.textContent || "";
if (!text.trim()) return null;
const isGermanChar = ch => /[A-Za-zÄÖÜäöüß]/.test(ch);
let idx = offset;
if (idx >= text.length || !isGermanChar(text[idx])) {
if (idx > 0 && isGermanChar(text[idx - 1])) {
idx = idx - 1;
} else {
return null;
}
}
let start = idx;
let end = idx + 1;
while (start > 0 && isGermanChar(text[start - 1])) start--;
while (end < text.length && isGermanChar(text[end])) end++;
const word = text.slice(start, end);
if (word.length < 2) return null;
const wordRange = document.createRange();
wordRange.setStart(textNode, start);
wordRange.setEnd(textNode, end);
const rect = wordRange.getBoundingClientRect();
// Verify mouse pointer is actually inside/near the word's bounding box
if (
x < rect.left - 6 ||
x > rect.right + 6 ||
y < rect.top - 6 ||
y > rect.bottom + 6
) {
return null;
}
return { word, rect };
} catch (_e) {
return null;
}
}
showHoverTooltipForRect(rect, rawWord, allowLiveFetch = true) {
let tooltip = document.getElementById("hover-translate-tooltip");
if (!tooltip) {
tooltip = document.createElement("div");
tooltip.id = "hover-translate-tooltip";
tooltip.className = "hover-translate-tooltip";
document.body.appendChild(tooltip);
}
const story = this.currentView === "reader"
? this.getAllStories().find(s => s.id === this.activeStoryId)
: null;
const info = this.lookupWordInfo(rawWord, story);
const articleHtml = info.article
? `${info.article} `
: "";
tooltip.innerHTML = `
${articleHtml}
${info.lemma || rawWord}
${info.english}
${info.plural ? `Plural: ${info.plural}
` : ""}
`;
const vw = window.innerWidth || 360;
const maxW = Math.min(300, vw - 24);
const halfW = Math.round(maxW / 2) + 12;
const left = Math.min(
Math.max(halfW, vw - halfW),
Math.max(halfW, rect.left + rect.width / 2)
);
const top = Math.max(64, rect.top);
// Apply explicit inline critical styles in addition to CSS class so it is 100% immune to stale CSS cache
Object.assign(tooltip.style, {
position: "fixed",
zIndex: "9999",
background: "#18181b",
color: "#ffffff",
padding: "0.55rem 0.85rem",
borderRadius: "10px",
boxShadow: "0 10px 25px rgba(0,0,0,0.3)",
pointerEvents: "none",
left: `${left}px`,
top: `${top}px`,
transform: "translate(-50%, -100%) translateY(-8px)",
opacity: "1",
display: "block",
maxWidth: `${maxW}px`,
fontSize: "0.84rem",
lineHeight: "1.35",
border: "1px solid rgba(255,255,255,0.16)"
});
tooltip.classList.add("visible");
if (allowLiveFetch && info.needsLiveTranslate) {
this.fetchLiveWordTranslation(rawWord);
}
}
async fetchLiveWordTranslation(rawWord) {
const lower = rawWord.toLowerCase();
if (this.pendingTranslations.has(lower)) return;
this.pendingTranslations.add(lower);
try {
const res = await fetch(`/api/translate?word=${encodeURIComponent(rawWord)}`);
if (!res.ok) return;
const data = await res.json();
if (data && data.english) {
if (!window.HOVER_DICT) window.HOVER_DICT = {};
window.HOVER_DICT[lower] = data.english;
const enEl = document.getElementById("hover-tt-en-text");
if (enEl) enEl.textContent = data.english;
}
} catch (_e) {
// Ignore offline errors
} finally {
this.pendingTranslations.delete(lower);
}
}
hideHoverTooltip() {
const tooltip = document.getElementById("hover-translate-tooltip");
if (tooltip) {
tooltip.classList.remove("visible");
tooltip.style.opacity = "0";
tooltip.style.display = "none";
}
}
// === AUDIO PRONUNCIATION (Server Native German Neural TTS + Browser Fallback) ===
speak(text, rate = 0.96) {
if (!text) return;
// Stop any currently playing audio
if (this.currentAudio) {
try {
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
} catch (_e) {}
this.currentAudio = null;
}
if ("speechSynthesis" in window) {
window.speechSynthesis.cancel();
}
// Primary: Stream authentic native German (de-DE) audio from backend /api/tts
if (window.location.protocol.startsWith("http")) {
const audio = new Audio(`/api/tts?text=${encodeURIComponent(text)}`);
audio.playbackRate = rate;
if ("preservesPitch" in audio) audio.preservesPitch = true;
this.currentAudio = audio;
audio.play().catch(err => {
console.warn("Server TTS playback error, falling back to browser TTS:", err);
this.speakBrowserFallback(text, rate);
});
return;
}
this.speakBrowserFallback(text, rate);
}
speakBrowserFallback(text, rate = 0.92) {
if (!("speechSynthesis" in window)) {
this.showToast("Audio wird von diesem Browser leider nicht unterstützt.");
return;
}
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "de-DE";
utterance.rate = rate;
const voices = window.speechSynthesis.getVoices();
const germanVoice =
voices.find(v => v.lang.toLowerCase().startsWith("de") && (v.name.includes("Natural") || v.name.includes("Google") || v.name.includes("Anna") || v.name.includes("Katja") || v.name.includes("Hedda") || v.name.includes("Stefan"))) ||
voices.find(v => v.lang.toLowerCase().startsWith("de"));
if (germanVoice) {
utterance.voice = germanVoice;
}
window.speechSynthesis.speak(utterance);
}
// === GENDER BADGE HELPER ===
getGenderBadgeHtml(article, pos = "") {
if (!article) {
return `${pos || "Wort"} `;
}
const clean = article.toLowerCase();
if (clean === "der") return `der `;
if (clean === "die") return `die `;
if (clean === "das") return `das `;
if (clean.includes("pl")) return `die (Pl.) `;
return `${article} `;
}
// === NAVIGATION ===
switchView(viewName) {
this.currentView = viewName;
this.hideHoverTooltip();
document.querySelectorAll(".view-section").forEach(sec => {
sec.classList.toggle("active", sec.id === `view-${viewName}`);
});
document.querySelectorAll(".nav-tab").forEach(tab => {
tab.classList.toggle("active", tab.dataset.view === viewName);
});
if (viewName === "dashboard") {
this.updateDashboardCounters();
this.renderDailySpotlight();
} else if (viewName === "trainer") {
if (!this.currentCard) this.pickNextCard();
this.renderTrainerStage();
} else if (viewName === "builder") {
this.renderBuilderStage();
} else if (viewName === "prepositions") {
this.renderPrepositionsStage();
} else if (viewName === "listening") {
this.renderListeningStage();
} else if (viewName === "reader") {
this.renderReaderSidebar();
this.renderReaderMain();
} else if (viewName === "practice") {
this.renderPracticeSidebar();
this.renderPracticeStage();
} else if (viewName === "dictionary") {
this.renderDictionary();
}
}
startTrainerMode(mode) {
this.trainerMode = mode;
this.switchView("trainer");
this.setTrainerMode(mode);
}
// === DASHBOARD ===
updateDashboardCounters() {
const now = Date.now();
const dueWords = this.vocab.filter(w => w.srs.dueDate <= now);
const learnedWords = this.vocab.filter(w => w.srs.reviews > 0);
const masteredWords = this.vocab.filter(w => w.srs.interval >= 6);
const learningWords = this.vocab.filter(w => w.srs.reviews > 0 && w.srs.interval < 6);
const dueCount = dueWords.length;
const navBadge = document.getElementById("nav-due-badge");
const heroDue = document.getElementById("hero-due-count");
const statDue = document.getElementById("stat-due");
const statLearned = document.getElementById("stat-learned");
const statMasteredSub = document.getElementById("stat-mastered-sub");
const statAccuracy = document.getElementById("stat-accuracy");
const statSentences = document.getElementById("stat-sentences");
const streakCount = document.getElementById("streak-count");
if (navBadge) navBadge.textContent = dueCount;
if (heroDue) heroDue.textContent = dueCount;
if (statDue) statDue.textContent = dueCount;
if (statLearned) statLearned.textContent = `${learnedWords.length} / ${this.vocab.length}`;
if (statMasteredSub) {
statMasteredSub.textContent = `${masteredWords.length} gemeistert • ${learningWords.length} im Training`;
}
if (statAccuracy) {
const pct = this.stats.totalChecks > 0
? Math.round((this.stats.correctChecks / this.stats.totalChecks) * 100) + "%"
: "—";
statAccuracy.textContent = pct;
}
if (statSentences) statSentences.textContent = this.stats.sentencesWritten;
if (streakCount) streakCount.textContent = this.stats.streak;
}
refreshDailySpotlight() {
const shuffled = [...this.vocab].sort(() => Math.random() - 0.5);
this.spotlightWords = shuffled.slice(0, 4);
this.renderDailySpotlight();
}
renderDailySpotlight() {
const container = document.getElementById("daily-spotlight-list");
if (!container) return;
container.innerHTML = this.spotlightWords
.map(
w => `
${this.getGenderBadgeHtml(w.article, w.pos)}
${w.word}
— ${w.english}
„${this.tokenizeSentenceForHover(w.exampleDe)}“
🔊
`
)
.join("");
}
// === ACTIVE RECALL & SRS TRAINER ===
setTrainerMode(mode) {
this.trainerMode = mode;
document.querySelectorAll(".mode-pill").forEach(btn => {
btn.classList.toggle("active", btn.dataset.mode === mode);
});
this.cardRevealed = false;
this.lastAnswerStatus = null;
this.pickNextCard();
this.renderTrainerStage();
}
filterTrainerDeck() {
const sel = document.getElementById("trainer-category-select");
if (sel) this.trainerFilter = sel.value;
this.cardRevealed = false;
this.lastAnswerStatus = null;
this.pickNextCard();
this.renderTrainerStage();
}
getFilteredTrainerPool() {
let pool = [...this.vocab];
if (this.trainerMode === "gender") {
pool = pool.filter(w => Boolean(w.article));
}
if (this.trainerFilter === "due") {
const now = Date.now();
const due = pool.filter(w => w.srs.dueDate <= now);
if (due.length > 0) return due;
return pool;
} else if (this.trainerFilter !== "all") {
const catPool = pool.filter(w => w.category === this.trainerFilter);
if (catPool.length > 0) return catPool;
}
return pool;
}
pickNextCard() {
const pool = this.getFilteredTrainerPool();
if (pool.length === 0) {
this.currentCard = null;
return;
}
const sorted = [...pool].sort((a, b) => a.srs.dueDate - b.srs.dueDate);
const candidates = sorted.slice(0, Math.min(5, sorted.length));
const next = candidates.find(c => !this.currentCard || c.id !== this.currentCard.id) || candidates[0];
this.currentCard = next;
this.cardRevealed = false;
this.lastAnswerStatus = null;
}
renderTrainerStage() {
const container = document.getElementById("trainer-stage-container");
if (!container) return;
const w = this.currentCard;
if (!w) {
container.innerHTML = `
🎉 Alle Karten in dieser Auswahl erledigt!
Wähle oben eine andere Kategorie oder lies eine Geschichte in der Leseecke.
📖 Zur Leseecke
`;
return;
}
const fullDisplayWord = w.article
? `${w.article.replace(" (Pl.)", "")} ${w.word}`
: w.word;
let promptHtml = "";
if (this.trainerMode === "cloze") {
const rawCloze = w.clozeSentence || w.exampleDe.replace(new RegExp(w.word, "i"), "___");
const clozeParts = rawCloze.split("___");
const clozeInteractiveHtml = clozeParts
.map(part => this.tokenizeSentenceForHover(part))
.join('______ ');
promptHtml = `
🧩 Lückensatz im Kontext • Ergänze das fehlende Wort
${w.english}
${clozeInteractiveHtml}
${w.exampleEn}
${
!this.cardRevealed
? `
`
: ""
}
`;
} else if (this.trainerMode === "production") {
const needsArticle = Boolean(w.article);
promptHtml = `
✍️ Aktiver Abruf • Übersetze auf Deutsch ${needsArticle ? "(MIT Artikel der/die/das!)" : ""}
${w.english}
Wortart: ${w.pos} • Kontext: „${w.exampleEn}“
${
!this.cardRevealed
? `
`
: ""
}
`;
} else if (this.trainerMode === "gender") {
promptHtml = `
🎨 Artikel & Plural-Blitz • Welcher Artikel gehört zu diesem Nomen?
___ ${w.word}
Bedeutung: ${w.english}
${
!this.cardRevealed
? `
der
die
das
die (Pl.)
`
: ""
}
`;
} else {
promptHtml = `
🃏 Karteikarte & Wortbau • Überprüfe Artikel, Aussprache & Satz
${w.english}
Versuche das deutsche Wort (inkl. Artikel & Beispielsatz) laut auszusprechen, bevor du umdrehst.
${
!this.cardRevealed
? `
🔄 Karte umdrehen & Audio hören
`
: ""
}
`;
}
let revealHtml = "";
if (this.cardRevealed) {
const statusClass = this.lastAnswerStatus
? this.lastAnswerStatus.isCorrect
? "correct"
: "incorrect"
: "";
revealHtml = `
${
this.lastAnswerStatus
? `
${this.lastAnswerStatus.message}
`
: ""
}
${w.english}
„${this.tokenizeSentenceForHover(w.exampleDe)}“
${w.exampleEn}
${
w.breakdown
? `
🧱 Wortbau & Eselsbrücke
${w.breakdown}
`
: ""
}
${
w.forms
? `
🔄 Formen & Kollokationen
${w.forms}
`
: ""
}
${
w.usageTip
? `
💡 Alltagstipp (Beyond Duolingo)
${w.usageTip}
`
: ""
}
Wie gut saß das Wort aktiv? (SM-2 Spaced Repetition)
Nochmal
< 10 Min
Schwer
1 Tag
Gut
3 Tage
Leicht
7+ Tage
`;
}
container.innerHTML = `
${w.category} • ${w.level}
Wiederholungen: ${w.srs.reviews} • Status: ${w.srs.status.toUpperCase()}
${promptHtml}
${revealHtml}
`;
const inputEl = document.getElementById("trainer-answer-input");
if (inputEl && !this.cardRevealed && typeof inputEl.focus === "function") {
setTimeout(() => inputEl.focus(), 30);
}
}
renderUmlautBar(inputId) {
const chars = ["ä", "ö", "ü", "ß", "Ä", "Ö", "Ü"];
return `
Umlaute:
${chars
.map(
ch =>
`${ch} `
)
.join("")}
`;
}
insertUmlaut(inputId, char) {
const input = document.getElementById(inputId);
if (!input) return;
const start = input.selectionStart ?? input.value.length;
const end = input.selectionEnd ?? input.value.length;
const val = input.value;
input.value = val.slice(0, start) + char + val.slice(end);
input.focus();
input.setSelectionRange(start + 1, start + 1);
if (inputId === "practice-user-input") {
this.updateLiveTargetWordHighlights(input.value);
}
}
normalizeGerman(str) {
return (str || "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ")
.replace(/[.,!?]/g, "");
}
checkTrainerTextAnswer(e) {
e.preventDefault();
const input = document.getElementById("trainer-answer-input");
if (!input || !this.currentCard) return;
const userRaw = input.value.trim();
if (!userRaw) return;
const w = this.currentCard;
const userNorm = this.normalizeGerman(userRaw);
this.stats.totalChecks++;
if (this.trainerMode === "cloze") {
const targetCloze = this.normalizeGerman(w.clozeAnswer || w.word);
const targetWordOnly = this.normalizeGerman(w.word);
const isCorrect = userNorm === targetCloze || userNorm === targetWordOnly;
if (isCorrect) this.stats.correctChecks++;
this.lastAnswerStatus = {
isCorrect,
message: isCorrect
? `✅ Perfekt! „${w.clozeAnswer || w.word}“ passt genau in den Satz.`
: `❌ Fast! Deine Eingabe: „${userRaw}“ — Gesucht war: „${w.clozeAnswer || w.word}“.`
};
} else if (this.trainerMode === "production") {
const cleanArticle = w.article ? w.article.replace(" (Pl.)", "").toLowerCase() : "";
const expectedFull = cleanArticle ? `${cleanArticle} ${w.word.toLowerCase()}` : w.word.toLowerCase();
const expectedWordOnly = w.word.toLowerCase();
if (userNorm === expectedFull) {
this.stats.correctChecks++;
const capitalizedCheck = w.article && userRaw.split(" ").pop()[0] === w.word[0];
this.lastAnswerStatus = {
isCorrect: true,
message: capitalizedCheck
? `✅ Ausgezeichnet! Wort UND Artikel (${w.article} ${w.word}) komplett richtig!`
: `✅ Richtig (${w.article} ${w.word})! Denk daran, Nomen im Deutschen großzuschreiben.`
};
} else if (w.article && userNorm.endsWith(expectedWordOnly)) {
this.lastAnswerStatus = {
isCorrect: false,
message: `⚠️ Das Nomen „${w.word}“ stimmt, aber der Artikel ist ${w.article} ${w.word} ! (${w.breakdown || "Merk dir Wort + Artikel immer als Einheit."})`
};
} else {
this.lastAnswerStatus = {
isCorrect: false,
message: `❌ Deine Eingabe: „${userRaw}“ — Richtige Antwort: ${w.article ? w.article + " " : ""}${w.word} .`
};
}
}
this.cardRevealed = true;
this.saveState();
this.speak((w.article ? w.article.replace(" (Pl.)", "") + " " : "") + w.word);
this.renderTrainerStage();
}
checkGenderChoice(chosenArticle) {
if (!this.currentCard) return;
const w = this.currentCard;
this.stats.totalChecks++;
const isCorrect = w.article === chosenArticle;
if (isCorrect) this.stats.correctChecks++;
this.lastAnswerStatus = {
isCorrect,
message: isCorrect
? `✅ Genau richtig! Es heißt ${w.article} ${w.word} (${w.plural || "kein Plural"}).`
: `❌ Nicht ganz: Du hast „${chosenArticle}“ gewählt, aber es heißt ${w.article} ${w.word} !`
};
this.cardRevealed = true;
this.saveState();
this.speak(`${w.article.replace(" (Pl.)", "")} ${w.word}`);
this.renderTrainerStage();
}
revealCardWithoutAnswer() {
if (!this.currentCard) return;
const w = this.currentCard;
this.cardRevealed = true;
this.lastAnswerStatus = null;
this.speak((w.article ? w.article.replace(" (Pl.)", "") + " " : "") + w.word);
this.renderTrainerStage();
}
gradeCurrentCard(grade) {
if (!this.currentCard) return;
const w = this.currentCard;
const now = Date.now();
const DAY_MS = 24 * 60 * 60 * 1000;
w.srs.reviews = (w.srs.reviews || 0) + 1;
if (grade === 1) {
w.srs.repetition = 0;
w.srs.interval = 0;
w.srs.dueDate = now + 10 * 60 * 1000;
w.srs.status = "learning";
} else if (grade === 2) {
w.srs.repetition += 1;
w.srs.interval = 1;
w.srs.dueDate = now + 1 * DAY_MS;
w.srs.status = "learning";
} else if (grade === 3) {
w.srs.repetition += 1;
w.srs.interval = w.srs.interval === 0 ? 3 : Math.round(w.srs.interval * w.srs.efactor);
w.srs.dueDate = now + w.srs.interval * DAY_MS;
w.srs.status = w.srs.interval >= 6 ? "mastered" : "learning";
} else if (grade === 4) {
w.srs.repetition += 1;
w.srs.efactor = Math.min(3.0, w.srs.efactor + 0.15);
w.srs.interval = w.srs.interval === 0 ? 7 : Math.round(w.srs.interval * w.srs.efactor * 1.3);
w.srs.dueDate = now + w.srs.interval * DAY_MS;
w.srs.status = "mastered";
}
this.saveState();
this.pickNextCard();
this.renderTrainerStage();
}
// === INTERACTIVE GRADED READER (LESEECKE) ===
renderReaderSidebar() {
const container = document.getElementById("story-list-container");
if (!container) return;
const stories = this.getAllStories();
container.innerHTML = stories
.map(
s => `
${s.type}
${s.level} • ${s.readTime}
${s.title}
${s.subtitle}
`
)
.join("");
}
selectStory(storyId) {
this.activeStoryId = storyId;
this.selectedReaderWord = null;
this.renderReaderSidebar();
this.renderReaderMain();
}
toggleReaderTranslation() {
this.showReaderTranslation = !this.showReaderTranslation;
this.renderReaderMain();
}
readWholeStoryAloud(rate = 0.9) {
const story = this.getAllStories().find(s => s.id === this.activeStoryId);
if (!story) return;
const fullText = story.paragraphs.map(p => p.de).join(" ");
this.speak(fullText, rate);
}
tokenizeSentenceForHover(deText) {
if (!deText) return "";
const tokens = deText.split(/([A-Za-zÄÖÜäöüß]+)/g);
return tokens
.map(token => {
if (!/^[A-Za-zÄÖÜäöüß]+$/.test(token)) {
return token;
}
return `${token} `;
})
.join("");
}
tokenizeParagraphToInteractiveHtml(deText, story) {
const tokens = deText.split(/([A-Za-zÄÖÜäöüß]+)/g);
return tokens
.map(token => {
if (!/^[A-Za-zÄÖÜäöüß]+$/.test(token)) {
return token;
}
const lookup = this.lookupWordInfo(token, story);
const isTarget = lookup && lookup.isTarget;
const inDeck = lookup && lookup.inDeck;
const classes = ["word-token"];
if (isTarget) classes.push("target-vocab");
else if (inDeck) classes.push("in-deck");
return `${token} `;
})
.join("");
}
lookupWordInfo(rawToken, story) {
const clean = rawToken.trim();
const lower = clean.toLowerCase();
// 1. Check story's own glossary
if (story && story.glossary && story.glossary[clean]) {
const g = story.glossary[clean];
const existingDeck = this.vocab.find(v => v.word.toLowerCase() === g.lemma.toLowerCase());
return {
surface: clean,
lemma: g.lemma,
article: g.article,
plural: g.plural,
pos: g.pos,
english: g.english,
note: g.note,
isTarget: true,
inDeck: Boolean(existingDeck)
};
}
// 2. Check user's SRS vocabulary database (exact or plural match)
const deckMatch = this.vocab.find(
v =>
v.word.toLowerCase() === lower ||
(v.plural && v.plural.toLowerCase().endsWith(" " + lower))
);
if (deckMatch) {
return {
surface: clean,
lemma: deckMatch.word,
article: deckMatch.article,
plural: deckMatch.plural,
pos: deckMatch.pos,
english: deckMatch.english,
note: deckMatch.usageTip || deckMatch.breakdown,
isTarget: story?.targetVocabIds?.includes(deckMatch.id) || false,
inDeck: true
};
}
// 3. Check other stories' glossaries
for (const st of window.GRADED_STORIES || []) {
if (st.glossary && st.glossary[clean]) {
const g = st.glossary[clean];
return {
surface: clean,
lemma: g.lemma,
article: g.article,
plural: g.plural,
pos: g.pos,
english: g.english,
note: g.note,
isTarget: false,
inDeck: false
};
}
}
// 4. Check common German dictionary
if (window.COMMON_GERMAN_DICT && window.COMMON_GERMAN_DICT[lower]) {
const c = window.COMMON_GERMAN_DICT[lower];
return {
surface: clean,
lemma: c.lemma,
article: c.article,
plural: c.plural,
pos: c.pos,
english: c.english,
note: c.note,
isTarget: false,
inDeck: false
};
}
// 5. Check comprehensive Hover Dictionary (100% of story/scenario/example words)
if (window.HOVER_DICT && window.HOVER_DICT[lower]) {
const hoverEn = window.HOVER_DICT[lower];
const isCap = clean[0] === clean[0].toUpperCase() && clean[0] !== clean[0].toLowerCase();
let inferredArt = null;
if (hoverEn.includes("(der ")) inferredArt = "der";
else if (hoverEn.includes("(die ")) inferredArt = "die";
else if (hoverEn.includes("(das ")) inferredArt = "das";
return {
surface: clean,
lemma: clean,
article: inferredArt,
plural: null,
pos: isCap ? "noun" : "word",
english: hoverEn,
note: null,
isTarget: false,
inDeck: false
};
}
// 6. Smart Morphological Analyzer + Live Translation trigger for custom imported words
const isCapitalized = clean[0] === clean[0].toUpperCase() && clean[0] !== clean[0].toLowerCase();
let guessedArticle = null;
let morphNote = "Klicke auf 'Ins SRS-Deck speichern', um dieses Wort zu trainieren.";
if (isCapitalized) {
if (lower.endsWith("ung") || lower.endsWith("heit") || lower.endsWith("keit") || lower.endsWith("schaft") || lower.endsWith("tät") || lower.endsWith("ion")) {
guessedArticle = "die";
morphNote = "Suffix-Regel: Deutsche Nomen auf -ung, -heit, -keit, -schaft, -ion sind immer feminin (die)!";
} else if (lower.endsWith("chen") || lower.endsWith("lein") || lower.endsWith("ment") || lower.endsWith("um")) {
guessedArticle = "das";
morphNote = "Suffix-Regel: Nomen auf -chen, -lein, -ment, -um sind fast immer neutrum (das)!";
}
}
return {
surface: clean,
lemma: clean,
article: guessedArticle,
plural: null,
pos: isCapitalized ? "noun" : "word",
english: "Übersetze...",
note: morphNote,
isTarget: false,
inDeck: false,
needsLiveTranslate: true
};
}
inspectReaderWord(rawToken) {
const story = this.getAllStories().find(s => s.id === this.activeStoryId);
const info = this.lookupWordInfo(rawToken, story);
this.selectedReaderWord = info;
if (info.needsLiveTranslate) {
this.fetchLiveWordTranslation(rawToken).then(() => {
if (window.HOVER_DICT && window.HOVER_DICT[rawToken.toLowerCase()]) {
this.selectedReaderWord.english = window.HOVER_DICT[rawToken.toLowerCase()];
this.renderReaderMain();
}
});
}
this.speak(rawToken);
this.renderReaderMain();
}
saveInspectedWordToDeck() {
const info = this.selectedReaderWord;
if (!info) return;
const existing = this.vocab.find(v => v.word.toLowerCase() === info.lemma.toLowerCase());
if (existing) {
existing.srs.dueDate = Date.now() - 1000;
this.saveState();
this.showToast(`„${existing.word}“ wurde im SRS-Trainer als sofort fällig markiert!`);
this.renderReaderMain();
return;
}
const story = this.getAllStories().find(s => s.id === this.activeStoryId);
const matchingParagraph = story?.paragraphs.find(p => p.de.includes(info.surface)) || story?.paragraphs[0];
const newEntry = this.createVocabEntry({
id: "custom_" + Date.now(),
word: info.lemma,
article: info.article,
plural: info.plural,
pos: info.pos || "word",
level: "A2/B1",
category: "Saved from Reading",
english: info.english === "Übersetze..." ? "Aus Leseecke gespeichert" : info.english,
breakdown: info.note,
forms: null,
exampleDe: matchingParagraph ? matchingParagraph.de : `Ich habe das Wort „${info.lemma}“ im Text gelesen.`,
exampleEn: matchingParagraph ? matchingParagraph.en : "",
clozeSentence: matchingParagraph
? matchingParagraph.de.replace(info.surface, "___")
: `Das Wort ___ ist sehr nützlich.`,
clozeAnswer: info.surface,
usageTip: info.note
});
this.vocab.unshift(newEntry);
this.selectedReaderWord.inDeck = true;
this.saveState();
this.showToast(`➕ „${info.lemma}“ zum SRS-Trainer hinzugefügt!`);
this.renderReaderMain();
}
answerStoryQuiz(storyId, optionIndex) {
this.quizSelection[storyId] = optionIndex;
this.renderReaderMain();
}
renderReaderMain() {
const container = document.getElementById("reader-content-area");
if (!container) return;
const story = this.getAllStories().find(s => s.id === this.activeStoryId) || this.getAllStories()[0];
if (!story) return;
const paragraphsHtml = story.paragraphs
.map(
p => `
${this.tokenizeParagraphToInteractiveHtml(p.de, story)}
🔊
${
this.showReaderTranslation && p.en
? `
${p.en}
`
: ""
}
`
)
.join("");
let inspectorHtml = "";
if (this.selectedReaderWord) {
const w = this.selectedReaderWord;
inspectorHtml = `
${this.getGenderBadgeHtml(w.article, w.pos)}
${w.lemma}
${w.plural ? `(${w.plural}) ` : ""}
🔊
${w.english}
${
w.note
? `
💡 ${w.note}
`
: ""
}
${w.inDeck ? "⚡ Im SRS-Trainer priorisieren" : "➕ Ins SRS-Deck speichern"}
`;
} else {
inspectorHtml = `
👆 Tipp: Fahre mit der Maus über ein Wort für die Sofort-Übersetzung oder klicke darauf, um es mit Audio ins SRS-Deck zu speichern!
`;
}
let quizHtml = "";
if (story.comprehensionQuestion) {
const q = story.comprehensionQuestion;
const chosen = this.quizSelection[story.id];
quizHtml = `
🧠 Verständnis-Check zur Geschichte
${this.tokenizeSentenceForHover(q.question)}
${q.options
.map((opt, idx) => {
let bg = "var(--bg-main)";
let border = "var(--border-color)";
if (chosen !== undefined) {
if (idx === q.correctIndex) {
bg = "var(--success-bg)";
border = "#34d399";
} else if (idx === chosen) {
bg = "var(--danger-bg)";
border = "#fca5a5";
}
}
return `
${opt}
`;
})
.join("")}
${
chosen !== undefined
? `
${chosen === q.correctIndex ? "✅ Richtig! " : "❌ Nicht ganz: "}${q.explanation}
`
: ""
}
`;
}
container.innerHTML = `
${paragraphsHtml}
${inspectorHtml}
${quizHtml}
`;
}
// === CONVERSATION & WRITING PRACTICE (SPRACHPRAXIS) ===
initScenarioStates() {
(window.CONVERSATION_SCENARIOS || []).forEach(scen => {
if (!this.scenarioState[scen.id]) {
this.resetScenario(scen.id, false);
}
});
}
resetScenario(scenarioId, reRender = true) {
const scen = (window.CONVERSATION_SCENARIOS || []).find(s => s.id === scenarioId);
if (!scen) return;
this.scenarioState[scenarioId] = {
turnIndex: 0,
usedWords: [],
completed: false,
messages: [
{
role: "partner",
de: scen.turns[0].partnerDe,
en: scen.turns[0].partnerEn
}
]
};
if (reRender) this.renderPracticeStage();
}
renderPracticeSidebar() {
const container = document.getElementById("scenario-list-container");
if (!container) return;
container.innerHTML = (window.CONVERSATION_SCENARIOS || []).map(scen => {
const st = this.scenarioState[scen.id];
const usedCount = st ? st.usedWords.length : 0;
return `
${scen.avatar} ${scen.rolePartner}
${usedCount}/${scen.targetWords.length} Zielwörter
${scen.title}
Level ${scen.level}
`;
}).join("");
}
selectScenario(scenarioId) {
this.activeScenarioId = scenarioId;
this.renderPracticeSidebar();
this.renderPracticeStage();
}
detectTargetWordsInText(text, scen) {
const lower = (text || "").toLowerCase();
const matched = [];
scen.targetWords.forEach(tw => {
const hit = tw.stems.some(stem => lower.includes(stem.toLowerCase()));
if (hit) matched.push(tw.word);
});
return matched;
}
updateLiveTargetWordHighlights(currentInputText) {
const scen = (window.CONVERSATION_SCENARIOS || []).find(s => s.id === this.activeScenarioId);
if (!scen) return;
const st = this.scenarioState[scen.id];
const newlyTyped = this.detectTargetWordsInText(currentInputText, scen);
const combined = new Set([...st.usedWords, ...newlyTyped]);
document.querySelectorAll(".target-word-chip").forEach(chip => {
const w = chip.dataset.word;
const isUsed = combined.has(w);
chip.classList.toggle("used", isUsed);
const iconSpan = chip.querySelector(".chip-check");
if (iconSpan) iconSpan.textContent = isUsed ? "✅" : "⭕";
});
}
analyzeGermanSentence(userText, newlyUsedTargetWords, currentTurn) {
const tips = [];
const trimmed = userText.trim();
if (newlyUsedTargetWords.length > 0) {
tips.push(`🎯 Zielwörter aktiv eingebaut: ${newlyUsedTargetWords.join(", ")}!`);
} else {
tips.push(`💡 Tipp: Versuche in deiner Antwort mindestens eines der oben gelisteten Zielwörter zu verwenden.`);
}
const lower = trimmed.toLowerCase();
if (lower.includes("weil ") || lower.includes("obwohl ") || lower.includes("dass ")) {
tips.push(`🧱 Nebensatz-Struktur genutzt (weil/obwohl/dass): Super für B1-Niveau! Denk daran, dass das konjugierte Verb im Nebensatz ganz ans Ende wandert.`);
}
if (lower.includes("deshalb ") || lower.includes("trotzdem ")) {
const badOrder = /deshalb\s+ich\b|trotzdem\s+ich\b/i.test(trimmed);
if (badOrder) {
tips.push(`⚠️ Wortstellung nach „deshalb / trotzdem“: Im Deutschen steht das Verb direkt auf Position 2: „Deshalb möchte ich ...“ (nicht „Deshalb ich möchte“).`);
} else {
tips.push(`✨ Starker Konnektor (deshalb/trotzdem) mit richtiger Verb-Zweitstellung! `);
}
}
if (currentTurn && currentTurn.sampleResponse) {
tips.push(`🇩🇪 Muster-Formulierung zum Vergleich: „${this.tokenizeSentenceForHover(currentTurn.sampleResponse)}“ `);
}
return tips.join(" ");
}
async handlePracticeSubmit(e) {
e.preventDefault();
const input = document.getElementById("practice-user-input");
if (!input) return;
const text = input.value.trim();
if (!text) return;
const scen = (window.CONVERSATION_SCENARIOS || []).find(s => s.id === this.activeScenarioId);
const st = this.scenarioState[scen.id];
const currentTurn = scen.turns[st.turnIndex];
const matchedWords = this.detectTargetWordsInText(text, scen);
matchedWords.forEach(w => {
if (!st.usedWords.includes(w)) st.usedWords.push(w);
});
st.messages.push({
role: "user",
de: text
});
this.stats.sentencesWritten++;
input.value = "";
let feedbackHtml = this.analyzeGermanSentence(text, matchedWords, currentTurn);
if (this.settings.aiProvider !== "builtin" && this.settings.apiKey) {
const aiCoachNote = await this.fetchOptionalAiFeedback(text, scen, currentTurn);
if (aiCoachNote) {
feedbackHtml = `🤖 Live-KI-Feedback: ${aiCoachNote} ` + feedbackHtml;
}
}
st.messages.push({
role: "feedback",
html: feedbackHtml
});
if (st.turnIndex + 1 < scen.turns.length) {
st.turnIndex += 1;
const nextTurn = scen.turns[st.turnIndex];
st.messages.push({
role: "partner",
de: nextTurn.partnerDe,
en: nextTurn.partnerEn
});
this.speak(nextTurn.partnerDe);
} else {
st.completed = true;
st.messages.push({
role: "partner",
de: "Vielen Dank für das Gespräch! Du hast die Situation erfolgreich auf Deutsch gemeistert. 🎉",
en: "Thank you for the conversation! You successfully mastered the situation in German."
});
}
this.saveState();
this.renderPracticeSidebar();
this.renderPracticeStage();
}
async fetchOptionalAiFeedback(userGerman, scen, currentTurn) {
try {
const prompt = `Du bist ein freundlicher Deutschlehrer für Niveau A2/B1. Situation: ${scen.situation}. Gesprächspartner sagte: "${currentTurn?.partnerDe || ""}". Der Lernende antwortete: "${userGerman}". Gib in 1-2 kurzen Sätzen konstruktives Feedback zu Grammatik/Wortwahl und nenne bei Fehlern eine natürliche Korrektur.`;
if (this.settings.aiProvider === "gemini") {
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${encodeURIComponent(this.settings.apiKey)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }]
})
}
);
const data = await res.json();
return data?.candidates?.[0]?.content?.parts?.[0]?.text || null;
} else if (this.settings.aiProvider === "openai") {
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.settings.apiKey}`
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
max_tokens: 150
})
});
const data = await res.json();
return data?.choices?.[0]?.message?.content || null;
}
} catch (err) {
console.warn("Optional AI request failed, using built-in coach:", err);
}
return null;
}
insertSampleHint() {
const scen = (window.CONVERSATION_SCENARIOS || []).find(s => s.id === this.activeScenarioId);
const st = this.scenarioState[scen.id];
const currentTurn = scen?.turns[st.turnIndex];
const input = document.getElementById("practice-user-input");
if (input && currentTurn) {
input.value = currentTurn.sampleResponse;
input.focus();
this.updateLiveTargetWordHighlights(input.value);
}
}
renderPracticeStage() {
const container = document.getElementById("practice-stage-area");
if (!container) return;
const scen = (window.CONVERSATION_SCENARIOS || []).find(s => s.id === this.activeScenarioId);
if (!scen) return;
const st = this.scenarioState[scen.id];
const currentTurn = scen.turns[st.turnIndex];
const targetChipsHtml = scen.targetWords
.map(tw => {
const isUsed = st.usedWords.includes(tw.word);
return `
${isUsed ? "✅" : "⭕"}
${tw.word}
(${tw.hint})
`;
})
.join("");
const messagesHtml = st.messages
.map(m => {
if (m.role === "partner") {
return `
${scen.avatar} ${scen.rolePartner}
🔊
${this.tokenizeSentenceForHover(m.de)}
${m.en ? `
${m.en}
` : ""}
`;
} else if (m.role === "user") {
return `
Du (Aktiv produziert)
${m.de}
`;
} else {
return `${m.html}
`;
}
})
.join("");
container.innerHTML = `
Rollenspiel • ${scen.level}
${scen.avatar} ${scen.title}
${scen.situation}
🔄 Dialog neu starten
🎯 Deine 5 Zielwörter für dieses Gespräch (${st.usedWords.length}/${scen.targetWords.length} genutzt)
${targetChipsHtml}
${messagesHtml}
${
!st.completed
? `
Aufgabe (Schritt ${st.turnIndex + 1}/${scen.turns.length}): ${currentTurn.promptHint}
`
: `
🎉 Szenario abgeschlossen! Du hast ${st.usedWords.length} von ${scen.targetWords.length} Zielwörtern aktiv im Kontext verwendet.
Nochmal üben
`
}
`;
const chatBox = document.getElementById("chat-stream-box");
if (chatBox) {
chatBox.scrollTop = chatBox.scrollHeight;
}
}
// === VOCABULARY BANK (WÖRTERBUCH) ===
renderDictionary() {
const grid = document.getElementById("dictionary-grid");
if (!grid) return;
const query = (document.getElementById("dict-search-input")?.value || "").trim().toLowerCase();
const genderFilter = document.getElementById("dict-gender-filter")?.value || "all";
const categoryFilter = document.getElementById("dict-category-filter")?.value || "all";
const filtered = this.vocab.filter(w => {
if (query) {
const hay = `${w.word} ${w.english} ${w.exampleDe} ${w.category}`.toLowerCase();
if (!hay.includes(query)) return false;
}
if (genderFilter !== "all") {
if (genderFilter === "verb") {
if (w.article) return false;
} else if (!w.article || !w.article.startsWith(genderFilter)) {
return false;
}
}
if (categoryFilter !== "all" && w.category !== categoryFilter) {
return false;
}
return true;
});
grid.innerHTML = filtered
.map(
w => `
${this.getGenderBadgeHtml(w.article, w.pos)}
${w.word}
🔊
${w.plural ? `
Plural: ${w.plural}
` : ""}
${w.english}
„${this.tokenizeSentenceForHover(w.exampleDe)}“
${w.breakdown ? `
🧱 ${w.breakdown}
` : ""}
${w.category}
SRS: ${w.srs.status.toUpperCase()} (${w.srs.reviews}x)
`
)
.join("");
}
// === MODALS & CUSTOM CONTENT ===
openModal(id) {
const el = document.getElementById(id);
if (el) el.classList.add("open");
if (id === "settings-modal") {
document.getElementById("settings-ai-provider").value = this.settings.aiProvider || "builtin";
document.getElementById("settings-api-key").value = this.settings.apiKey || "";
}
}
closeModal(id) {
const el = document.getElementById(id);
if (el) el.classList.remove("open");
}
handleAddCustomWord(e) {
e.preventDefault();
const article = document.getElementById("custom-article").value || null;
const word = document.getElementById("custom-word").value.trim();
const english = document.getElementById("custom-english").value.trim();
const plural = document.getElementById("custom-plural").value.trim() || null;
const exampleDe = document.getElementById("custom-example-de").value.trim();
const exampleEn = document.getElementById("custom-example-en").value.trim();
const tip = document.getElementById("custom-tip").value.trim();
if (!word || !english || !exampleDe) return;
const newWord = this.createVocabEntry({
id: "user_" + Date.now(),
word,
article,
plural,
pos: article ? "noun" : "verb / expression",
level: "A2/B1",
category: "Saved from Reading",
english,
breakdown: tip || null,
forms: plural,
exampleDe,
exampleEn,
clozeSentence: exampleDe.replace(new RegExp(word, "i"), "___"),
clozeAnswer: word,
usageTip: tip || null
});
this.vocab.unshift(newWord);
this.saveState();
this.closeModal("add-word-modal");
e.target.reset();
this.showToast(`✅ „${word}“ wurde zu deinem Wortschatz-Trainer hinzugefügt!`);
if (this.currentView === "dictionary") this.renderDictionary();
}
handleImportCustomStory(e) {
e.preventDefault();
const title = document.getElementById("import-story-title").value.trim();
const body = document.getElementById("import-story-body").value.trim();
if (!title || !body) return;
const rawParagraphs = body
.split(/\n\s*\n/)
.map(p => p.trim())
.filter(Boolean);
const newStory = {
id: "custom_story_" + Date.now(),
title,
subtitle: "Eigener importierter Text (Jedes Wort ist klickbar & hoverbar)",
level: "A2 / B1",
type: "Eigener Text",
readTime: "3 min",
targetVocabIds: [],
paragraphs: rawParagraphs.map(p => ({ de: p, en: "" })),
glossary: {}
};
this.customStories.unshift(newStory);
this.activeStoryId = newStory.id;
this.saveState();
this.closeModal("import-text-modal");
e.target.reset();
this.switchView("reader");
this.showToast(`📄 „${title}“ erfolgreich in der Leseecke geöffnet!`);
}
saveSettings() {
this.settings.aiProvider = document.getElementById("settings-ai-provider").value;
this.settings.apiKey = document.getElementById("settings-api-key").value.trim();
this.saveState();
this.closeModal("settings-modal");
this.showToast("⚙️ Einstellungen gespeichert!");
}
exportProgress() {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(this.vocab, null, 2));
const dlAnchor = document.createElement("a");
dlAnchor.setAttribute("href", dataStr);
dlAnchor.setAttribute("download", "wortkraft_wortschatz_backup.json");
document.body.appendChild(dlAnchor);
dlAnchor.click();
dlAnchor.remove();
this.showToast("💾 Backup heruntergeladen!");
}
resetProgress() {
if (!confirm("Möchtest du deinen Lernfortschritt wirklich auf den Anfang zurücksetzen?")) return;
localStorage.removeItem(STORAGE_KEY);
window.location.reload();
}
showToast(message) {
const toast = document.getElementById("toast");
if (!toast) return;
toast.textContent = message;
toast.classList.add("show");
clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => {
toast.classList.remove("show");
}, 3200);
}
escapeJs(str) {
return (str || "")
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/"/g, """)
.replace(/\n/g, " ");
}
// === 1) WORTBAUKASTEN (COMPOUND WORD & PREFIX LAB) ===
setBuilderSubmode(submode) {
this.builderSubmode = submode;
this.builderQuizResult = null;
["prefixes", "compounds", "quiz"].forEach(m => {
const btn = document.getElementById(`builder-tab-${m}`);
if (btn) btn.classList.toggle("active", m === submode);
});
this.renderBuilderStage();
}
selectVerbRoot(rootName) {
this.selectedVerbRoot = rootName;
this.renderBuilderStage();
}
saveLabItemToDeck(word, article, plural, english, exampleDe, exampleEn, note) {
const existing = this.vocab.find(v => v.word.toLowerCase() === word.toLowerCase());
if (existing) {
existing.srs.dueDate = Date.now() - 1000;
this.saveState();
this.showToast(`⚡ „${word}“ wurde im SRS-Trainer priorisiert!`);
return;
}
const newEntry = this.createVocabEntry({
id: "lab_" + Date.now() + "_" + Math.floor(Math.random() * 1000),
word,
article: article || null,
plural: plural || null,
pos: article ? "noun" : "verb",
level: "A2/B1",
category: "Everyday & Bureaucracy",
english,
breakdown: note,
forms: plural,
exampleDe,
exampleEn: exampleEn || "",
clozeSentence: exampleDe.replace(new RegExp(word, "i"), "___"),
clozeAnswer: word,
usageTip: note
});
this.vocab.unshift(newEntry);
this.saveState();
this.showToast(`➕ „${word}“ zum SRS-Trainer hinzugefügt!`);
}
getBuilderQuizItems() {
const data = window.WORD_BUILDER_DATA;
if (!data) return [];
const items = [];
data.verbRoots.forEach(r => {
r.derivatives.forEach(d => {
items.push({
type: "prefix",
root: r.root,
word: d.word,
separable: d.separable,
english: d.english,
promptDe: d.quizCloze,
answer: d.quizAnswer,
explanation: `${d.word} (${d.forms}) — ${d.logic}`
});
});
});
data.compounds.forEach(c => {
items.push({
type: "compound",
compound: c.compound,
finalArticle: c.finalArticle,
english: c.english,
parts: c.parts,
explanation: c.ruleNote,
exampleDe: c.exampleDe
});
});
return items;
}
checkBuilderQuizAnswer(userVal) {
const items = this.getBuilderQuizItems();
const item = items[this.builderQuizIndex % items.length];
if (!item) return;
this.stats.totalChecks++;
if (item.type === "prefix") {
const isCorrect = this.normalizeGerman(userVal) === this.normalizeGerman(item.answer);
if (isCorrect) this.stats.correctChecks++;
this.builderQuizResult = {
isCorrect,
message: isCorrect
? `✅ Richtig! „${item.answer}“ (${item.word} — ${item.separable ? "trennbar" : "untrennbar"}).`
: `❌ Fast! Gesucht war: „${item.answer}“ (${item.word}).`
};
this.speak(item.promptDe.replace("___", item.answer));
} else {
const isCorrect = userVal === item.finalArticle;
if (isCorrect) this.stats.correctChecks++;
this.builderQuizResult = {
isCorrect,
message: isCorrect
? `✅ Perfekt! Das letzte Wort bestimmt den Artikel: ${item.finalArticle} ${item.compound}!`
: `❌ Nicht ganz: Das letzte Wort in „${item.compound}“ bestimmt den Artikel → ${item.finalArticle} ${item.compound}!`
};
this.speak(`${item.finalArticle} ${item.compound}`);
}
this.saveState();
this.renderBuilderStage();
}
nextBuilderQuiz() {
const items = this.getBuilderQuizItems();
this.builderQuizIndex = (this.builderQuizIndex + 1) % items.length;
this.builderQuizResult = null;
this.renderBuilderStage();
}
renderBuilderStage() {
const container = document.getElementById("builder-stage-container");
const data = window.WORD_BUILDER_DATA;
if (!container || !data) return;
if (this.builderSubmode === "prefixes") {
const activeRoot = data.verbRoots.find(r => r.root === this.selectedVerbRoot) || data.verbRoots[0];
const rootPills = data.verbRoots
.map(
r => `
Stamm: ${r.root}
`
)
.join("");
const cardsHtml = activeRoot.derivatives
.map(
d => `
${d.separable ? "✂️ Trennbar (mit -ge-)" : "🔒 Untrennbar (ohne -ge-)"}
🔊
➕
${d.prefix} ${activeRoot.root} → ${d.word}
${d.forms}
${d.english}
💡 Logik: ${d.logic}
„${this.tokenizeSentenceForHover(d.exampleDe)}“
${d.exampleEn}
`
)
.join("");
container.innerHTML = `
🔀 Verb-Stämme & Präfix-Labor
Ein einziger deutscher Verbstamm bildet durch Vorsilben (Präfixe) dutzende neue Wörter.
Trennbare Präfixe (an-, ab-, auf-, aus-, mit-, vor-, teil-) sind betont und bekommen im Perfekt -ge- in der Mitte.
Untrennbare Präfixe (be-, ver-, ent-, er-, über-, unter-) bekommen kein -ge-!
${rootPills}
Grundverb: ${activeRoot.root} — ${activeRoot.baseMeaning}
${cardsHtml}
`;
} else if (this.builderSubmode === "compounds") {
const compoundsHtml = data.compounds
.map(c => {
const blocksHtml = c.parts
.map((part, idx) => {
const isLast = idx === c.parts.length - 1;
return `
${idx > 0 ? `+
` : ""}
${part.article ? this.getGenderBadgeHtml(part.article) : ""}
${part.word}
${part.meaning}
`;
})
.join("");
return `
${this.getGenderBadgeHtml(c.finalArticle)}
${this.tokenizeSentenceForHover(c.compound)}
(${c.plural})
— ${c.english}
🔊 Anhören
➕ Ins SRS-Deck
${blocksHtml}
🎯 Artikel-Regel: ${c.ruleNote}
„${this.tokenizeSentenceForHover(c.exampleDe)}“
`;
})
.join("");
container.innerHTML = `
🧱 Komposita-Knacker: Keine Angst vor langen deutschen Wörtern!
Goldene Regel: Egal wie lang ein zusammengesetztes Nomen ist — das allerletzte Baustein-Wort (gelb umrandet) bestimmt immer 100% den Artikel (der/die/das)!
${compoundsHtml}
`;
} else {
// Quiz submode
const items = this.getBuilderQuizItems();
const item = items[this.builderQuizIndex % items.length];
let quizBody = "";
if (item.type === "prefix") {
quizBody = `
🔀 Präfix-Verb im Kontext • Stamm: „${item.root}“
${item.english}
${this.tokenizeSentenceForHover(item.promptDe)}
`;
} else {
const partsLine = item.parts.map(p => `${p.article ? p.article + " " : ""}${p.word}`).join(" + ");
quizBody = `
🧱 Komposita-Artikel-Challenge • Welcher Artikel gewinnt?
___ ${item.compound}
Bausteine: ${partsLine} (${item.english})
der
die
das
`;
}
container.innerHTML = `
Wortbaukasten-Quiz • Frage ${(this.builderQuizIndex % items.length) + 1} / ${items.length}
${quizBody}
${
this.builderQuizResult
? `
${this.builderQuizResult.message}
${item.explanation}
Weiter zur nächsten Aufgabe →
`
: ""
}
`;
}
}
// === 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 => `
${opt}
`
)
.join("");
container.innerHTML = `
Präposition & Kasus • ${(this.prepIndex % drills.length) + 1} / ${drills.length}
Verb: ${d.verb} (${d.english})
🎯 Wähle die richtige Präposition + Artikel im richtigen Fall (Akkusativ / Dativ)
${this.tokenizeSentenceForHover(d.sentenceBefore)}
[ ___ ]
${this.tokenizeSentenceForHover(d.sentenceAfter)}
Nomen-Hinweis: ${d.nounHint}
${optionsHtml}
${
this.prepResult
? `
${this.prepResult.message}
🗣️ „${this.tokenizeSentenceForHover(this.prepResult.fullSentence)}“
🔊
💡 Erklärung: ${d.explanation}
🔗 Da-/Wo-Form: ${d.daForm} / ${d.woForm} — „${this.tokenizeSentenceForHover(d.daExampleDe)}“
Nächster Satz →
`
: ""
}
`;
} else if (this.prepSubmode === "dawo") {
const d = drills[this.daWoIndex % drills.length];
const daChoices = ["darauf", "darüber", "darum", "daran", "davon", "dafür"];
const clozeDaSentence = d.daExampleDe.replace(new RegExp(d.daForm, "i"), "___");
container.innerHTML = `
Da- & Wo-Baukasten • ${(this.daWoIndex % drills.length) + 1} / ${drills.length}
${d.verb} + ${d.preposition} (${d.grammaticalCase})
🔗 Wie ersetzen Deutsche einen ganzen Satzteil durch ein „da-Wort“?
Regel: da + Präposition (bei Vokal a/o/u/ü mit -r- dazwischen: da + r + auf = darauf ).
${this.tokenizeSentenceForHover(clozeDaSentence)}
Gesuchtes Verb: ${d.verb} (${d.english})
${daChoices
.map(
ch => `
${ch}
`
)
.join("")}
${
this.daWoResult
? `
${this.daWoResult.message}
„${this.tokenizeSentenceForHover(d.daExampleDe)}“
🔊
Weiter →
`
: ""
}
`;
} else {
// Reference table
container.innerHTML = `
📋 Die wichtigsten A2/B1 Verben mit fester Präposition
Lerne jedes Verb immer zusammen mit seiner Präposition und dem Fall (Akkusativ oder Dativ ).
${drills
.map(
d => `
${d.verb} ${d.preposition}
+ ${d.grammaticalCase}
${d.english}
Da-/Wo-Form: ${d.daForm} • ${d.woForm}?
„${this.tokenizeSentenceForHover(`${d.sentenceBefore} ${d.correctCombo} ${d.sentenceAfter}`)}“
🔊 Anhören
➕ Ins SRS-Deck
`
)
.join("")}
`;
}
}
// === 3) HÖRTRAINING & AUSSPRACHE (LISTENING DICTATION & SHADOWING) ===
computeWordDiffHtml(expectedSentence, userSentence) {
const cleanWord = w => w.toLowerCase().replace(/[.,!?„“"']/g, "");
const expectedWords = expectedSentence.split(/\s+/).filter(Boolean);
const userWords = (userSentence || "").split(/\s+/).filter(Boolean).map(cleanWord);
let matchedCount = 0;
const diffHtml = expectedWords
.map(expW => {
const normExp = cleanWord(expW);
const foundIdx = userWords.indexOf(normExp);
if (foundIdx !== -1) {
userWords.splice(foundIdx, 1);
matchedCount++;
return `${expW} `;
}
return `${expW} `;
})
.join(" ");
const scorePct = Math.round((matchedCount / expectedWords.length) * 100);
return { diffHtml, scorePct, matchedCount, total: expectedWords.length };
}
checkListeningDictation(e) {
e.preventDefault();
const input = document.getElementById("listening-dictation-input");
if (!input) return;
const userText = input.value.trim();
if (!userText) return;
const list = window.LISTENING_EXERCISES || [];
const ex = list[this.listeningIndex % list.length];
if (!ex) return;
const { diffHtml, scorePct, matchedCount, total } = this.computeWordDiffHtml(ex.de, userText);
this.stats.totalChecks++;
this.stats.sentencesWritten++;
if (scorePct >= 80) this.stats.correctChecks++;
this.listeningResult = {
userText,
diffHtml,
scorePct,
matchedCount,
total
};
this.saveState();
this.renderListeningStage();
}
toggleListeningHint() {
this.listeningShowHint = !this.listeningShowHint;
this.renderListeningStage();
}
nextListeningExercise() {
const list = window.LISTENING_EXERCISES || [];
this.listeningIndex = (this.listeningIndex + 1) % list.length;
this.listeningShowHint = false;
this.listeningResult = null;
this.speechResult = null;
this.renderListeningStage();
const nextEx = list[this.listeningIndex % list.length];
if (nextEx) this.speak(nextEx.de, 0.96);
}
startMicrophonePronunciationCheck() {
const SpeechRec = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRec) {
this.showToast("Dein Browser unterstützt die Web Speech Mikrofon-Erkennung leider nicht (am besten Chrome oder Edge nutzen).");
return;
}
const list = window.LISTENING_EXERCISES || [];
const ex = list[this.listeningIndex % list.length];
if (!ex) return;
if (this.isRecordingMic && this.activeRecognition) {
this.activeRecognition.stop();
return;
}
const recognition = new SpeechRec();
recognition.lang = "de-DE";
recognition.interimResults = false;
recognition.maxAlternatives = 1;
this.isRecordingMic = true;
this.activeRecognition = recognition;
this.renderListeningStage();
recognition.onresult = event => {
const transcript = event.results?.[0]?.[0]?.transcript || "";
const { diffHtml, scorePct } = this.computeWordDiffHtml(ex.de, transcript);
this.speechResult = {
transcript,
diffHtml,
scorePct
};
this.stats.sentencesWritten++;
this.saveState();
};
recognition.onerror = event => {
this.showToast(`Mikrofon-Hinweis: ${event.error}`);
};
recognition.onend = () => {
this.isRecordingMic = false;
this.renderListeningStage();
};
recognition.start();
}
renderListeningStage() {
const container = document.getElementById("listening-stage-container");
const list = window.LISTENING_EXERCISES || [];
if (!container || list.length === 0) return;
const ex = list[this.listeningIndex % list.length];
const wordInitialsHint = ex.de
.split(" ")
.map(w => w[0] + "___")
.join(" ");
container.innerHTML = `
🎧 Hörtraining & Aussprache • ${(this.listeningIndex % list.length) + 1} / ${list.length} (${ex.level})
${ex.situation}
Schritt 1: Höre den deutschen Satz (ohne Text!)
Klicke auf Play und tippe unten genau, was du hörst.
🔊 Normal abspielen (1.0x)
🐢 Langsam (0.8x)
💡 ${this.listeningShowHint ? "Hinweis ausblenden" : "Englische Bedeutung & Anfangsbuchstaben zeigen"}
⏭️ Nächster Hör-Satz →
${
this.listeningShowHint
? `
🇬🇧 Bedeutung: „${ex.en}“
🔤 Wort-Struktur (${ex.de.split(" ").length} Wörter): ${wordInitialsHint}
`
: ""
}
${
this.listeningResult
? `
🎯 Diktat-Ergebnis: ${this.listeningResult.scorePct}% (${this.listeningResult.matchedCount}/${this.listeningResult.total} Wörter erkannt)
🔊 Nochmal hören
${this.listeningResult.diffHtml}
🇬🇧 „${ex.en}“
💡 Hör-Fokus: ${ex.focusNote}
`
: ""
}
🎙️ Schritt 2: Shadowing & Aussprache-Check (Optional)
Sprich den Satz laut auf Deutsch nach — die Spracherkennung prüft deine Aussprache Wort für Wort!
${this.isRecordingMic ? "🔴 Höre zu... (Jetzt sprechen)" : "🎙️ Satz laut sprechen"}
${
this.speechResult
? `
🗣️ Erkannt: „${this.speechResult.transcript}“ (${this.speechResult.scorePct}% Übereinstimmung)
${this.speechResult.diffHtml}
`
: ""
}
`;
}
renderAll() {
this.updateDashboardCounters();
this.renderDailySpotlight();
}
}
function initWortKraft() {
if (!window.app) {
window.app = new WortKraftApp();
}
}
if (document.readyState === "loading") {
window.addEventListener("DOMContentLoaded", initWortKraft);
} else {
initWortKraft();
}