1805 lines
66 KiB
JavaScript
1805 lines
66 KiB
JavaScript
// 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">< 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, """)
|
||
.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();
|
||
}
|