// 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();
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;
document.addEventListener("mousemove", e => {
if (!this.hoverTranslateEnabled) return;
// 1. Check if directly over an explicit .word-token span
const tokenEl = e.target.closest?.(".word-token");
if (tokenEl) {
const word = tokenEl.dataset.word || tokenEl.textContent.trim();
if (word) {
this.showHoverTooltipForRect(tokenEl.getBoundingClientRect(), word, true);
lastHoveredWord = word;
return;
}
}
// 2. Universal German word detector under cursor (works on headings, cards, buttons, chips, etc.)
const hit = this.getWordAtPoint(e.clientX, e.clientY);
if (hit && hit.word) {
const info = this.lookupWordInfo(hit.word, null);
// Show tooltip if word is in our German dictionaries
if (!info.needsLiveTranslate) {
this.showHoverTooltipForRect(hit.rect, hit.word, false);
lastHoveredWord = hit.word;
return;
}
}
if (lastHoveredWord) {
lastHoveredWord = null;
this.hideHoverTooltip();
}
});
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 left = Math.min(
window.innerWidth - 160,
Math.max(160, rect.left + rect.width / 2)
);
const top = Math.max(55, 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: "310px",
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 === "reader") {
this.renderReaderSidebar();
this.renderReaderMain();
} else if (viewName === "practice") {
this.renderPracticeSidebar();
this.renderPracticeStage();
} else if (viewName === "dictionary") {
this.renderDictionary();
}
}
startTrainerMode(mode) {
this.trainerMode = mode;
this.switchView("trainer");
this.setTrainerMode(mode);
}
// === DASHBOARD ===
updateDashboardCounters() {
const now = Date.now();
const dueWords = this.vocab.filter(w => w.srs.dueDate <= now);
const learnedWords = this.vocab.filter(w => w.srs.reviews > 0);
const masteredWords = this.vocab.filter(w => w.srs.interval >= 6);
const learningWords = this.vocab.filter(w => w.srs.reviews > 0 && w.srs.interval < 6);
const dueCount = dueWords.length;
const navBadge = document.getElementById("nav-due-badge");
const heroDue = document.getElementById("hero-due-count");
const statDue = document.getElementById("stat-due");
const statLearned = document.getElementById("stat-learned");
const statMasteredSub = document.getElementById("stat-mastered-sub");
const statAccuracy = document.getElementById("stat-accuracy");
const statSentences = document.getElementById("stat-sentences");
const streakCount = document.getElementById("streak-count");
if (navBadge) navBadge.textContent = dueCount;
if (heroDue) heroDue.textContent = dueCount;
if (statDue) statDue.textContent = dueCount;
if (statLearned) statLearned.textContent = `${learnedWords.length} / ${this.vocab.length}`;
if (statMasteredSub) {
statMasteredSub.textContent = `${masteredWords.length} gemeistert • ${learningWords.length} im Training`;
}
if (statAccuracy) {
const pct = this.stats.totalChecks > 0
? Math.round((this.stats.correctChecks / this.stats.totalChecks) * 100) + "%"
: "—";
statAccuracy.textContent = pct;
}
if (statSentences) statSentences.textContent = this.stats.sentencesWritten;
if (streakCount) streakCount.textContent = this.stats.streak;
}
refreshDailySpotlight() {
const shuffled = [...this.vocab].sort(() => Math.random() - 0.5);
this.spotlightWords = shuffled.slice(0, 4);
this.renderDailySpotlight();
}
renderDailySpotlight() {
const container = document.getElementById("daily-spotlight-list");
if (!container) return;
container.innerHTML = this.spotlightWords
.map(
w => `
${this.getGenderBadgeHtml(w.article, w.pos)}
${w.word}
— ${w.english}
„${this.tokenizeSentenceForHover(w.exampleDe)}“
`
)
.join("");
}
// === ACTIVE RECALL & SRS TRAINER ===
setTrainerMode(mode) {
this.trainerMode = mode;
document.querySelectorAll(".mode-pill").forEach(btn => {
btn.classList.toggle("active", btn.dataset.mode === mode);
});
this.cardRevealed = false;
this.lastAnswerStatus = null;
this.pickNextCard();
this.renderTrainerStage();
}
filterTrainerDeck() {
const sel = document.getElementById("trainer-category-select");
if (sel) this.trainerFilter = sel.value;
this.cardRevealed = false;
this.lastAnswerStatus = null;
this.pickNextCard();
this.renderTrainerStage();
}
getFilteredTrainerPool() {
let pool = [...this.vocab];
if (this.trainerMode === "gender") {
pool = pool.filter(w => Boolean(w.article));
}
if (this.trainerFilter === "due") {
const now = Date.now();
const due = pool.filter(w => w.srs.dueDate <= now);
if (due.length > 0) return due;
return pool;
} else if (this.trainerFilter !== "all") {
const catPool = pool.filter(w => w.category === this.trainerFilter);
if (catPool.length > 0) return catPool;
}
return pool;
}
pickNextCard() {
const pool = this.getFilteredTrainerPool();
if (pool.length === 0) {
this.currentCard = null;
return;
}
const sorted = [...pool].sort((a, b) => a.srs.dueDate - b.srs.dueDate);
const candidates = sorted.slice(0, Math.min(5, sorted.length));
const next = candidates.find(c => !this.currentCard || c.id !== this.currentCard.id) || candidates[0];
this.currentCard = next;
this.cardRevealed = false;
this.lastAnswerStatus = null;
}
renderTrainerStage() {
const container = document.getElementById("trainer-stage-container");
if (!container) return;
const w = this.currentCard;
if (!w) {
container.innerHTML = `
🎉 Alle Karten in dieser Auswahl erledigt!
Wähle oben eine andere Kategorie oder lies eine Geschichte in der Leseecke.
`;
return;
}
const fullDisplayWord = w.article
? `${w.article.replace(" (Pl.)", "")} ${w.word}`
: w.word;
let promptHtml = "";
if (this.trainerMode === "cloze") {
const rawCloze = w.clozeSentence || w.exampleDe.replace(new RegExp(w.word, "i"), "___");
const clozeParts = rawCloze.split("___");
const clozeInteractiveHtml = clozeParts
.map(part => this.tokenizeSentenceForHover(part))
.join('______');
promptHtml = `
🧩 Lückensatz im Kontext • Ergänze das fehlende Wort
${w.english}
${clozeInteractiveHtml}
${w.exampleEn}
${
!this.cardRevealed
? `
`
: ""
}
`;
} 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
? `
`
: ""
}
`;
} else {
promptHtml = `
🃏 Karteikarte & Wortbau • Überprüfe Artikel, Aussprache & Satz
${w.english}
Versuche das deutsche Wort (inkl. Artikel & Beispielsatz) laut auszusprechen, bevor du umdrehst.
${
!this.cardRevealed
? `
`
: ""
}
`;
}
let revealHtml = "";
if (this.cardRevealed) {
const statusClass = this.lastAnswerStatus
? this.lastAnswerStatus.isCorrect
? "correct"
: "incorrect"
: "";
revealHtml = `
${
this.lastAnswerStatus
? `
${this.lastAnswerStatus.message}
`
: ""
}
${w.english}
„${this.tokenizeSentenceForHover(w.exampleDe)}“
${w.exampleEn}
${
w.breakdown
? `
🧱 Wortbau & Eselsbrücke
${w.breakdown}
`
: ""
}
${
w.forms
? `
🔄 Formen & Kollokationen
${w.forms}
`
: ""
}
${
w.usageTip
? `
💡 Alltagstipp (Beyond Duolingo)
${w.usageTip}
`
: ""
}
Wie gut saß das Wort aktiv? (SM-2 Spaced Repetition)
`;
}
container.innerHTML = `
${w.category} • ${w.level}
Wiederholungen: ${w.srs.reviews} • Status: ${w.srs.status.toUpperCase()}
${promptHtml}
${revealHtml}
`;
const inputEl = document.getElementById("trainer-answer-input");
if (inputEl && !this.cardRevealed && typeof inputEl.focus === "function") {
setTimeout(() => inputEl.focus(), 30);
}
}
renderUmlautBar(inputId) {
const chars = ["ä", "ö", "ü", "ß", "Ä", "Ö", "Ü"];
return `
Umlaute:
${chars
.map(
ch =>
``
)
.join("")}
`;
}
insertUmlaut(inputId, char) {
const input = document.getElementById(inputId);
if (!input) return;
const start = input.selectionStart ?? input.value.length;
const end = input.selectionEnd ?? input.value.length;
const val = input.value;
input.value = val.slice(0, start) + char + val.slice(end);
input.focus();
input.setSelectionRange(start + 1, start + 1);
if (inputId === "practice-user-input") {
this.updateLiveTargetWordHighlights(input.value);
}
}
normalizeGerman(str) {
return (str || "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ")
.replace(/[.,!?]/g, "");
}
checkTrainerTextAnswer(e) {
e.preventDefault();
const input = document.getElementById("trainer-answer-input");
if (!input || !this.currentCard) return;
const userRaw = input.value.trim();
if (!userRaw) return;
const w = this.currentCard;
const userNorm = this.normalizeGerman(userRaw);
this.stats.totalChecks++;
if (this.trainerMode === "cloze") {
const targetCloze = this.normalizeGerman(w.clozeAnswer || w.word);
const targetWordOnly = this.normalizeGerman(w.word);
const isCorrect = userNorm === targetCloze || userNorm === targetWordOnly;
if (isCorrect) this.stats.correctChecks++;
this.lastAnswerStatus = {
isCorrect,
message: isCorrect
? `✅ Perfekt! „${w.clozeAnswer || w.word}“ passt genau in den Satz.`
: `❌ Fast! Deine Eingabe: „${userRaw}“ — Gesucht war: „${w.clozeAnswer || w.word}“.`
};
} else if (this.trainerMode === "production") {
const cleanArticle = w.article ? w.article.replace(" (Pl.)", "").toLowerCase() : "";
const expectedFull = cleanArticle ? `${cleanArticle} ${w.word.toLowerCase()}` : w.word.toLowerCase();
const expectedWordOnly = w.word.toLowerCase();
if (userNorm === expectedFull) {
this.stats.correctChecks++;
const capitalizedCheck = w.article && userRaw.split(" ").pop()[0] === w.word[0];
this.lastAnswerStatus = {
isCorrect: true,
message: capitalizedCheck
? `✅ Ausgezeichnet! Wort UND Artikel (${w.article} ${w.word}) komplett richtig!`
: `✅ Richtig (${w.article} ${w.word})! Denk daran, Nomen im Deutschen großzuschreiben.`
};
} else if (w.article && userNorm.endsWith(expectedWordOnly)) {
this.lastAnswerStatus = {
isCorrect: false,
message: `⚠️ Das Nomen „${w.word}“ stimmt, aber der Artikel ist ${w.article} ${w.word}! (${w.breakdown || "Merk dir Wort + Artikel immer als Einheit."})`
};
} else {
this.lastAnswerStatus = {
isCorrect: false,
message: `❌ Deine Eingabe: „${userRaw}“ — Richtige Antwort: ${w.article ? w.article + " " : ""}${w.word}.`
};
}
}
this.cardRevealed = true;
this.saveState();
this.speak((w.article ? w.article.replace(" (Pl.)", "") + " " : "") + w.word);
this.renderTrainerStage();
}
checkGenderChoice(chosenArticle) {
if (!this.currentCard) return;
const w = this.currentCard;
this.stats.totalChecks++;
const isCorrect = w.article === chosenArticle;
if (isCorrect) this.stats.correctChecks++;
this.lastAnswerStatus = {
isCorrect,
message: isCorrect
? `✅ Genau richtig! Es heißt ${w.article} ${w.word} (${w.plural || "kein Plural"}).`
: `❌ Nicht ganz: Du hast „${chosenArticle}“ gewählt, aber es heißt ${w.article} ${w.word}!`
};
this.cardRevealed = true;
this.saveState();
this.speak(`${w.article.replace(" (Pl.)", "")} ${w.word}`);
this.renderTrainerStage();
}
revealCardWithoutAnswer() {
if (!this.currentCard) return;
const w = this.currentCard;
this.cardRevealed = true;
this.lastAnswerStatus = null;
this.speak((w.article ? w.article.replace(" (Pl.)", "") + " " : "") + w.word);
this.renderTrainerStage();
}
gradeCurrentCard(grade) {
if (!this.currentCard) return;
const w = this.currentCard;
const now = Date.now();
const DAY_MS = 24 * 60 * 60 * 1000;
w.srs.reviews = (w.srs.reviews || 0) + 1;
if (grade === 1) {
w.srs.repetition = 0;
w.srs.interval = 0;
w.srs.dueDate = now + 10 * 60 * 1000;
w.srs.status = "learning";
} else if (grade === 2) {
w.srs.repetition += 1;
w.srs.interval = 1;
w.srs.dueDate = now + 1 * DAY_MS;
w.srs.status = "learning";
} else if (grade === 3) {
w.srs.repetition += 1;
w.srs.interval = w.srs.interval === 0 ? 3 : Math.round(w.srs.interval * w.srs.efactor);
w.srs.dueDate = now + w.srs.interval * DAY_MS;
w.srs.status = w.srs.interval >= 6 ? "mastered" : "learning";
} else if (grade === 4) {
w.srs.repetition += 1;
w.srs.efactor = Math.min(3.0, w.srs.efactor + 0.15);
w.srs.interval = w.srs.interval === 0 ? 7 : Math.round(w.srs.interval * w.srs.efactor * 1.3);
w.srs.dueDate = now + w.srs.interval * DAY_MS;
w.srs.status = "mastered";
}
this.saveState();
this.pickNextCard();
this.renderTrainerStage();
}
// === INTERACTIVE GRADED READER (LESEECKE) ===
renderReaderSidebar() {
const container = document.getElementById("story-list-container");
if (!container) return;
const stories = this.getAllStories();
container.innerHTML = stories
.map(
s => `
${s.type}
${s.level} • ${s.readTime}
${s.title}
${s.subtitle}
`
)
.join("");
}
selectStory(storyId) {
this.activeStoryId = storyId;
this.selectedReaderWord = null;
this.renderReaderSidebar();
this.renderReaderMain();
}
toggleReaderTranslation() {
this.showReaderTranslation = !this.showReaderTranslation;
this.renderReaderMain();
}
readWholeStoryAloud(rate = 0.9) {
const story = this.getAllStories().find(s => s.id === this.activeStoryId);
if (!story) return;
const fullText = story.paragraphs.map(p => p.de).join(" ");
this.speak(fullText, rate);
}
tokenizeSentenceForHover(deText) {
if (!deText) return "";
const tokens = deText.split(/([A-Za-zÄÖÜäöüß]+)/g);
return tokens
.map(token => {
if (!/^[A-Za-zÄÖÜäöüß]+$/.test(token)) {
return token;
}
return `${token}`;
})
.join("");
}
tokenizeParagraphToInteractiveHtml(deText, story) {
const tokens = deText.split(/([A-Za-zÄÖÜäöüß]+)/g);
return tokens
.map(token => {
if (!/^[A-Za-zÄÖÜäöüß]+$/.test(token)) {
return token;
}
const lookup = this.lookupWordInfo(token, story);
const isTarget = lookup && lookup.isTarget;
const inDeck = lookup && lookup.inDeck;
const classes = ["word-token"];
if (isTarget) classes.push("target-vocab");
else if (inDeck) classes.push("in-deck");
return `${token}`;
})
.join("");
}
lookupWordInfo(rawToken, story) {
const clean = rawToken.trim();
const lower = clean.toLowerCase();
// 1. Check story's own glossary
if (story && story.glossary && story.glossary[clean]) {
const g = story.glossary[clean];
const existingDeck = this.vocab.find(v => v.word.toLowerCase() === g.lemma.toLowerCase());
return {
surface: clean,
lemma: g.lemma,
article: g.article,
plural: g.plural,
pos: g.pos,
english: g.english,
note: g.note,
isTarget: true,
inDeck: Boolean(existingDeck)
};
}
// 2. Check user's SRS vocabulary database (exact or plural match)
const deckMatch = this.vocab.find(
v =>
v.word.toLowerCase() === lower ||
(v.plural && v.plural.toLowerCase().endsWith(" " + lower))
);
if (deckMatch) {
return {
surface: clean,
lemma: deckMatch.word,
article: deckMatch.article,
plural: deckMatch.plural,
pos: deckMatch.pos,
english: deckMatch.english,
note: deckMatch.usageTip || deckMatch.breakdown,
isTarget: story?.targetVocabIds?.includes(deckMatch.id) || false,
inDeck: true
};
}
// 3. Check other stories' glossaries
for (const st of window.GRADED_STORIES || []) {
if (st.glossary && st.glossary[clean]) {
const g = st.glossary[clean];
return {
surface: clean,
lemma: g.lemma,
article: g.article,
plural: g.plural,
pos: g.pos,
english: g.english,
note: g.note,
isTarget: false,
inDeck: false
};
}
}
// 4. Check common German dictionary
if (window.COMMON_GERMAN_DICT && window.COMMON_GERMAN_DICT[lower]) {
const c = window.COMMON_GERMAN_DICT[lower];
return {
surface: clean,
lemma: c.lemma,
article: c.article,
plural: c.plural,
pos: c.pos,
english: c.english,
note: c.note,
isTarget: false,
inDeck: false
};
}
// 5. Check comprehensive Hover Dictionary (100% of story/scenario/example words)
if (window.HOVER_DICT && window.HOVER_DICT[lower]) {
const hoverEn = window.HOVER_DICT[lower];
const isCap = clean[0] === clean[0].toUpperCase() && clean[0] !== clean[0].toLowerCase();
let inferredArt = null;
if (hoverEn.includes("(der ")) inferredArt = "der";
else if (hoverEn.includes("(die ")) inferredArt = "die";
else if (hoverEn.includes("(das ")) inferredArt = "das";
return {
surface: clean,
lemma: clean,
article: inferredArt,
plural: null,
pos: isCap ? "noun" : "word",
english: hoverEn,
note: null,
isTarget: false,
inDeck: false
};
}
// 6. Smart Morphological Analyzer + Live Translation trigger for custom imported words
const isCapitalized = clean[0] === clean[0].toUpperCase() && clean[0] !== clean[0].toLowerCase();
let guessedArticle = null;
let morphNote = "Klicke auf 'Ins SRS-Deck speichern', um dieses Wort zu trainieren.";
if (isCapitalized) {
if (lower.endsWith("ung") || lower.endsWith("heit") || lower.endsWith("keit") || lower.endsWith("schaft") || lower.endsWith("tät") || lower.endsWith("ion")) {
guessedArticle = "die";
morphNote = "Suffix-Regel: Deutsche Nomen auf -ung, -heit, -keit, -schaft, -ion sind immer feminin (die)!";
} else if (lower.endsWith("chen") || lower.endsWith("lein") || lower.endsWith("ment") || lower.endsWith("um")) {
guessedArticle = "das";
morphNote = "Suffix-Regel: Nomen auf -chen, -lein, -ment, -um sind fast immer neutrum (das)!";
}
}
return {
surface: clean,
lemma: clean,
article: guessedArticle,
plural: null,
pos: isCapitalized ? "noun" : "word",
english: "Übersetze...",
note: morphNote,
isTarget: false,
inDeck: false,
needsLiveTranslate: true
};
}
inspectReaderWord(rawToken) {
const story = this.getAllStories().find(s => s.id === this.activeStoryId);
const info = this.lookupWordInfo(rawToken, story);
this.selectedReaderWord = info;
if (info.needsLiveTranslate) {
this.fetchLiveWordTranslation(rawToken).then(() => {
if (window.HOVER_DICT && window.HOVER_DICT[rawToken.toLowerCase()]) {
this.selectedReaderWord.english = window.HOVER_DICT[rawToken.toLowerCase()];
this.renderReaderMain();
}
});
}
this.speak(rawToken);
this.renderReaderMain();
}
saveInspectedWordToDeck() {
const info = this.selectedReaderWord;
if (!info) return;
const existing = this.vocab.find(v => v.word.toLowerCase() === info.lemma.toLowerCase());
if (existing) {
existing.srs.dueDate = Date.now() - 1000;
this.saveState();
this.showToast(`„${existing.word}“ wurde im SRS-Trainer als sofort fällig markiert!`);
this.renderReaderMain();
return;
}
const story = this.getAllStories().find(s => s.id === this.activeStoryId);
const matchingParagraph = story?.paragraphs.find(p => p.de.includes(info.surface)) || story?.paragraphs[0];
const newEntry = this.createVocabEntry({
id: "custom_" + Date.now(),
word: info.lemma,
article: info.article,
plural: info.plural,
pos: info.pos || "word",
level: "A2/B1",
category: "Saved from Reading",
english: info.english === "Übersetze..." ? "Aus Leseecke gespeichert" : info.english,
breakdown: info.note,
forms: null,
exampleDe: matchingParagraph ? matchingParagraph.de : `Ich habe das Wort „${info.lemma}“ im Text gelesen.`,
exampleEn: matchingParagraph ? matchingParagraph.en : "",
clozeSentence: matchingParagraph
? matchingParagraph.de.replace(info.surface, "___")
: `Das Wort ___ ist sehr nützlich.`,
clozeAnswer: info.surface,
usageTip: info.note
});
this.vocab.unshift(newEntry);
this.selectedReaderWord.inDeck = true;
this.saveState();
this.showToast(`➕ „${info.lemma}“ zum SRS-Trainer hinzugefügt!`);
this.renderReaderMain();
}
answerStoryQuiz(storyId, optionIndex) {
this.quizSelection[storyId] = optionIndex;
this.renderReaderMain();
}
renderReaderMain() {
const container = document.getElementById("reader-content-area");
if (!container) return;
const story = this.getAllStories().find(s => s.id === this.activeStoryId) || this.getAllStories()[0];
if (!story) return;
const paragraphsHtml = story.paragraphs
.map(
p => `
${this.tokenizeParagraphToInteractiveHtml(p.de, story)}
${
this.showReaderTranslation && p.en
? `
${p.en}
`
: ""
}
`
)
.join("");
let inspectorHtml = "";
if (this.selectedReaderWord) {
const w = this.selectedReaderWord;
inspectorHtml = `
${this.getGenderBadgeHtml(w.article, w.pos)}
${w.lemma}
${w.plural ? `(${w.plural})` : ""}
${w.english}
${
w.note
? `
💡 ${w.note}
`
: ""
}
`;
} else {
inspectorHtml = `
👆 Tipp: Fahre mit der Maus über ein Wort für die Sofort-Übersetzung oder klicke darauf, um es mit Audio ins SRS-Deck zu speichern!
`;
}
let quizHtml = "";
if (story.comprehensionQuestion) {
const q = story.comprehensionQuestion;
const chosen = this.quizSelection[story.id];
quizHtml = `
🧠 Verständnis-Check zur Geschichte
${this.tokenizeSentenceForHover(q.question)}
${q.options
.map((opt, idx) => {
let bg = "var(--bg-main)";
let border = "var(--border-color)";
if (chosen !== undefined) {
if (idx === q.correctIndex) {
bg = "var(--success-bg)";
border = "#34d399";
} else if (idx === chosen) {
bg = "var(--danger-bg)";
border = "#fca5a5";
}
}
return `
`;
})
.join("")}
${
chosen !== undefined
? `
${chosen === q.correctIndex ? "✅ Richtig! " : "❌ Nicht ganz: "}${q.explanation}
`
: ""
}
`;
}
container.innerHTML = `
${paragraphsHtml}
${inspectorHtml}
${quizHtml}
`;
}
// === CONVERSATION & WRITING PRACTICE (SPRACHPRAXIS) ===
initScenarioStates() {
(window.CONVERSATION_SCENARIOS || []).forEach(scen => {
if (!this.scenarioState[scen.id]) {
this.resetScenario(scen.id, false);
}
});
}
resetScenario(scenarioId, reRender = true) {
const scen = (window.CONVERSATION_SCENARIOS || []).find(s => s.id === scenarioId);
if (!scen) return;
this.scenarioState[scenarioId] = {
turnIndex: 0,
usedWords: [],
completed: false,
messages: [
{
role: "partner",
de: scen.turns[0].partnerDe,
en: scen.turns[0].partnerEn
}
]
};
if (reRender) this.renderPracticeStage();
}
renderPracticeSidebar() {
const container = document.getElementById("scenario-list-container");
if (!container) return;
container.innerHTML = (window.CONVERSATION_SCENARIOS || []).map(scen => {
const st = this.scenarioState[scen.id];
const usedCount = st ? st.usedWords.length : 0;
return `
${scen.avatar} ${scen.rolePartner}
${usedCount}/${scen.targetWords.length} Zielwörter
${scen.title}
Level ${scen.level}
`;
}).join("");
}
selectScenario(scenarioId) {
this.activeScenarioId = scenarioId;
this.renderPracticeSidebar();
this.renderPracticeStage();
}
detectTargetWordsInText(text, scen) {
const lower = (text || "").toLowerCase();
const matched = [];
scen.targetWords.forEach(tw => {
const hit = tw.stems.some(stem => lower.includes(stem.toLowerCase()));
if (hit) matched.push(tw.word);
});
return matched;
}
updateLiveTargetWordHighlights(currentInputText) {
const scen = (window.CONVERSATION_SCENARIOS || []).find(s => s.id === this.activeScenarioId);
if (!scen) return;
const st = this.scenarioState[scen.id];
const newlyTyped = this.detectTargetWordsInText(currentInputText, scen);
const combined = new Set([...st.usedWords, ...newlyTyped]);
document.querySelectorAll(".target-word-chip").forEach(chip => {
const w = chip.dataset.word;
const isUsed = combined.has(w);
chip.classList.toggle("used", isUsed);
const iconSpan = chip.querySelector(".chip-check");
if (iconSpan) iconSpan.textContent = isUsed ? "✅" : "⭕";
});
}
analyzeGermanSentence(userText, newlyUsedTargetWords, currentTurn) {
const tips = [];
const trimmed = userText.trim();
if (newlyUsedTargetWords.length > 0) {
tips.push(`🎯 Zielwörter aktiv eingebaut: ${newlyUsedTargetWords.join(", ")}!`);
} else {
tips.push(`💡 Tipp: Versuche in deiner Antwort mindestens eines der oben gelisteten Zielwörter zu verwenden.`);
}
const lower = trimmed.toLowerCase();
if (lower.includes("weil ") || lower.includes("obwohl ") || lower.includes("dass ")) {
tips.push(`🧱 Nebensatz-Struktur genutzt (weil/obwohl/dass): Super für B1-Niveau! Denk daran, dass das konjugierte Verb im Nebensatz ganz ans Ende wandert.`);
}
if (lower.includes("deshalb ") || lower.includes("trotzdem ")) {
const badOrder = /deshalb\s+ich\b|trotzdem\s+ich\b/i.test(trimmed);
if (badOrder) {
tips.push(`⚠️ Wortstellung nach „deshalb / trotzdem“: Im Deutschen steht das Verb direkt auf Position 2: „Deshalb möchte ich...“ (nicht „Deshalb ich möchte“).`);
} else {
tips.push(`✨ Starker Konnektor (deshalb/trotzdem) mit richtiger Verb-Zweitstellung!`);
}
}
if (currentTurn && currentTurn.sampleResponse) {
tips.push(`🇩🇪 Muster-Formulierung zum Vergleich: „${this.tokenizeSentenceForHover(currentTurn.sampleResponse)}“`);
}
return tips.join("
");
}
async handlePracticeSubmit(e) {
e.preventDefault();
const input = document.getElementById("practice-user-input");
if (!input) return;
const text = input.value.trim();
if (!text) return;
const scen = (window.CONVERSATION_SCENARIOS || []).find(s => s.id === this.activeScenarioId);
const st = this.scenarioState[scen.id];
const currentTurn = scen.turns[st.turnIndex];
const matchedWords = this.detectTargetWordsInText(text, scen);
matchedWords.forEach(w => {
if (!st.usedWords.includes(w)) st.usedWords.push(w);
});
st.messages.push({
role: "user",
de: text
});
this.stats.sentencesWritten++;
input.value = "";
let feedbackHtml = this.analyzeGermanSentence(text, matchedWords, currentTurn);
if (this.settings.aiProvider !== "builtin" && this.settings.apiKey) {
const aiCoachNote = await this.fetchOptionalAiFeedback(text, scen, currentTurn);
if (aiCoachNote) {
feedbackHtml = `🤖 Live-KI-Feedback: ${aiCoachNote}
` + feedbackHtml;
}
}
st.messages.push({
role: "feedback",
html: feedbackHtml
});
if (st.turnIndex + 1 < scen.turns.length) {
st.turnIndex += 1;
const nextTurn = scen.turns[st.turnIndex];
st.messages.push({
role: "partner",
de: nextTurn.partnerDe,
en: nextTurn.partnerEn
});
this.speak(nextTurn.partnerDe);
} else {
st.completed = true;
st.messages.push({
role: "partner",
de: "Vielen Dank für das Gespräch! Du hast die Situation erfolgreich auf Deutsch gemeistert. 🎉",
en: "Thank you for the conversation! You successfully mastered the situation in German."
});
}
this.saveState();
this.renderPracticeSidebar();
this.renderPracticeStage();
}
async fetchOptionalAiFeedback(userGerman, scen, currentTurn) {
try {
const prompt = `Du bist ein freundlicher Deutschlehrer für Niveau A2/B1. Situation: ${scen.situation}. Gesprächspartner sagte: "${currentTurn?.partnerDe || ""}". Der Lernende antwortete: "${userGerman}". Gib in 1-2 kurzen Sätzen konstruktives Feedback zu Grammatik/Wortwahl und nenne bei Fehlern eine natürliche Korrektur.`;
if (this.settings.aiProvider === "gemini") {
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${encodeURIComponent(this.settings.apiKey)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }]
})
}
);
const data = await res.json();
return data?.candidates?.[0]?.content?.parts?.[0]?.text || null;
} else if (this.settings.aiProvider === "openai") {
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.settings.apiKey}`
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
max_tokens: 150
})
});
const data = await res.json();
return data?.choices?.[0]?.message?.content || null;
}
} catch (err) {
console.warn("Optional AI request failed, using built-in coach:", err);
}
return null;
}
insertSampleHint() {
const scen = (window.CONVERSATION_SCENARIOS || []).find(s => s.id === this.activeScenarioId);
const st = this.scenarioState[scen.id];
const currentTurn = scen?.turns[st.turnIndex];
const input = document.getElementById("practice-user-input");
if (input && currentTurn) {
input.value = currentTurn.sampleResponse;
input.focus();
this.updateLiveTargetWordHighlights(input.value);
}
}
renderPracticeStage() {
const container = document.getElementById("practice-stage-area");
if (!container) return;
const scen = (window.CONVERSATION_SCENARIOS || []).find(s => s.id === this.activeScenarioId);
if (!scen) return;
const st = this.scenarioState[scen.id];
const currentTurn = scen.turns[st.turnIndex];
const targetChipsHtml = scen.targetWords
.map(tw => {
const isUsed = st.usedWords.includes(tw.word);
return `
${isUsed ? "✅" : "⭕"}
${tw.word}
(${tw.hint})
`;
})
.join("");
const messagesHtml = st.messages
.map(m => {
if (m.role === "partner") {
return `
${scen.avatar} ${scen.rolePartner}
${this.tokenizeSentenceForHover(m.de)}
${m.en ? `
${m.en}
` : ""}
`;
} else if (m.role === "user") {
return `
Du (Aktiv produziert)
${m.de}
`;
} else {
return `${m.html}
`;
}
})
.join("");
container.innerHTML = `
Rollenspiel • ${scen.level}
${scen.avatar} ${scen.title}
${scen.situation}
🎯 Deine 5 Zielwörter für dieses Gespräch (${st.usedWords.length}/${scen.targetWords.length} genutzt)
${targetChipsHtml}
${messagesHtml}
${
!st.completed
? `
Aufgabe (Schritt ${st.turnIndex + 1}/${scen.turns.length}): ${currentTurn.promptHint}
`
: `
🎉 Szenario abgeschlossen! Du hast ${st.usedWords.length} von ${scen.targetWords.length} Zielwörtern aktiv im Kontext verwendet.
`
}
`;
const chatBox = document.getElementById("chat-stream-box");
if (chatBox) {
chatBox.scrollTop = chatBox.scrollHeight;
}
}
// === VOCABULARY BANK (WÖRTERBUCH) ===
renderDictionary() {
const grid = document.getElementById("dictionary-grid");
if (!grid) return;
const query = (document.getElementById("dict-search-input")?.value || "").trim().toLowerCase();
const genderFilter = document.getElementById("dict-gender-filter")?.value || "all";
const categoryFilter = document.getElementById("dict-category-filter")?.value || "all";
const filtered = this.vocab.filter(w => {
if (query) {
const hay = `${w.word} ${w.english} ${w.exampleDe} ${w.category}`.toLowerCase();
if (!hay.includes(query)) return false;
}
if (genderFilter !== "all") {
if (genderFilter === "verb") {
if (w.article) return false;
} else if (!w.article || !w.article.startsWith(genderFilter)) {
return false;
}
}
if (categoryFilter !== "all" && w.category !== categoryFilter) {
return false;
}
return true;
});
grid.innerHTML = filtered
.map(
w => `
${this.getGenderBadgeHtml(w.article, w.pos)}
${w.word}
${w.plural ? `
Plural: ${w.plural}
` : ""}
${w.english}
„${this.tokenizeSentenceForHover(w.exampleDe)}“
${w.breakdown ? `
🧱 ${w.breakdown}
` : ""}
${w.category}
SRS: ${w.srs.status.toUpperCase()} (${w.srs.reviews}x)
`
)
.join("");
}
// === MODALS & CUSTOM CONTENT ===
openModal(id) {
const el = document.getElementById(id);
if (el) el.classList.add("open");
if (id === "settings-modal") {
document.getElementById("settings-ai-provider").value = this.settings.aiProvider || "builtin";
document.getElementById("settings-api-key").value = this.settings.apiKey || "";
}
}
closeModal(id) {
const el = document.getElementById(id);
if (el) el.classList.remove("open");
}
handleAddCustomWord(e) {
e.preventDefault();
const article = document.getElementById("custom-article").value || null;
const word = document.getElementById("custom-word").value.trim();
const english = document.getElementById("custom-english").value.trim();
const plural = document.getElementById("custom-plural").value.trim() || null;
const exampleDe = document.getElementById("custom-example-de").value.trim();
const exampleEn = document.getElementById("custom-example-en").value.trim();
const tip = document.getElementById("custom-tip").value.trim();
if (!word || !english || !exampleDe) return;
const newWord = this.createVocabEntry({
id: "user_" + Date.now(),
word,
article,
plural,
pos: article ? "noun" : "verb / expression",
level: "A2/B1",
category: "Saved from Reading",
english,
breakdown: tip || null,
forms: plural,
exampleDe,
exampleEn,
clozeSentence: exampleDe.replace(new RegExp(word, "i"), "___"),
clozeAnswer: word,
usageTip: tip || null
});
this.vocab.unshift(newWord);
this.saveState();
this.closeModal("add-word-modal");
e.target.reset();
this.showToast(`✅ „${word}“ wurde zu deinem Wortschatz-Trainer hinzugefügt!`);
if (this.currentView === "dictionary") this.renderDictionary();
}
handleImportCustomStory(e) {
e.preventDefault();
const title = document.getElementById("import-story-title").value.trim();
const body = document.getElementById("import-story-body").value.trim();
if (!title || !body) return;
const rawParagraphs = body
.split(/\n\s*\n/)
.map(p => p.trim())
.filter(Boolean);
const newStory = {
id: "custom_story_" + Date.now(),
title,
subtitle: "Eigener importierter Text (Jedes Wort ist klickbar & hoverbar)",
level: "A2 / B1",
type: "Eigener Text",
readTime: "3 min",
targetVocabIds: [],
paragraphs: rawParagraphs.map(p => ({ de: p, en: "" })),
glossary: {}
};
this.customStories.unshift(newStory);
this.activeStoryId = newStory.id;
this.saveState();
this.closeModal("import-text-modal");
e.target.reset();
this.switchView("reader");
this.showToast(`📄 „${title}“ erfolgreich in der Leseecke geöffnet!`);
}
saveSettings() {
this.settings.aiProvider = document.getElementById("settings-ai-provider").value;
this.settings.apiKey = document.getElementById("settings-api-key").value.trim();
this.saveState();
this.closeModal("settings-modal");
this.showToast("⚙️ Einstellungen gespeichert!");
}
exportProgress() {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(this.vocab, null, 2));
const dlAnchor = document.createElement("a");
dlAnchor.setAttribute("href", dataStr);
dlAnchor.setAttribute("download", "wortkraft_wortschatz_backup.json");
document.body.appendChild(dlAnchor);
dlAnchor.click();
dlAnchor.remove();
this.showToast("💾 Backup heruntergeladen!");
}
resetProgress() {
if (!confirm("Möchtest du deinen Lernfortschritt wirklich auf den Anfang zurücksetzen?")) return;
localStorage.removeItem(STORAGE_KEY);
window.location.reload();
}
showToast(message) {
const toast = document.getElementById("toast");
if (!toast) return;
toast.textContent = message;
toast.classList.add("show");
clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => {
toast.classList.remove("show");
}, 3200);
}
escapeJs(str) {
return (str || "")
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/"/g, """)
.replace(/\n/g, " ");
}
renderAll() {
this.updateDashboardCounters();
this.renderDailySpotlight();
}
}
function initWortKraft() {
if (!window.app) {
window.app = new WortKraftApp();
}
}
if (document.readyState === "loading") {
window.addEventListener("DOMContentLoaded", initWortKraft);
} else {
initWortKraft();
}