Files
wortkraft/app.js
T

1805 lines
66 KiB
JavaScript
Raw Blame History

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