Files
wortkraft/app.js
T

1629 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// WortKraft — Active German A2/B1 Vocabulary, Reader & Conversation Studio
const STORAGE_KEY = "wortkraft_state_v1";
class WortKraftApp {
constructor() {
this.currentView = "dashboard";
this.trainerMode = "cloze"; // 'cloze' | 'production' | 'gender' | 'flashcard'
this.trainerFilter = "due";
this.currentCard = null;
this.cardRevealed = false;
this.lastAnswerStatus = null; // { isCorrect, message, userText }
this.activeStoryId = window.GRADED_STORIES[0].id;
this.showReaderTranslation = false;
this.selectedReaderWord = null;
this.quizSelection = {};
this.activeScenarioId = window.CONVERSATION_SCENARIOS[0].id;
this.scenarioState = {}; // { [scenarioId]: { turnIndex, messages, usedTargetWords } }
this.spotlightWords = [];
this.loadState();
this.initScenarioStates();
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;
localStorage.setItem(STORAGE_KEY, JSON.stringify(parsed));
this.renderAll();
} else {
// First run on server: push current default state to server
this.pushStateToServer();
}
} catch (_e) {
// Offline or opened as local file:// — 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 || [];
// Merge any new default words if vocabulary.js was updated
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 = parsed.settings || {
aiProvider: "builtin",
apiKey: ""
};
return;
} catch (e) {
console.warn("Could not parse saved state, initializing fresh state.", e);
}
}
// Initialize default state
const now = Date.now();
this.vocab = window.INITIAL_VOCABULARY.map((item, idx) => {
const entry = this.createVocabEntry(item);
// Make first 15 words immediately due today so user has a focused starting queue,
// and the rest available as new/due as well
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: ""
};
this.saveState();
}
createVocabEntry(raw) {
return {
...raw,
srs: raw.srs || {
interval: 0,
repetition: 0,
efactor: 2.5,
dueDate: Date.now(),
status: "new", // 'new' | 'learning' | 'mastered'
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];
}
// === AUDIO PRONUNCIATION (Web Speech API de-DE) ===
speak(text, rate = 0.92) {
if (!("speechSynthesis" in window)) {
this.showToast("Audio wird von diesem Browser leider nicht unterstützt.");
return;
}
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "de-DE";
utterance.rate = rate;
const voices = window.speechSynthesis.getVoices();
const germanVoice =
voices.find(v => v.lang.startsWith("de") && (v.name.includes("Natural") || v.name.includes("Google") || v.name.includes("Anna") || v.name.includes("Katja"))) ||
voices.find(v => v.lang.startsWith("de"));
if (germanVoice) {
utterance.voice = germanVoice;
}
window.speechSynthesis.speak(utterance);
}
// === GENDER BADGE HELPER ===
getGenderBadgeHtml(article, pos = "") {
if (!article) {
return `<span class="gender-badge gender-none">${pos || "Wort"}</span>`;
}
const clean = article.toLowerCase();
if (clean === "der") return `<span class="gender-badge gender-der">der</span>`;
if (clean === "die") return `<span class="gender-badge gender-die">die</span>`;
if (clean === "das") return `<span class="gender-badge gender-das">das</span>`;
if (clean.includes("pl")) return `<span class="gender-badge gender-pl">die (Pl.)</span>`;
return `<span class="gender-badge gender-none">${article}</span>`;
}
// === NAVIGATION ===
switchView(viewName) {
this.currentView = viewName;
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();
} 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 => `
<div style="padding: 0.85rem 1rem; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-main); display: flex; justify-content: space-between; align-items: flex-start; gap: 0.75rem;">
<div>
<div style="display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap;">
${this.getGenderBadgeHtml(w.article, w.pos)}
<strong style="font-size: 1rem;">${w.word}</strong>
<span style="font-size: 0.84rem; color: var(--text-secondary);">— ${w.english}</span>
</div>
<div style="font-size: 0.83rem; color: var(--text-muted); margin-top: 0.3rem;">
„${w.exampleDe}“
</div>
</div>
<button class="icon-btn" style="width: 32px; height: 32px; flex-shrink: 0;" onclick="app.speak('${this.escapeJs(
(w.article ? w.article.replace(" (Pl.)", "") + " " : "") + w.word + ". " + w.exampleDe
)}')" title="Vorlesen">🔊</button>
</div>
`
)
.join("");
}
// === ACTIVE RECALL & SRS TRAINER ===
setTrainerMode(mode) {
this.trainerMode = mode;
document.querySelectorAll(".mode-pill").forEach(btn => {
btn.classList.toggle("active", btn.dataset.mode === mode);
});
this.cardRevealed = false;
this.lastAnswerStatus = null;
this.pickNextCard();
this.renderTrainerStage();
}
filterTrainerDeck() {
const sel = document.getElementById("trainer-category-select");
if (sel) this.trainerFilter = sel.value;
this.cardRevealed = false;
this.lastAnswerStatus = null;
this.pickNextCard();
this.renderTrainerStage();
}
getFilteredTrainerPool() {
let pool = [...this.vocab];
// If in gender mode, only include nouns with articles
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; // fallback if all due cards finished
} 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;
}
// Sort by due date and pick among top 5 so it feels fresh
const sorted = [...pool].sort((a, b) => a.srs.dueDate - b.srs.dueDate);
const candidates = sorted.slice(0, Math.min(5, sorted.length));
const next = candidates.find(c => !this.currentCard || c.id !== this.currentCard.id) || candidates[0];
this.currentCard = next;
this.cardRevealed = false;
this.lastAnswerStatus = null;
}
renderTrainerStage() {
const container = document.getElementById("trainer-stage-container");
if (!container) return;
const w = this.currentCard;
if (!w) {
container.innerHTML = `
<div class="srs-card-stage" style="text-align: center;">
<h2>🎉 Alle Karten in dieser Auswahl erledigt!</h2>
<p style="color: var(--text-secondary); margin: 0.75rem 0 1.25rem;">Wähle oben eine andere Kategorie oder lies eine Geschichte in der Leseecke.</p>
<button class="btn btn-primary" onclick="app.switchView('reader')">📖 Zur Leseecke</button>
</div>
`;
return;
}
const fullDisplayWord = w.article
? `${w.article.replace(" (Pl.)", "")} ${w.word}`
: w.word;
let promptHtml = "";
if (this.trainerMode === "cloze") {
const clozeText = w.clozeSentence || w.exampleDe.replace(new RegExp(w.word, "i"), "___");
promptHtml = `
<div class="prompt-instruction">🧩 Lückensatz im Kontext • Ergänze das fehlende Wort</div>
<div class="prompt-main">${w.english}</div>
<div class="cloze-box">
<div class="cloze-de">${clozeText.replace(/___/g, '<span style="color:var(--primary);border-bottom:2px solid var(--primary);padding:0 0.4rem;">______</span>')}</div>
<div class="cloze-en">${w.exampleEn}</div>
</div>
${
!this.cardRevealed
? `
<form onsubmit="app.checkTrainerTextAnswer(event)">
<div class="answer-input-row">
<input type="text" id="trainer-answer-input" class="german-input" placeholder="Fehlendes Wort eingeben (z.B. ${w.clozeAnswer ? w.clozeAnswer.slice(0, 2) + "..." : w.word.slice(0, 2) + "..."})" autocomplete="off" autofocus />
<button type="submit" class="btn btn-primary">Prüfen</button>
<button type="button" class="btn btn-secondary" onclick="app.revealCardWithoutAnswer()">Lösung zeigen</button>
</div>
${this.renderUmlautBar("trainer-answer-input")}
</form>
`
: ""
}
`;
} else if (this.trainerMode === "production") {
const needsArticle = Boolean(w.article);
promptHtml = `
<div class="prompt-instruction">✍️ Aktiver Abruf • Übersetze auf Deutsch ${needsArticle ? "(MIT Artikel der/die/das!)" : ""}</div>
<div class="prompt-main">${w.english}</div>
<div style="font-size: 0.9rem; color: var(--text-secondary); margin-bottom: 1rem;">
Wortart: <strong>${w.pos}</strong> • Kontext: <em>„${w.exampleEn}“</em>
</div>
${
!this.cardRevealed
? `
<form onsubmit="app.checkTrainerTextAnswer(event)">
<div class="answer-input-row">
<input type="text" id="trainer-answer-input" class="german-input" placeholder="${needsArticle ? "z.B. die Vereinbarung" : "Deutsches Wort eingeben..."}" autocomplete="off" autofocus />
<button type="submit" class="btn btn-primary">Prüfen</button>
<button type="button" class="btn btn-secondary" onclick="app.revealCardWithoutAnswer()">Lösung zeigen</button>
</div>
${this.renderUmlautBar("trainer-answer-input")}
</form>
`
: ""
}
`;
} else if (this.trainerMode === "gender") {
promptHtml = `
<div class="prompt-instruction">🎨 Artikel & Plural-Blitz • Welcher Artikel gehört zu diesem Nomen?</div>
<div class="prompt-main" style="font-size: 2rem;">___ ${w.word}</div>
<div style="font-size: 0.95rem; color: var(--text-secondary); margin-bottom: 1rem;">
Bedeutung: <strong>${w.english}</strong>
</div>
${
!this.cardRevealed
? `
<div class="gender-choice-grid">
<button class="gender-choice-btn gender-der" onclick="app.checkGenderChoice('der')">der</button>
<button class="gender-choice-btn gender-die" onclick="app.checkGenderChoice('die')">die</button>
<button class="gender-choice-btn gender-das" onclick="app.checkGenderChoice('das')">das</button>
<button class="gender-choice-btn gender-pl" onclick="app.checkGenderChoice('die (Pl.)')">die (Pl.)</button>
</div>
`
: ""
}
`;
} else {
// Flashcard mode
promptHtml = `
<div class="prompt-instruction">🃏 Karteikarte & Wortbau • Überprüfe Artikel, Aussprache & Satz</div>
<div class="prompt-main">${w.english}</div>
<div style="font-size: 0.92rem; color: var(--text-secondary); margin-bottom: 1.25rem;">
Versuche das deutsche Wort (inkl. Artikel & Beispielsatz) laut auszusprechen, bevor du umdrehst.
</div>
${
!this.cardRevealed
? `
<button class="btn btn-primary" onclick="app.revealCardWithoutAnswer()">🔄 Karte umdrehen & Audio hören</button>
`
: ""
}
`;
}
let revealHtml = "";
if (this.cardRevealed) {
const statusClass = this.lastAnswerStatus
? this.lastAnswerStatus.isCorrect
? "correct"
: "incorrect"
: "";
revealHtml = `
<div class="feedback-panel ${statusClass}">
${
this.lastAnswerStatus
? `<div style="font-weight: 700; margin-bottom: 0.65rem; font-size: 0.95rem;">
${this.lastAnswerStatus.message}
</div>`
: ""
}
<div class="word-reveal-header">
<div class="word-reveal-title">
${this.getGenderBadgeHtml(w.article, w.pos)}
<span>${w.word}</span>
${w.plural ? `<span style="font-size: 0.9rem; font-weight: 500; color: var(--text-secondary);">(${w.plural})</span>` : ""}
</div>
<button class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.84rem;" onclick="app.speak('${this.escapeJs(
fullDisplayWord + ". " + w.exampleDe
)}')">
🔊 Aussprache & Satz
</button>
</div>
<div style="font-size: 1.02rem; margin-bottom: 0.65rem;">
<strong>${w.english}</strong>
</div>
<div class="cloze-box" style="background: rgba(255,255,255,0.85); margin-bottom: 0.85rem;">
<div class="cloze-de">„${w.exampleDe}“</div>
<div class="cloze-en">${w.exampleEn}</div>
</div>
<div class="word-detail-grid">
${
w.breakdown
? `<div class="detail-item">
<span class="detail-label">🧱 Wortbau & Eselsbrücke</span>
<span>${w.breakdown}</span>
</div>`
: ""
}
${
w.forms
? `<div class="detail-item">
<span class="detail-label">🔄 Formen & Kollokationen</span>
<span>${w.forms}</span>
</div>`
: ""
}
${
w.usageTip
? `<div class="detail-item" style="grid-column: 1 / -1;">
<span class="detail-label">💡 Alltagstipp (Beyond Duolingo)</span>
<span>${w.usageTip}</span>
</div>`
: ""
}
</div>
<div style="margin-top: 1rem; font-size: 0.8rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase;">
Wie gut saß das Wort aktiv? (SM-2 Spaced Repetition)
</div>
<div class="srs-grade-buttons">
<button class="grade-btn grade-again" onclick="app.gradeCurrentCard(1)">
Nochmal
<span class="interval">&lt; 10 Min</span>
</button>
<button class="grade-btn grade-hard" onclick="app.gradeCurrentCard(2)">
Schwer
<span class="interval">1 Tag</span>
</button>
<button class="grade-btn grade-good" onclick="app.gradeCurrentCard(3)">
Gut
<span class="interval">3 Tage</span>
</button>
<button class="grade-btn grade-easy" onclick="app.gradeCurrentCard(4)">
Leicht
<span class="interval">7+ Tage</span>
</button>
</div>
</div>
`;
}
container.innerHTML = `
<div class="srs-card-stage">
<div class="card-meta-top">
<span class="category-tag">${w.category} • ${w.level}</span>
<span style="font-size: 0.8rem; color: var(--text-muted);">
Wiederholungen: ${w.srs.reviews} • Status: <strong>${w.srs.status.toUpperCase()}</strong>
</span>
</div>
${promptHtml}
${revealHtml}
</div>
`;
const inputEl = document.getElementById("trainer-answer-input");
if (inputEl && !this.cardRevealed) {
setTimeout(() => inputEl.focus(), 30);
}
}
renderUmlautBar(inputId) {
const chars = ["ä", "ö", "ü", "ß", "Ä", "Ö", "Ü"];
return `
<div class="umlaut-bar">
<span class="umlaut-label">Umlaute:</span>
${chars
.map(
ch =>
`<button type="button" class="umlaut-key" onclick="app.insertUmlaut('${inputId}', '${ch}')">${ch}</button>`
)
.join("")}
</div>
`;
}
insertUmlaut(inputId, char) {
const input = document.getElementById(inputId);
if (!input) return;
const start = input.selectionStart ?? input.value.length;
const end = input.selectionEnd ?? input.value.length;
const val = input.value;
input.value = val.slice(0, start) + char + val.slice(end);
input.focus();
input.setSelectionRange(start + 1, start + 1);
if (inputId === "practice-user-input") {
this.updateLiveTargetWordHighlights(input.value);
}
}
normalizeGerman(str) {
return (str || "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ")
.replace(/[.,!?]/g, "");
}
checkTrainerTextAnswer(e) {
e.preventDefault();
const input = document.getElementById("trainer-answer-input");
if (!input || !this.currentCard) return;
const userRaw = input.value.trim();
if (!userRaw) return;
const w = this.currentCard;
const userNorm = this.normalizeGerman(userRaw);
this.stats.totalChecks++;
if (this.trainerMode === "cloze") {
const targetCloze = this.normalizeGerman(w.clozeAnswer || w.word);
const targetWordOnly = this.normalizeGerman(w.word);
const isCorrect = userNorm === targetCloze || userNorm === targetWordOnly;
if (isCorrect) this.stats.correctChecks++;
this.lastAnswerStatus = {
isCorrect,
message: isCorrect
? `✅ Perfekt! „${w.clozeAnswer || w.word}“ passt genau in den Satz.`
: `❌ Fast! Deine Eingabe: „${userRaw}“ — Gesucht war: „${w.clozeAnswer || w.word}“.`
};
} else if (this.trainerMode === "production") {
const cleanArticle = w.article ? w.article.replace(" (Pl.)", "").toLowerCase() : "";
const expectedFull = cleanArticle ? `${cleanArticle} ${w.word.toLowerCase()}` : w.word.toLowerCase();
const expectedWordOnly = w.word.toLowerCase();
if (userNorm === expectedFull) {
this.stats.correctChecks++;
const capitalizedCheck = w.article && userRaw.split(" ").pop()[0] === w.word[0];
this.lastAnswerStatus = {
isCorrect: true,
message: capitalizedCheck
? `✅ Ausgezeichnet! Wort UND Artikel (${w.article} ${w.word}) komplett richtig!`
: `✅ Richtig (${w.article} ${w.word})! Denk daran, Nomen im Deutschen großzuschreiben.`
};
} else if (w.article && userNorm.endsWith(expectedWordOnly)) {
// Right noun, wrong or missing article!
this.lastAnswerStatus = {
isCorrect: false,
message: `⚠️ Das Nomen „${w.word}“ stimmt, aber der Artikel ist <strong>${w.article} ${w.word}</strong>! (${w.breakdown || "Merk dir Wort + Artikel immer als Einheit."})`
};
} else {
this.lastAnswerStatus = {
isCorrect: false,
message: `❌ Deine Eingabe: „${userRaw}“ — Richtige Antwort: <strong>${w.article ? w.article + " " : ""}${w.word}</strong>.`
};
}
}
this.cardRevealed = true;
this.saveState();
this.speak((w.article ? w.article.replace(" (Pl.)", "") + " " : "") + w.word);
this.renderTrainerStage();
}
checkGenderChoice(chosenArticle) {
if (!this.currentCard) return;
const w = this.currentCard;
this.stats.totalChecks++;
const isCorrect = w.article === chosenArticle;
if (isCorrect) this.stats.correctChecks++;
this.lastAnswerStatus = {
isCorrect,
message: isCorrect
? `✅ Genau richtig! Es heißt <strong>${w.article} ${w.word}</strong> (${w.plural || "kein Plural"}).`
: `❌ Nicht ganz: Du hast „${chosenArticle}“ gewählt, aber es heißt <strong>${w.article} ${w.word}</strong>!`
};
this.cardRevealed = true;
this.saveState();
this.speak(`${w.article.replace(" (Pl.)", "")} ${w.word}`);
this.renderTrainerStage();
}
revealCardWithoutAnswer() {
if (!this.currentCard) return;
const w = this.currentCard;
this.cardRevealed = true;
this.lastAnswerStatus = null;
this.speak((w.article ? w.article.replace(" (Pl.)", "") + " " : "") + w.word);
this.renderTrainerStage();
}
gradeCurrentCard(grade) {
// grade: 1 (Again), 2 (Hard), 3 (Good), 4 (Easy)
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; // 10 minutes
w.srs.status = "learning";
} else if (grade === 2) {
w.srs.repetition += 1;
w.srs.interval = 1;
w.srs.dueDate = now + 1 * DAY_MS;
w.srs.status = "learning";
} else if (grade === 3) {
w.srs.repetition += 1;
w.srs.interval = w.srs.interval === 0 ? 3 : Math.round(w.srs.interval * w.srs.efactor);
w.srs.dueDate = now + w.srs.interval * DAY_MS;
w.srs.status = w.srs.interval >= 6 ? "mastered" : "learning";
} else if (grade === 4) {
w.srs.repetition += 1;
w.srs.efactor = Math.min(3.0, w.srs.efactor + 0.15);
w.srs.interval = w.srs.interval === 0 ? 7 : Math.round(w.srs.interval * w.srs.efactor * 1.3);
w.srs.dueDate = now + w.srs.interval * DAY_MS;
w.srs.status = "mastered";
}
this.saveState();
this.pickNextCard();
this.renderTrainerStage();
}
// === INTERACTIVE GRADED READER (LESEECKE) ===
renderReaderSidebar() {
const container = document.getElementById("story-list-container");
if (!container) return;
const stories = this.getAllStories();
container.innerHTML = stories
.map(
s => `
<div class="story-card-item ${s.id === this.activeStoryId ? "active" : ""}" onclick="app.selectStory('${s.id}')">
<div class="story-card-meta">
<span>${s.type}</span>
<span>${s.level} • ${s.readTime}</span>
</div>
<div class="story-card-title">${s.title}</div>
<div class="story-card-sub">${s.subtitle}</div>
</div>
`
)
.join("");
}
selectStory(storyId) {
this.activeStoryId = storyId;
this.selectedReaderWord = null;
this.renderReaderSidebar();
this.renderReaderMain();
}
toggleReaderTranslation() {
this.showReaderTranslation = !this.showReaderTranslation;
this.renderReaderMain();
}
readWholeStoryAloud(rate = 0.9) {
const story = this.getAllStories().find(s => s.id === this.activeStoryId);
if (!story) return;
const fullText = story.paragraphs.map(p => p.de).join(" ");
this.speak(fullText, rate);
}
tokenizeParagraphToInteractiveHtml(deText, story) {
// Split keeping punctuation and words separate
const tokens = deText.split(/([A-Za-zÄÖÜäöüß]+)/g);
return tokens
.map(token => {
if (!/^[A-Za-zÄÖÜäöüß]+$/.test(token)) {
return token;
}
const lookup = this.lookupWordInfo(token, story);
const isTarget = lookup && lookup.isTarget;
const inDeck = lookup && lookup.inDeck;
const classes = ["word-token"];
if (isTarget) classes.push("target-vocab");
else if (inDeck) classes.push("in-deck");
return `<span class="${classes.join(" ")}" onclick="app.inspectReaderWord('${this.escapeJs(token)}')">${token}</span>`;
})
.join("");
}
lookupWordInfo(rawToken, story) {
const clean = rawToken.trim();
const lower = clean.toLowerCase();
// 1. Check story's own glossary
if (story && story.glossary && story.glossary[clean]) {
const g = story.glossary[clean];
const existingDeck = this.vocab.find(v => v.word.toLowerCase() === g.lemma.toLowerCase());
return {
surface: clean,
lemma: g.lemma,
article: g.article,
plural: g.plural,
pos: g.pos,
english: g.english,
note: g.note,
isTarget: true,
inDeck: Boolean(existingDeck)
};
}
// 2. Check user's SRS vocabulary database (exact or stem match)
const deckMatch = this.vocab.find(
v =>
v.word.toLowerCase() === lower ||
(v.plural && v.plural.toLowerCase().endsWith(lower)) ||
(v.word.length >= 5 && lower.startsWith(v.word.toLowerCase().slice(0, 5)))
);
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[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. Smart Morphological Analyzer for any clicked German word
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: "Klicke zum Nachschlagen oder füge deine eigene Übersetzung hinzu",
note: morphNote,
isTarget: false,
inDeck: false
};
}
inspectReaderWord(rawToken) {
const story = this.getAllStories().find(s => s.id === this.activeStoryId);
this.selectedReaderWord = this.lookupWordInfo(rawToken, story);
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.startsWith("Klicke zum") ? "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, idx) => `
<div class="story-paragraph">
<div class="story-de-line">
${this.tokenizeParagraphToInteractiveHtml(p.de, story)}
<button class="icon-btn" style="width: 28px; height: 28px; font-size: 0.8rem; margin-left: 0.35rem; vertical-align: middle;" onclick="app.speak('${this.escapeJs(p.de)}')" title="Absatz anhören">🔊</button>
</div>
${
this.showReaderTranslation && p.en
? `<div class="story-en-line">${p.en}</div>`
: ""
}
</div>
`
)
.join("");
let inspectorHtml = "";
if (this.selectedReaderWord) {
const w = this.selectedReaderWord;
inspectorHtml = `
<div class="word-inspector-drawer">
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; flex-wrap: wrap;">
<div>
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
${this.getGenderBadgeHtml(w.article, w.pos)}
<strong style="font-size: 1.25rem;">${w.lemma}</strong>
${w.plural ? `<span style="font-size: 0.86rem; color: var(--text-secondary);">(${w.plural})</span>` : ""}
<button class="icon-btn" style="width: 30px; height: 30px;" onclick="app.speak('${this.escapeJs(
(w.article ? w.article.replace(" (Pl.)", "") + " " : "") + w.lemma
)}')">🔊</button>
</div>
<div style="font-size: 1rem; font-weight: 600; color: var(--text-main); margin-top: 0.25rem;">
${w.english}
</div>
${
w.note
? `<div style="font-size: 0.84rem; color: var(--text-secondary); margin-top: 0.25rem;">💡 ${w.note}</div>`
: ""
}
</div>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-primary" style="padding: 0.5rem 0.9rem; font-size: 0.84rem;" onclick="app.saveInspectedWordToDeck()">
${w.inDeck ? "⚡ Im SRS-Trainer priorisieren" : "➕ Ins SRS-Deck speichern"}
</button>
</div>
</div>
</div>
`;
} else {
inspectorHtml = `
<div class="word-inspector-drawer" style="text-align: center; color: var(--text-secondary); font-size: 0.9rem;">
👆 <strong>Tipp:</strong> Klicke oben im Text auf ein beliebiges Wort (besonders die <span style="background:#fef3c7;padding:0 0.3rem;border-bottom:2px solid var(--primary);font-weight:600;">gelb markierten Zielwörter</span>), um Artikel, Stammform und Aussprache zu sehen!
</div>
`;
}
let quizHtml = "";
if (story.comprehensionQuestion) {
const q = story.comprehensionQuestion;
const chosen = this.quizSelection[story.id];
quizHtml = `
<div style="margin-top: 1.75rem; padding-top: 1.5rem; border-top: 1px solid var(--border-color);">
<div class="prompt-instruction">🧠 Verständnis-Check zur Geschichte</div>
<div style="font-weight: 700; font-size: 1.02rem; margin-bottom: 0.75rem;">${q.question}</div>
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
${q.options
.map((opt, idx) => {
let bg = "var(--bg-main)";
let border = "var(--border-color)";
if (chosen !== undefined) {
if (idx === q.correctIndex) {
bg = "var(--success-bg)";
border = "#34d399";
} else if (idx === chosen) {
bg = "var(--danger-bg)";
border = "#fca5a5";
}
}
return `
<button
style="text-align: left; padding: 0.75rem 1rem; border-radius: 10px; border: 1px solid ${border}; background: ${bg}; cursor: pointer; font-family: inherit; font-size: 0.92rem; font-weight: 500;"
onclick="app.answerStoryQuiz('${story.id}', ${idx})"
>
${opt}
</button>
`;
})
.join("")}
</div>
${
chosen !== undefined
? `<div style="margin-top: 0.75rem; font-size: 0.88rem; color: var(--text-secondary);">
${chosen === q.correctIndex ? "✅ Richtig! " : "❌ Nicht ganz: "}${q.explanation}
</div>`
: ""
}
</div>
`;
}
container.innerHTML = `
<div class="reader-toolbar">
<div>
<span class="category-tag">${story.type} • ${story.level}</span>
<h2 style="font-size: 1.4rem; font-weight: 800; margin-top: 0.35rem;">${story.title}</h2>
<p style="font-size: 0.88rem; color: var(--text-secondary);">${story.subtitle}</p>
</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<button class="btn btn-secondary" style="padding: 0.45rem 0.85rem; font-size: 0.84rem;" onclick="app.readWholeStoryAloud(0.85)">
🐢 Langsam vorlesen (0.85x)
</button>
<button class="btn btn-secondary" style="padding: 0.45rem 0.85rem; font-size: 0.84rem;" onclick="app.readWholeStoryAloud(1.0)">
🔊 Normal (1.0x)
</button>
<button class="btn ${this.showReaderTranslation ? "btn-primary" : "btn-secondary"}" style="padding: 0.45rem 0.85rem; font-size: 0.84rem;" onclick="app.toggleReaderTranslation()">
🌐 Übersetzung ${this.showReaderTranslation ? "ausblenden" : "anzeigen"}
</button>
</div>
</div>
${paragraphsHtml}
${inspectorHtml}
${quizHtml}
`;
}
// === CONVERSATION & WRITING PRACTICE (SPRACHPRAXIS) ===
initScenarioStates() {
window.CONVERSATION_SCENARIOS.forEach(scen => {
if (!this.scenarioState[scen.id]) {
this.resetScenario(scen.id, false);
}
});
}
resetScenario(scenarioId, reRender = true) {
const scen = window.CONVERSATION_SCENARIOS.find(s => s.id === scenarioId);
if (!scen) return;
this.scenarioState[scenarioId] = {
turnIndex: 0,
usedWords: [],
completed: false,
messages: [
{
role: "partner",
de: scen.turns[0].partnerDe,
en: scen.turns[0].partnerEn
}
]
};
if (reRender) this.renderPracticeStage();
}
renderPracticeSidebar() {
const container = document.getElementById("scenario-list-container");
if (!container) return;
container.innerHTML = window.CONVERSATION_SCENARIOS.map(scen => {
const st = this.scenarioState[scen.id];
const usedCount = st ? st.usedWords.length : 0;
return `
<div class="story-card-item ${scen.id === this.activeScenarioId ? "active" : ""}" onclick="app.selectScenario('${scen.id}')">
<div class="story-card-meta">
<span>${scen.avatar} ${scen.rolePartner}</span>
<span>${usedCount}/${scen.targetWords.length} Zielwörter</span>
</div>
<div class="story-card-title">${scen.title}</div>
<div class="story-card-sub">Level ${scen.level}</div>
</div>
`;
}).join("");
}
selectScenario(scenarioId) {
this.activeScenarioId = scenarioId;
this.renderPracticeSidebar();
this.renderPracticeStage();
}
detectTargetWordsInText(text, scen) {
const lower = (text || "").toLowerCase();
const matched = [];
scen.targetWords.forEach(tw => {
const hit = tw.stems.some(stem => lower.includes(stem.toLowerCase()));
if (hit) matched.push(tw.word);
});
return matched;
}
updateLiveTargetWordHighlights(currentInputText) {
const scen = window.CONVERSATION_SCENARIOS.find(s => s.id === this.activeScenarioId);
if (!scen) return;
const st = this.scenarioState[scen.id];
const newlyTyped = this.detectTargetWordsInText(currentInputText, scen);
const combined = new Set([...st.usedWords, ...newlyTyped]);
document.querySelectorAll(".target-word-chip").forEach(chip => {
const w = chip.dataset.word;
const isUsed = combined.has(w);
chip.classList.toggle("used", isUsed);
const iconSpan = chip.querySelector(".chip-check");
if (iconSpan) iconSpan.textContent = isUsed ? "✅" : "⭕";
});
}
analyzeGermanSentence(userText, newlyUsedTargetWords, currentTurn) {
const tips = [];
const trimmed = userText.trim();
// Celebrate target vocabulary usage
if (newlyUsedTargetWords.length > 0) {
tips.push(`🎯 <strong>Zielwörter aktiv eingebaut:</strong> ${newlyUsedTargetWords.join(", ")}!`);
} else {
tips.push(`💡 <strong>Tipp:</strong> Versuche in deiner Antwort mindestens eines der oben gelisteten <em>Zielwörter</em> zu verwenden.`);
}
// Check subordinate clause connectors (weil, obwohl, dass)
const lower = trimmed.toLowerCase();
if (lower.includes("weil ") || lower.includes("obwohl ") || lower.includes("dass ")) {
tips.push(`🧱 <strong>Nebensatz-Struktur genutzt (weil/obwohl/dass):</strong> Super für B1-Niveau! Denk daran, dass das konjugierte Verb im Nebensatz ganz ans Ende wandert.`);
}
// Check V2 connectors (deshalb, trotzdem)
if (lower.includes("deshalb ") || lower.includes("trotzdem ")) {
const badOrder = /deshalb\s+ich\b|trotzdem\s+ich\b/i.test(trimmed);
if (badOrder) {
tips.push(`⚠️ <strong>Wortstellung nach „deshalb / trotzdem“:</strong> Im Deutschen steht das Verb direkt auf Position 2: <em>„Deshalb <u>möchte ich</u>...“</em> (nicht „Deshalb ich möchte“).`);
} else {
tips.push(`✨ <strong>Starker Konnektor (deshalb/trotzdem) mit richtiger Verb-Zweitstellung!</strong>`);
}
}
if (currentTurn && currentTurn.sampleResponse) {
tips.push(`🇩🇪 <strong>Muster-Formulierung zum Vergleich:</strong> <em>„${currentTurn.sampleResponse}“</em>`);
}
return tips.join("<br/>");
}
async handlePracticeSubmit(e) {
e.preventDefault();
const input = document.getElementById("practice-user-input");
if (!input) return;
const text = input.value.trim();
if (!text) return;
const scen = window.CONVERSATION_SCENARIOS.find(s => s.id === this.activeScenarioId);
const st = this.scenarioState[scen.id];
const currentTurn = scen.turns[st.turnIndex];
const matchedWords = this.detectTargetWordsInText(text, scen);
matchedWords.forEach(w => {
if (!st.usedWords.includes(w)) st.usedWords.push(w);
});
st.messages.push({
role: "user",
de: text
});
this.stats.sentencesWritten++;
input.value = "";
// Generate feedback (either via optional Live AI Key or instant built-in Smart Coach)
let feedbackHtml = this.analyzeGermanSentence(text, matchedWords, currentTurn);
if (this.settings.aiProvider !== "builtin" && this.settings.apiKey) {
const aiCoachNote = await this.fetchOptionalAiFeedback(text, scen, currentTurn);
if (aiCoachNote) {
feedbackHtml = `🤖 <strong>Live-KI-Feedback:</strong> ${aiCoachNote}<br/>` + feedbackHtml;
}
}
st.messages.push({
role: "feedback",
html: feedbackHtml
});
// Advance to next partner turn if available
if (st.turnIndex + 1 < scen.turns.length) {
st.turnIndex += 1;
const nextTurn = scen.turns[st.turnIndex];
st.messages.push({
role: "partner",
de: nextTurn.partnerDe,
en: nextTurn.partnerEn
});
this.speak(nextTurn.partnerDe);
} else {
st.completed = true;
st.messages.push({
role: "partner",
de: "Vielen Dank für das Gespräch! Du hast die Situation erfolgreich auf Deutsch gemeistert. 🎉",
en: "Thank you for the conversation! You successfully mastered the situation in German."
});
}
this.saveState();
this.renderPracticeSidebar();
this.renderPracticeStage();
}
async fetchOptionalAiFeedback(userGerman, scen, currentTurn) {
try {
const prompt = `Du bist ein freundlicher Deutschlehrer für Niveau A2/B1. Situation: ${scen.situation}. Gesprächspartner sagte: "${currentTurn?.partnerDe || ""}". Der Lernende antwortete: "${userGerman}". Gib in 1-2 kurzen Sätzen konstruktives Feedback zu Grammatik/Wortwahl und nenne bei Fehlern eine natürliche Korrektur.`;
if (this.settings.aiProvider === "gemini") {
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${encodeURIComponent(this.settings.apiKey)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }]
})
}
);
const data = await res.json();
return data?.candidates?.[0]?.content?.parts?.[0]?.text || null;
} else if (this.settings.aiProvider === "openai") {
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.settings.apiKey}`
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
max_tokens: 150
})
});
const data = await res.json();
return data?.choices?.[0]?.message?.content || null;
}
} catch (err) {
console.warn("Optional AI request failed, using built-in coach:", err);
}
return null;
}
insertSampleHint() {
const scen = window.CONVERSATION_SCENARIOS.find(s => s.id === this.activeScenarioId);
const st = this.scenarioState[scen.id];
const currentTurn = scen?.turns[st.turnIndex];
const input = document.getElementById("practice-user-input");
if (input && currentTurn) {
input.value = currentTurn.sampleResponse;
input.focus();
this.updateLiveTargetWordHighlights(input.value);
}
}
renderPracticeStage() {
const container = document.getElementById("practice-stage-area");
if (!container) return;
const scen = window.CONVERSATION_SCENARIOS.find(s => s.id === this.activeScenarioId);
if (!scen) return;
const st = this.scenarioState[scen.id];
const currentTurn = scen.turns[st.turnIndex];
const targetChipsHtml = scen.targetWords
.map(tw => {
const isUsed = st.usedWords.includes(tw.word);
return `
<div class="target-word-chip ${isUsed ? "used" : ""}" data-word="${tw.word}" title="${tw.hint}">
<span class="chip-check">${isUsed ? "✅" : "⭕"}</span>
<span>${tw.word}</span>
<span style="font-size: 0.75rem; font-weight: 400; color: var(--text-muted);">(${tw.hint})</span>
</div>
`;
})
.join("");
const messagesHtml = st.messages
.map(m => {
if (m.role === "partner") {
return `
<div class="chat-bubble chat-partner">
<div style="display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem;">
<strong style="font-size: 0.8rem; color: var(--text-muted);">${scen.avatar} ${scen.rolePartner}</strong>
<button class="icon-btn" style="width: 26px; height: 26px; font-size: 0.75rem;" onclick="app.speak('${this.escapeJs(m.de)}')">🔊</button>
</div>
<div>${m.de}</div>
${m.en ? `<div style="font-size: 0.82rem; color: var(--text-secondary); margin-top: 0.25rem;">${m.en}</div>` : ""}
</div>
`;
} else if (m.role === "user") {
return `
<div class="chat-bubble chat-user">
<div style="font-size: 0.76rem; opacity: 0.75; margin-bottom: 0.2rem;">Du (Aktiv produziert)</div>
<div>${m.de}</div>
</div>
`;
} else {
return `<div class="chat-feedback">${m.html}</div>`;
}
})
.join("");
container.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap;">
<div>
<span class="category-tag">Rollenspiel • ${scen.level}</span>
<h2 style="font-size: 1.3rem; font-weight: 800; margin-top: 0.25rem;">${scen.avatar} ${scen.title}</h2>
<p style="font-size: 0.88rem; color: var(--text-secondary); margin-top: 0.2rem;">${scen.situation}</p>
</div>
<button class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.82rem;" onclick="app.resetScenario('${scen.id}')">
🔄 Dialog neu starten
</button>
</div>
<div class="target-words-box">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 0.8rem; font-weight: 700; text-transform: uppercase; color: var(--text-secondary);">
🎯 Deine 5 Zielwörter für dieses Gespräch (${st.usedWords.length}/${scen.targetWords.length} genutzt)
</span>
</div>
<div class="target-chips-row">
${targetChipsHtml}
</div>
</div>
<div class="chat-stream" id="chat-stream-box">
${messagesHtml}
</div>
${
!st.completed
? `
<div style="background: var(--bg-main); padding: 1rem; border-radius: var(--radius-md); border: 1px solid var(--border-color);">
<div style="font-size: 0.84rem; color: var(--text-secondary); margin-bottom: 0.6rem;">
<strong>Aufgabe (Schritt ${st.turnIndex + 1}/${scen.turns.length}):</strong> ${currentTurn.promptHint}
</div>
<form onsubmit="app.handlePracticeSubmit(event)">
<div class="answer-input-row">
<input
type="text"
id="practice-user-input"
class="german-input"
placeholder="Schreibe deine deutsche Antwort (nutze die Zielwörter)..."
autocomplete="off"
oninput="app.updateLiveTargetWordHighlights(this.value)"
/>
<button type="submit" class="btn btn-primary">Senden ↵</button>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
${this.renderUmlautBar("practice-user-input")}
<button type="button" class="btn btn-secondary" style="padding: 0.3rem 0.7rem; font-size: 0.78rem;" onclick="app.insertSampleHint()">
💡 Starthilfe / Mustersatz einfügen
</button>
</div>
</form>
</div>
`
: `
<div style="text-align: center; padding: 1rem; background: var(--success-bg); border: 1px solid #6ee7b7; border-radius: var(--radius-md);">
<strong>🎉 Szenario abgeschlossen!</strong> Du hast ${st.usedWords.length} von ${scen.targetWords.length} Zielwörtern aktiv im Kontext verwendet.
<div style="margin-top: 0.65rem;">
<button class="btn btn-primary" onclick="app.resetScenario('${scen.id}')">Nochmal üben</button>
</div>
</div>
`
}
`;
const chatBox = document.getElementById("chat-stream-box");
if (chatBox) {
chatBox.scrollTop = chatBox.scrollHeight;
}
}
// === VOCABULARY BANK (WÖRTERBUCH) ===
renderDictionary() {
const grid = document.getElementById("dictionary-grid");
if (!grid) return;
const query = (document.getElementById("dict-search-input")?.value || "").trim().toLowerCase();
const genderFilter = document.getElementById("dict-gender-filter")?.value || "all";
const categoryFilter = document.getElementById("dict-category-filter")?.value || "all";
const filtered = this.vocab.filter(w => {
if (query) {
const hay = `${w.word} ${w.english} ${w.exampleDe} ${w.category}`.toLowerCase();
if (!hay.includes(query)) return false;
}
if (genderFilter !== "all") {
if (genderFilter === "verb") {
if (w.article) return false;
} else if (!w.article || !w.article.startsWith(genderFilter)) {
return false;
}
}
if (categoryFilter !== "all" && w.category !== categoryFilter) {
return false;
}
return true;
});
grid.innerHTML = filtered
.map(
w => `
<div class="vocab-item-card">
<div>
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem;">
<div style="display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap;">
${this.getGenderBadgeHtml(w.article, w.pos)}
<strong style="font-size: 1.08rem;">${w.word}</strong>
</div>
<button class="icon-btn" style="width: 30px; height: 30px; font-size: 0.82rem;" onclick="app.speak('${this.escapeJs(
(w.article ? w.article.replace(" (Pl.)", "") + " " : "") + w.word + ". " + w.exampleDe
)}')">🔊</button>
</div>
${w.plural ? `<div style="font-size: 0.78rem; color: var(--text-muted); margin-top: 0.1rem;">Plural: ${w.plural}</div>` : ""}
<div style="font-weight: 600; color: var(--text-main); margin: 0.45rem 0 0.35rem;">${w.english}</div>
<div style="font-size: 0.85rem; color: var(--text-secondary); background: var(--bg-main); padding: 0.5rem 0.65rem; border-radius: 8px;">
„${w.exampleDe}“
</div>
${w.breakdown ? `<div style="font-size: 0.78rem; color: var(--text-muted); margin-top: 0.45rem;">🧱 ${w.breakdown}</div>` : ""}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding-top: 0.5rem; border-top: 1px solid var(--border-color); font-size: 0.75rem; color: var(--text-muted);">
<span>${w.category}</span>
<span>SRS: <strong>${w.srs.status.toUpperCase()}</strong> (${w.srs.reviews}x)</span>
</div>
</div>
`
)
.join("");
}
// === MODALS & CUSTOM CONTENT ===
openModal(id) {
const el = document.getElementById(id);
if (el) el.classList.add("open");
if (id === "settings-modal") {
document.getElementById("settings-ai-provider").value = this.settings.aiProvider || "builtin";
document.getElementById("settings-api-key").value = this.settings.apiKey || "";
}
}
closeModal(id) {
const el = document.getElementById(id);
if (el) el.classList.remove("open");
}
handleAddCustomWord(e) {
e.preventDefault();
const article = document.getElementById("custom-article").value || null;
const word = document.getElementById("custom-word").value.trim();
const english = document.getElementById("custom-english").value.trim();
const plural = document.getElementById("custom-plural").value.trim() || null;
const exampleDe = document.getElementById("custom-example-de").value.trim();
const exampleEn = document.getElementById("custom-example-en").value.trim();
const tip = document.getElementById("custom-tip").value.trim();
if (!word || !english || !exampleDe) return;
const newWord = this.createVocabEntry({
id: "user_" + Date.now(),
word,
article,
plural,
pos: article ? "noun" : "verb / expression",
level: "A2/B1",
category: "Saved from Reading",
english,
breakdown: tip || null,
forms: plural,
exampleDe,
exampleEn,
clozeSentence: exampleDe.replace(new RegExp(word, "i"), "___"),
clozeAnswer: word,
usageTip: tip || null
});
this.vocab.unshift(newWord);
this.saveState();
this.closeModal("add-word-modal");
e.target.reset();
this.showToast(`✅ „${word}“ wurde zu deinem Wortschatz-Trainer hinzugefügt!`);
if (this.currentView === "dictionary") this.renderDictionary();
}
handleImportCustomStory(e) {
e.preventDefault();
const title = document.getElementById("import-story-title").value.trim();
const body = document.getElementById("import-story-body").value.trim();
if (!title || !body) return;
const rawParagraphs = body
.split(/\n\s*\n/)
.map(p => p.trim())
.filter(Boolean);
const newStory = {
id: "custom_story_" + Date.now(),
title,
subtitle: "Eigener importierter Text (Jedes Wort ist klickbar)",
level: "A2 / B1",
type: "Eigener Text",
readTime: "3 min",
targetVocabIds: [],
paragraphs: rawParagraphs.map(p => ({ de: p, en: "" })),
glossary: {}
};
this.customStories.unshift(newStory);
this.activeStoryId = newStory.id;
this.saveState();
this.closeModal("import-text-modal");
e.target.reset();
this.switchView("reader");
this.showToast(`📄 „${title}“ erfolgreich in der Leseecke geöffnet!`);
}
saveSettings() {
this.settings.aiProvider = document.getElementById("settings-ai-provider").value;
this.settings.apiKey = document.getElementById("settings-api-key").value.trim();
this.saveState();
this.closeModal("settings-modal");
this.showToast("⚙️ Einstellungen gespeichert!");
}
exportProgress() {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(this.vocab, null, 2));
const dlAnchor = document.createElement("a");
dlAnchor.setAttribute("href", dataStr);
dlAnchor.setAttribute("download", "wortkraft_wortschatz_backup.json");
document.body.appendChild(dlAnchor);
dlAnchor.click();
dlAnchor.remove();
this.showToast("💾 Backup heruntergeladen!");
}
resetProgress() {
if (!confirm("Möchtest du deinen Lernfortschritt wirklich auf den Anfang zurücksetzen?")) return;
localStorage.removeItem(STORAGE_KEY);
window.location.reload();
}
showToast(message) {
const toast = document.getElementById("toast");
if (!toast) return;
toast.textContent = message;
toast.classList.add("show");
clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => {
toast.classList.remove("show");
}, 3200);
}
escapeJs(str) {
return (str || "")
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/"/g, "&quot;")
.replace(/\n/g, " ");
}
renderAll() {
this.updateDashboardCounters();
this.renderDailySpotlight();
}
}
window.addEventListener("DOMContentLoaded", () => {
window.app = new WortKraftApp();
});