Fix static data volume mount and add global hover-to-translate mode
This commit is contained in:
1 parent
f35864a03e
commit
ffe25e782a
7 files changed
+569
-80
No files matched your search
@@ -11,18 +11,25 @@ class WortKraftApp {
|
||||
this.cardRevealed = false;
|
||||
this.lastAnswerStatus = null; // { isCorrect, message, userText }
|
||||
|
||||
this.activeStoryId = window.GRADED_STORIES[0].id;
|
||||
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 = window.CONVERSATION_SCENARIOS[0].id;
|
||||
this.scenarioState = {}; // { [scenarioId]: { turnIndex, messages, usedTargetWords } }
|
||||
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();
|
||||
@@ -38,7 +45,7 @@ class WortKraftApp {
|
||||
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 => {
|
||||
(window.INITIAL_VOCABULARY || []).forEach(item => {
|
||||
if (!existingIds.has(item.id)) {
|
||||
this.vocab.push(this.createVocabEntry(item));
|
||||
}
|
||||
@@ -46,15 +53,18 @@ class WortKraftApp {
|
||||
}
|
||||
if (Array.isArray(parsed.customStories)) this.customStories = parsed.customStories;
|
||||
if (parsed.stats) this.stats = parsed.stats;
|
||||
if (parsed.settings) this.settings = parsed.settings;
|
||||
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 {
|
||||
// First run on server: push current default state to server
|
||||
this.pushStateToServer();
|
||||
}
|
||||
} catch (_e) {
|
||||
// Offline or opened as local file:// — localStorage handles persistence
|
||||
// Offline or local file:// mode — localStorage handles persistence
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,9 +91,8 @@ class WortKraftApp {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
this.vocab = parsed.vocab || [];
|
||||
// Merge any new default words if vocabulary.js was updated
|
||||
const existingIds = new Set(this.vocab.map(v => v.id));
|
||||
window.INITIAL_VOCABULARY.forEach(item => {
|
||||
(window.INITIAL_VOCABULARY || []).forEach(item => {
|
||||
if (!existingIds.has(item.id)) {
|
||||
this.vocab.push(this.createVocabEntry(item));
|
||||
}
|
||||
@@ -95,9 +104,11 @@ class WortKraftApp {
|
||||
correctChecks: 0,
|
||||
sentencesWritten: 0
|
||||
};
|
||||
this.settings = parsed.settings || {
|
||||
this.settings = {
|
||||
aiProvider: "builtin",
|
||||
apiKey: ""
|
||||
apiKey: "",
|
||||
hoverTranslateEnabled: true,
|
||||
...(parsed.settings || {})
|
||||
};
|
||||
return;
|
||||
} catch (e) {
|
||||
@@ -105,12 +116,9 @@ class WortKraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize default state
|
||||
const now = Date.now();
|
||||
this.vocab = window.INITIAL_VOCABULARY.map((item, idx) => {
|
||||
this.vocab = (window.INITIAL_VOCABULARY || []).map((item, idx) => {
|
||||
const entry = this.createVocabEntry(item);
|
||||
// Make first 15 words immediately due today so user has a focused starting queue,
|
||||
// and the rest available as new/due as well
|
||||
entry.srs.dueDate = idx < 18 ? now - 1000 : now;
|
||||
return entry;
|
||||
});
|
||||
@@ -124,7 +132,8 @@ class WortKraftApp {
|
||||
};
|
||||
this.settings = {
|
||||
aiProvider: "builtin",
|
||||
apiKey: ""
|
||||
apiKey: "",
|
||||
hoverTranslateEnabled: true
|
||||
};
|
||||
this.saveState();
|
||||
}
|
||||
@@ -137,7 +146,7 @@ class WortKraftApp {
|
||||
repetition: 0,
|
||||
efactor: 2.5,
|
||||
dueDate: Date.now(),
|
||||
status: "new", // 'new' | 'learning' | 'mastered'
|
||||
status: "new",
|
||||
reviews: 0
|
||||
}
|
||||
};
|
||||
@@ -158,7 +167,124 @@ class WortKraftApp {
|
||||
}
|
||||
|
||||
getAllStories() {
|
||||
return [...this.customStories, ...window.GRADED_STORIES];
|
||||
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) ===
|
||||
@@ -198,6 +324,7 @@ class WortKraftApp {
|
||||
// === NAVIGATION ===
|
||||
switchView(viewName) {
|
||||
this.currentView = viewName;
|
||||
this.hideHoverTooltip();
|
||||
document.querySelectorAll(".view-section").forEach(sec => {
|
||||
sec.classList.toggle("active", sec.id === `view-${viewName}`);
|
||||
});
|
||||
@@ -207,6 +334,7 @@ class WortKraftApp {
|
||||
|
||||
if (viewName === "dashboard") {
|
||||
this.updateDashboardCounters();
|
||||
this.renderDailySpotlight();
|
||||
} else if (viewName === "trainer") {
|
||||
if (!this.currentCard) this.pickNextCard();
|
||||
this.renderTrainerStage();
|
||||
@@ -282,8 +410,8 @@ class WortKraftApp {
|
||||
<strong style="font-size: 1rem;">${w.word}</strong>
|
||||
<span style="font-size: 0.84rem; color: var(--text-secondary);">— ${w.english}</span>
|
||||
</div>
|
||||
<div style="font-size: 0.83rem; color: var(--text-muted); margin-top: 0.3rem;">
|
||||
„${w.exampleDe}“
|
||||
<div 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(
|
||||
@@ -319,7 +447,6 @@ class WortKraftApp {
|
||||
getFilteredTrainerPool() {
|
||||
let pool = [...this.vocab];
|
||||
|
||||
// If in gender mode, only include nouns with articles
|
||||
if (this.trainerMode === "gender") {
|
||||
pool = pool.filter(w => Boolean(w.article));
|
||||
}
|
||||
@@ -328,7 +455,7 @@ class WortKraftApp {
|
||||
const now = Date.now();
|
||||
const due = pool.filter(w => w.srs.dueDate <= now);
|
||||
if (due.length > 0) return due;
|
||||
return pool; // fallback if all due cards finished
|
||||
return pool;
|
||||
} else if (this.trainerFilter !== "all") {
|
||||
const catPool = pool.filter(w => w.category === this.trainerFilter);
|
||||
if (catPool.length > 0) return catPool;
|
||||
@@ -342,7 +469,6 @@ class WortKraftApp {
|
||||
this.currentCard = null;
|
||||
return;
|
||||
}
|
||||
// Sort by due date and pick among top 5 so it feels fresh
|
||||
const sorted = [...pool].sort((a, b) => a.srs.dueDate - b.srs.dueDate);
|
||||
const candidates = sorted.slice(0, Math.min(5, sorted.length));
|
||||
const next = candidates.find(c => !this.currentCard || c.id !== this.currentCard.id) || candidates[0];
|
||||
@@ -374,12 +500,17 @@ class WortKraftApp {
|
||||
let promptHtml = "";
|
||||
|
||||
if (this.trainerMode === "cloze") {
|
||||
const clozeText = w.clozeSentence || w.exampleDe.replace(new RegExp(w.word, "i"), "___");
|
||||
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">${clozeText.replace(/___/g, '<span style="color:var(--primary);border-bottom:2px solid var(--primary);padding:0 0.4rem;">______</span>')}</div>
|
||||
<div class="cloze-de">${clozeInteractiveHtml}</div>
|
||||
<div class="cloze-en">${w.exampleEn}</div>
|
||||
</div>
|
||||
${
|
||||
@@ -441,7 +572,6 @@ class WortKraftApp {
|
||||
}
|
||||
`;
|
||||
} else {
|
||||
// Flashcard mode
|
||||
promptHtml = `
|
||||
<div class="prompt-instruction">🃏 Karteikarte & Wortbau • Überprüfe Artikel, Aussprache & Satz</div>
|
||||
<div class="prompt-main">${w.english}</div>
|
||||
@@ -492,7 +622,7 @@ class WortKraftApp {
|
||||
</div>
|
||||
|
||||
<div class="cloze-box" style="background: rgba(255,255,255,0.85); margin-bottom: 0.85rem;">
|
||||
<div class="cloze-de">„${w.exampleDe}“</div>
|
||||
<div class="cloze-de">„${this.tokenizeSentenceForHover(w.exampleDe)}“</div>
|
||||
<div class="cloze-en">${w.exampleEn}</div>
|
||||
</div>
|
||||
|
||||
@@ -562,7 +692,7 @@ class WortKraftApp {
|
||||
`;
|
||||
|
||||
const inputEl = document.getElementById("trainer-answer-input");
|
||||
if (inputEl && !this.cardRevealed) {
|
||||
if (inputEl && !this.cardRevealed && typeof inputEl.focus === "function") {
|
||||
setTimeout(() => inputEl.focus(), 30);
|
||||
}
|
||||
}
|
||||
@@ -644,7 +774,6 @@ class WortKraftApp {
|
||||
: `✅ Richtig (${w.article} ${w.word})! Denk daran, Nomen im Deutschen großzuschreiben.`
|
||||
};
|
||||
} else if (w.article && userNorm.endsWith(expectedWordOnly)) {
|
||||
// Right noun, wrong or missing article!
|
||||
this.lastAnswerStatus = {
|
||||
isCorrect: false,
|
||||
message: `⚠️ Das Nomen „${w.word}“ stimmt, aber der Artikel ist <strong>${w.article} ${w.word}</strong>! (${w.breakdown || "Merk dir Wort + Artikel immer als Einheit."})`
|
||||
@@ -694,7 +823,6 @@ class WortKraftApp {
|
||||
}
|
||||
|
||||
gradeCurrentCard(grade) {
|
||||
// grade: 1 (Again), 2 (Hard), 3 (Good), 4 (Easy)
|
||||
if (!this.currentCard) return;
|
||||
const w = this.currentCard;
|
||||
const now = Date.now();
|
||||
@@ -705,7 +833,7 @@ class WortKraftApp {
|
||||
if (grade === 1) {
|
||||
w.srs.repetition = 0;
|
||||
w.srs.interval = 0;
|
||||
w.srs.dueDate = now + 10 * 60 * 1000; // 10 minutes
|
||||
w.srs.dueDate = now + 10 * 60 * 1000;
|
||||
w.srs.status = "learning";
|
||||
} else if (grade === 2) {
|
||||
w.srs.repetition += 1;
|
||||
@@ -771,8 +899,20 @@ class WortKraftApp {
|
||||
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) {
|
||||
// Split keeping punctuation and words separate
|
||||
const tokens = deText.split(/([A-Za-zÄÖÜäöüß]+)/g);
|
||||
return tokens
|
||||
.map(token => {
|
||||
@@ -786,7 +926,7 @@ class WortKraftApp {
|
||||
if (isTarget) classes.push("target-vocab");
|
||||
else if (inDeck) classes.push("in-deck");
|
||||
|
||||
return `<span class="${classes.join(" ")}" onclick="app.inspectReaderWord('${this.escapeJs(token)}')">${token}</span>`;
|
||||
return `<span class="${classes.join(" ")}" data-word="${this.escapeJs(token)}" onclick="app.inspectReaderWord('${this.escapeJs(token)}')">${token}</span>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -812,12 +952,11 @@ class WortKraftApp {
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Check user's SRS vocabulary database (exact or stem match)
|
||||
// 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)) ||
|
||||
(v.word.length >= 5 && lower.startsWith(v.word.toLowerCase().slice(0, 5)))
|
||||
(v.plural && v.plural.toLowerCase().endsWith(" " + lower))
|
||||
);
|
||||
if (deckMatch) {
|
||||
return {
|
||||
@@ -834,7 +973,7 @@ class WortKraftApp {
|
||||
}
|
||||
|
||||
// 3. Check other stories' glossaries
|
||||
for (const st of window.GRADED_STORIES) {
|
||||
for (const st of window.GRADED_STORIES || []) {
|
||||
if (st.glossary && st.glossary[clean]) {
|
||||
const g = st.glossary[clean];
|
||||
return {
|
||||
@@ -852,7 +991,7 @@ class WortKraftApp {
|
||||
}
|
||||
|
||||
// 4. Check common German dictionary
|
||||
if (window.COMMON_GERMAN_DICT[lower]) {
|
||||
if (window.COMMON_GERMAN_DICT && window.COMMON_GERMAN_DICT[lower]) {
|
||||
const c = window.COMMON_GERMAN_DICT[lower];
|
||||
return {
|
||||
surface: clean,
|
||||
@@ -867,7 +1006,28 @@ class WortKraftApp {
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Smart Morphological Analyzer for any clicked German word
|
||||
// 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.";
|
||||
@@ -888,16 +1048,26 @@ class WortKraftApp {
|
||||
article: guessedArticle,
|
||||
plural: null,
|
||||
pos: isCapitalized ? "noun" : "word",
|
||||
english: "Klicke zum Nachschlagen oder füge deine eigene Übersetzung hinzu",
|
||||
english: "Übersetze...",
|
||||
note: morphNote,
|
||||
isTarget: false,
|
||||
inDeck: false
|
||||
inDeck: false,
|
||||
needsLiveTranslate: true
|
||||
};
|
||||
}
|
||||
|
||||
inspectReaderWord(rawToken) {
|
||||
const story = this.getAllStories().find(s => s.id === this.activeStoryId);
|
||||
this.selectedReaderWord = this.lookupWordInfo(rawToken, story);
|
||||
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();
|
||||
}
|
||||
@@ -926,7 +1096,7 @@ class WortKraftApp {
|
||||
pos: info.pos || "word",
|
||||
level: "A2/B1",
|
||||
category: "Saved from Reading",
|
||||
english: info.english.startsWith("Klicke zum") ? "Aus Leseecke gespeichert" : info.english,
|
||||
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.`,
|
||||
@@ -959,7 +1129,7 @@ class WortKraftApp {
|
||||
|
||||
const paragraphsHtml = story.paragraphs
|
||||
.map(
|
||||
(p, idx) => `
|
||||
p => `
|
||||
<div class="story-paragraph">
|
||||
<div class="story-de-line">
|
||||
${this.tokenizeParagraphToInteractiveHtml(p.de, story)}
|
||||
@@ -1010,7 +1180,7 @@ class WortKraftApp {
|
||||
} else {
|
||||
inspectorHtml = `
|
||||
<div class="word-inspector-drawer" style="text-align: center; color: var(--text-secondary); font-size: 0.9rem;">
|
||||
👆 <strong>Tipp:</strong> Klicke oben im Text auf ein beliebiges Wort (besonders die <span style="background:#fef3c7;padding:0 0.3rem;border-bottom:2px solid var(--primary);font-weight:600;">gelb markierten Zielwörter</span>), um Artikel, Stammform und Aussprache zu sehen!
|
||||
👆 <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>
|
||||
`;
|
||||
}
|
||||
@@ -1022,7 +1192,7 @@ class WortKraftApp {
|
||||
quizHtml = `
|
||||
<div style="margin-top: 1.75rem; padding-top: 1.5rem; border-top: 1px solid var(--border-color);">
|
||||
<div class="prompt-instruction">🧠 Verständnis-Check zur Geschichte</div>
|
||||
<div style="font-weight: 700; font-size: 1.02rem; margin-bottom: 0.75rem;">${q.question}</div>
|
||||
<div style="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) => {
|
||||
@@ -1067,14 +1237,17 @@ class WortKraftApp {
|
||||
<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 vorlesen (0.85x)
|
||||
🐢 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()">
|
||||
🌐 Übersetzung ${this.showReaderTranslation ? "ausblenden" : "anzeigen"}
|
||||
🌐 Satz-Übersetzung ${this.showReaderTranslation ? "aus" : "an"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1087,7 +1260,7 @@ class WortKraftApp {
|
||||
|
||||
// === CONVERSATION & WRITING PRACTICE (SPRACHPRAXIS) ===
|
||||
initScenarioStates() {
|
||||
window.CONVERSATION_SCENARIOS.forEach(scen => {
|
||||
(window.CONVERSATION_SCENARIOS || []).forEach(scen => {
|
||||
if (!this.scenarioState[scen.id]) {
|
||||
this.resetScenario(scen.id, false);
|
||||
}
|
||||
@@ -1095,7 +1268,7 @@ class WortKraftApp {
|
||||
}
|
||||
|
||||
resetScenario(scenarioId, reRender = true) {
|
||||
const scen = window.CONVERSATION_SCENARIOS.find(s => s.id === scenarioId);
|
||||
const scen = (window.CONVERSATION_SCENARIOS || []).find(s => s.id === scenarioId);
|
||||
if (!scen) return;
|
||||
this.scenarioState[scenarioId] = {
|
||||
turnIndex: 0,
|
||||
@@ -1116,7 +1289,7 @@ class WortKraftApp {
|
||||
const container = document.getElementById("scenario-list-container");
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = window.CONVERSATION_SCENARIOS.map(scen => {
|
||||
container.innerHTML = (window.CONVERSATION_SCENARIOS || []).map(scen => {
|
||||
const st = this.scenarioState[scen.id];
|
||||
const usedCount = st ? st.usedWords.length : 0;
|
||||
return `
|
||||
@@ -1149,7 +1322,7 @@ class WortKraftApp {
|
||||
}
|
||||
|
||||
updateLiveTargetWordHighlights(currentInputText) {
|
||||
const scen = window.CONVERSATION_SCENARIOS.find(s => s.id === this.activeScenarioId);
|
||||
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);
|
||||
@@ -1168,20 +1341,17 @@ class WortKraftApp {
|
||||
const tips = [];
|
||||
const trimmed = userText.trim();
|
||||
|
||||
// Celebrate target vocabulary usage
|
||||
if (newlyUsedTargetWords.length > 0) {
|
||||
tips.push(`🎯 <strong>Zielwörter aktiv eingebaut:</strong> ${newlyUsedTargetWords.join(", ")}!`);
|
||||
} else {
|
||||
tips.push(`💡 <strong>Tipp:</strong> Versuche in deiner Antwort mindestens eines der oben gelisteten <em>Zielwörter</em> zu verwenden.`);
|
||||
}
|
||||
|
||||
// Check subordinate clause connectors (weil, obwohl, dass)
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.includes("weil ") || lower.includes("obwohl ") || lower.includes("dass ")) {
|
||||
tips.push(`🧱 <strong>Nebensatz-Struktur genutzt (weil/obwohl/dass):</strong> Super für B1-Niveau! Denk daran, dass das konjugierte Verb im Nebensatz ganz ans Ende wandert.`);
|
||||
}
|
||||
|
||||
// Check V2 connectors (deshalb, trotzdem)
|
||||
if (lower.includes("deshalb ") || lower.includes("trotzdem ")) {
|
||||
const badOrder = /deshalb\s+ich\b|trotzdem\s+ich\b/i.test(trimmed);
|
||||
if (badOrder) {
|
||||
@@ -1192,7 +1362,7 @@ class WortKraftApp {
|
||||
}
|
||||
|
||||
if (currentTurn && currentTurn.sampleResponse) {
|
||||
tips.push(`🇩🇪 <strong>Muster-Formulierung zum Vergleich:</strong> <em>„${currentTurn.sampleResponse}“</em>`);
|
||||
tips.push(`🇩🇪 <strong>Muster-Formulierung zum Vergleich:</strong> <em>„${this.tokenizeSentenceForHover(currentTurn.sampleResponse)}“</em>`);
|
||||
}
|
||||
|
||||
return tips.join("<br/>");
|
||||
@@ -1205,7 +1375,7 @@ class WortKraftApp {
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
const scen = window.CONVERSATION_SCENARIOS.find(s => s.id === this.activeScenarioId);
|
||||
const scen = (window.CONVERSATION_SCENARIOS || []).find(s => s.id === this.activeScenarioId);
|
||||
const st = this.scenarioState[scen.id];
|
||||
const currentTurn = scen.turns[st.turnIndex];
|
||||
|
||||
@@ -1222,7 +1392,6 @@ class WortKraftApp {
|
||||
this.stats.sentencesWritten++;
|
||||
input.value = "";
|
||||
|
||||
// Generate feedback (either via optional Live AI Key or instant built-in Smart Coach)
|
||||
let feedbackHtml = this.analyzeGermanSentence(text, matchedWords, currentTurn);
|
||||
if (this.settings.aiProvider !== "builtin" && this.settings.apiKey) {
|
||||
const aiCoachNote = await this.fetchOptionalAiFeedback(text, scen, currentTurn);
|
||||
@@ -1236,7 +1405,6 @@ class WortKraftApp {
|
||||
html: feedbackHtml
|
||||
});
|
||||
|
||||
// Advance to next partner turn if available
|
||||
if (st.turnIndex + 1 < scen.turns.length) {
|
||||
st.turnIndex += 1;
|
||||
const nextTurn = scen.turns[st.turnIndex];
|
||||
@@ -1299,7 +1467,7 @@ class WortKraftApp {
|
||||
}
|
||||
|
||||
insertSampleHint() {
|
||||
const scen = window.CONVERSATION_SCENARIOS.find(s => s.id === this.activeScenarioId);
|
||||
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");
|
||||
@@ -1314,7 +1482,7 @@ class WortKraftApp {
|
||||
const container = document.getElementById("practice-stage-area");
|
||||
if (!container) return;
|
||||
|
||||
const scen = window.CONVERSATION_SCENARIOS.find(s => s.id === this.activeScenarioId);
|
||||
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];
|
||||
@@ -1341,7 +1509,7 @@ class WortKraftApp {
|
||||
<strong style="font-size: 0.8rem; color: var(--text-muted);">${scen.avatar} ${scen.rolePartner}</strong>
|
||||
<button class="icon-btn" style="width: 26px; height: 26px; font-size: 0.75rem;" onclick="app.speak('${this.escapeJs(m.de)}')">🔊</button>
|
||||
</div>
|
||||
<div>${m.de}</div>
|
||||
<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>
|
||||
`;
|
||||
@@ -1474,7 +1642,7 @@ class WortKraftApp {
|
||||
${w.plural ? `<div style="font-size: 0.78rem; color: var(--text-muted); margin-top: 0.1rem;">Plural: ${w.plural}</div>` : ""}
|
||||
<div style="font-weight: 600; color: var(--text-main); margin: 0.45rem 0 0.35rem;">${w.english}</div>
|
||||
<div style="font-size: 0.85rem; color: var(--text-secondary); background: var(--bg-main); padding: 0.5rem 0.65rem; border-radius: 8px;">
|
||||
„${w.exampleDe}“
|
||||
„${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>
|
||||
@@ -1555,7 +1723,7 @@ class WortKraftApp {
|
||||
const newStory = {
|
||||
id: "custom_story_" + Date.now(),
|
||||
title,
|
||||
subtitle: "Eigener importierter Text (Jedes Wort ist klickbar)",
|
||||
subtitle: "Eigener importierter Text (Jedes Wort ist klickbar & hoverbar)",
|
||||
level: "A2 / B1",
|
||||
type: "Eigener Text",
|
||||
readTime: "3 min",
|
||||
@@ -1623,6 +1791,14 @@ class WortKraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
window.app = new WortKraftApp();
|
||||
});
|
||||
function initWortKraft() {
|
||||
if (!window.app) {
|
||||
window.app = new WortKraftApp();
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
window.addEventListener("DOMContentLoaded", initWortKraft);
|
||||
} else {
|
||||
initWortKraft();
|
||||
}
|
||||
Reference in new issue
Block a user