Fix static data volume mount and add global hover-to-translate mode

This commit is contained in:
chrisf committed 2026-10-21 04:32:47 +00:00
1 parent f35864a03e
commit ffe25e782a
7 files changed
+569 -80

No files matched your search

+2 -2
View File
@@ -4,7 +4,7 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=80 ENV PORT=80
ENV DATA_DIR=/app/data ENV DATA_DIR=/app/store
COPY package*.json ./ COPY package*.json ./
RUN npm install --omit=dev RUN npm install --omit=dev
@@ -13,7 +13,7 @@ COPY index.html styles.css app.js ./
COPY data/ ./data/ COPY data/ ./data/
COPY server/ ./server/ COPY server/ ./server/
RUN mkdir -p /app/data RUN mkdir -p /app/store
EXPOSE 80 EXPOSE 80
+243 -67
View File
@@ -11,18 +11,25 @@ class WortKraftApp {
this.cardRevealed = false; this.cardRevealed = false;
this.lastAnswerStatus = null; // { isCorrect, message, userText } 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.showReaderTranslation = false;
this.selectedReaderWord = null; this.selectedReaderWord = null;
this.quizSelection = {}; this.quizSelection = {};
this.activeScenarioId = window.CONVERSATION_SCENARIOS[0].id; this.activeScenarioId = scenarios[0]?.id || "";
this.scenarioState = {}; // { [scenarioId]: { turnIndex, messages, usedTargetWords } } this.scenarioState = {};
this.spotlightWords = []; this.spotlightWords = [];
this.pendingTranslations = new Set();
this.loadState(); this.loadState();
this.hoverTranslateEnabled = this.settings.hoverTranslateEnabled !== false;
this.initScenarioStates(); this.initScenarioStates();
this.setupHoverListeners();
this.updateHoverToggleUI();
this.refreshDailySpotlight(); this.refreshDailySpotlight();
this.renderAll(); this.renderAll();
this.syncFromServer(); this.syncFromServer();
@@ -38,7 +45,7 @@ class WortKraftApp {
if (Array.isArray(parsed.vocab) && parsed.vocab.length > 0) { if (Array.isArray(parsed.vocab) && parsed.vocab.length > 0) {
this.vocab = parsed.vocab; this.vocab = parsed.vocab;
const existingIds = new Set(this.vocab.map(v => v.id)); 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)) { if (!existingIds.has(item.id)) {
this.vocab.push(this.createVocabEntry(item)); this.vocab.push(this.createVocabEntry(item));
} }
@@ -46,15 +53,18 @@ class WortKraftApp {
} }
if (Array.isArray(parsed.customStories)) this.customStories = parsed.customStories; if (Array.isArray(parsed.customStories)) this.customStories = parsed.customStories;
if (parsed.stats) this.stats = parsed.stats; 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)); localStorage.setItem(STORAGE_KEY, JSON.stringify(parsed));
this.renderAll(); this.renderAll();
} else { } else {
// First run on server: push current default state to server
this.pushStateToServer(); this.pushStateToServer();
} }
} catch (_e) { } 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 { try {
const parsed = JSON.parse(saved); const parsed = JSON.parse(saved);
this.vocab = parsed.vocab || []; this.vocab = parsed.vocab || [];
// Merge any new default words if vocabulary.js was updated
const existingIds = new Set(this.vocab.map(v => v.id)); 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)) { if (!existingIds.has(item.id)) {
this.vocab.push(this.createVocabEntry(item)); this.vocab.push(this.createVocabEntry(item));
} }
@@ -95,9 +104,11 @@ class WortKraftApp {
correctChecks: 0, correctChecks: 0,
sentencesWritten: 0 sentencesWritten: 0
}; };
this.settings = parsed.settings || { this.settings = {
aiProvider: "builtin", aiProvider: "builtin",
apiKey: "" apiKey: "",
hoverTranslateEnabled: true,
...(parsed.settings || {})
}; };
return; return;
} catch (e) { } catch (e) {
@@ -105,12 +116,9 @@ class WortKraftApp {
} }
} }
// Initialize default state
const now = Date.now(); 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); 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; entry.srs.dueDate = idx < 18 ? now - 1000 : now;
return entry; return entry;
}); });
@@ -124,7 +132,8 @@ class WortKraftApp {
}; };
this.settings = { this.settings = {
aiProvider: "builtin", aiProvider: "builtin",
apiKey: "" apiKey: "",
hoverTranslateEnabled: true
}; };
this.saveState(); this.saveState();
} }
@@ -137,7 +146,7 @@ class WortKraftApp {
repetition: 0, repetition: 0,
efactor: 2.5, efactor: 2.5,
dueDate: Date.now(), dueDate: Date.now(),
status: "new", // 'new' | 'learning' | 'mastered' status: "new",
reviews: 0 reviews: 0
} }
}; };
@@ -158,7 +167,124 @@ class WortKraftApp {
} }
getAllStories() { 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) === // === AUDIO PRONUNCIATION (Web Speech API de-DE) ===
@@ -198,6 +324,7 @@ class WortKraftApp {
// === NAVIGATION === // === NAVIGATION ===
switchView(viewName) { switchView(viewName) {
this.currentView = viewName; this.currentView = viewName;
this.hideHoverTooltip();
document.querySelectorAll(".view-section").forEach(sec => { document.querySelectorAll(".view-section").forEach(sec => {
sec.classList.toggle("active", sec.id === `view-${viewName}`); sec.classList.toggle("active", sec.id === `view-${viewName}`);
}); });
@@ -207,6 +334,7 @@ class WortKraftApp {
if (viewName === "dashboard") { if (viewName === "dashboard") {
this.updateDashboardCounters(); this.updateDashboardCounters();
this.renderDailySpotlight();
} else if (viewName === "trainer") { } else if (viewName === "trainer") {
if (!this.currentCard) this.pickNextCard(); if (!this.currentCard) this.pickNextCard();
this.renderTrainerStage(); this.renderTrainerStage();
@@ -282,8 +410,8 @@ class WortKraftApp {
<strong style="font-size: 1rem;">${w.word}</strong> <strong style="font-size: 1rem;">${w.word}</strong>
<span style="font-size: 0.84rem; color: var(--text-secondary);">— ${w.english}</span> <span style="font-size: 0.84rem; color: var(--text-secondary);">— ${w.english}</span>
</div> </div>
<div style="font-size: 0.83rem; color: var(--text-muted); margin-top: 0.3rem;"> <div style="font-size: 0.88rem; color: var(--text-muted); margin-top: 0.35rem;">
„${w.exampleDe}“ „${this.tokenizeSentenceForHover(w.exampleDe)}“
</div> </div>
</div> </div>
<button class="icon-btn" style="width: 32px; height: 32px; flex-shrink: 0;" onclick="app.speak('${this.escapeJs( <button class="icon-btn" style="width: 32px; height: 32px; flex-shrink: 0;" onclick="app.speak('${this.escapeJs(
@@ -319,7 +447,6 @@ class WortKraftApp {
getFilteredTrainerPool() { getFilteredTrainerPool() {
let pool = [...this.vocab]; let pool = [...this.vocab];
// If in gender mode, only include nouns with articles
if (this.trainerMode === "gender") { if (this.trainerMode === "gender") {
pool = pool.filter(w => Boolean(w.article)); pool = pool.filter(w => Boolean(w.article));
} }
@@ -328,7 +455,7 @@ class WortKraftApp {
const now = Date.now(); const now = Date.now();
const due = pool.filter(w => w.srs.dueDate <= now); const due = pool.filter(w => w.srs.dueDate <= now);
if (due.length > 0) return due; if (due.length > 0) return due;
return pool; // fallback if all due cards finished return pool;
} else if (this.trainerFilter !== "all") { } else if (this.trainerFilter !== "all") {
const catPool = pool.filter(w => w.category === this.trainerFilter); const catPool = pool.filter(w => w.category === this.trainerFilter);
if (catPool.length > 0) return catPool; if (catPool.length > 0) return catPool;
@@ -342,7 +469,6 @@ class WortKraftApp {
this.currentCard = null; this.currentCard = null;
return; 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 sorted = [...pool].sort((a, b) => a.srs.dueDate - b.srs.dueDate);
const candidates = sorted.slice(0, Math.min(5, sorted.length)); const candidates = sorted.slice(0, Math.min(5, sorted.length));
const next = candidates.find(c => !this.currentCard || c.id !== this.currentCard.id) || candidates[0]; const next = candidates.find(c => !this.currentCard || c.id !== this.currentCard.id) || candidates[0];
@@ -374,12 +500,17 @@ class WortKraftApp {
let promptHtml = ""; let promptHtml = "";
if (this.trainerMode === "cloze") { 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 = ` promptHtml = `
<div class="prompt-instruction">🧩 Lückensatz im Kontext • Ergänze das fehlende Wort</div> <div class="prompt-instruction">🧩 Lückensatz im Kontext • Ergänze das fehlende Wort</div>
<div class="prompt-main">${w.english}</div> <div class="prompt-main">${w.english}</div>
<div class="cloze-box"> <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 class="cloze-en">${w.exampleEn}</div>
</div> </div>
${ ${
@@ -441,7 +572,6 @@ class WortKraftApp {
} }
`; `;
} else { } else {
// Flashcard mode
promptHtml = ` promptHtml = `
<div class="prompt-instruction">🃏 Karteikarte & Wortbau • Überprüfe Artikel, Aussprache & Satz</div> <div class="prompt-instruction">🃏 Karteikarte & Wortbau • Überprüfe Artikel, Aussprache & Satz</div>
<div class="prompt-main">${w.english}</div> <div class="prompt-main">${w.english}</div>
@@ -492,7 +622,7 @@ class WortKraftApp {
</div> </div>
<div class="cloze-box" style="background: rgba(255,255,255,0.85); margin-bottom: 0.85rem;"> <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 class="cloze-en">${w.exampleEn}</div>
</div> </div>
@@ -562,7 +692,7 @@ class WortKraftApp {
`; `;
const inputEl = document.getElementById("trainer-answer-input"); const inputEl = document.getElementById("trainer-answer-input");
if (inputEl && !this.cardRevealed) { if (inputEl && !this.cardRevealed && typeof inputEl.focus === "function") {
setTimeout(() => inputEl.focus(), 30); setTimeout(() => inputEl.focus(), 30);
} }
} }
@@ -644,7 +774,6 @@ class WortKraftApp {
: `✅ Richtig (${w.article} ${w.word})! Denk daran, Nomen im Deutschen großzuschreiben.` : `✅ Richtig (${w.article} ${w.word})! Denk daran, Nomen im Deutschen großzuschreiben.`
}; };
} else if (w.article && userNorm.endsWith(expectedWordOnly)) { } else if (w.article && userNorm.endsWith(expectedWordOnly)) {
// Right noun, wrong or missing article!
this.lastAnswerStatus = { this.lastAnswerStatus = {
isCorrect: false, 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."})` 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) { gradeCurrentCard(grade) {
// grade: 1 (Again), 2 (Hard), 3 (Good), 4 (Easy)
if (!this.currentCard) return; if (!this.currentCard) return;
const w = this.currentCard; const w = this.currentCard;
const now = Date.now(); const now = Date.now();
@@ -705,7 +833,7 @@ class WortKraftApp {
if (grade === 1) { if (grade === 1) {
w.srs.repetition = 0; w.srs.repetition = 0;
w.srs.interval = 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"; w.srs.status = "learning";
} else if (grade === 2) { } else if (grade === 2) {
w.srs.repetition += 1; w.srs.repetition += 1;
@@ -771,8 +899,20 @@ class WortKraftApp {
this.speak(fullText, rate); 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) { tokenizeParagraphToInteractiveHtml(deText, story) {
// Split keeping punctuation and words separate
const tokens = deText.split(/([A-Za-zÄÖÜäöüß]+)/g); const tokens = deText.split(/([A-Za-zÄÖÜäöüß]+)/g);
return tokens return tokens
.map(token => { .map(token => {
@@ -786,7 +926,7 @@ class WortKraftApp {
if (isTarget) classes.push("target-vocab"); if (isTarget) classes.push("target-vocab");
else if (inDeck) classes.push("in-deck"); 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(""); .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( const deckMatch = this.vocab.find(
v => v =>
v.word.toLowerCase() === lower || v.word.toLowerCase() === lower ||
(v.plural && v.plural.toLowerCase().endsWith(lower)) || (v.plural && v.plural.toLowerCase().endsWith(" " + lower))
(v.word.length >= 5 && lower.startsWith(v.word.toLowerCase().slice(0, 5)))
); );
if (deckMatch) { if (deckMatch) {
return { return {
@@ -834,7 +973,7 @@ class WortKraftApp {
} }
// 3. Check other stories' glossaries // 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]) { if (st.glossary && st.glossary[clean]) {
const g = st.glossary[clean]; const g = st.glossary[clean];
return { return {
@@ -852,7 +991,7 @@ class WortKraftApp {
} }
// 4. Check common German dictionary // 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]; const c = window.COMMON_GERMAN_DICT[lower];
return { return {
surface: clean, 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(); const isCapitalized = clean[0] === clean[0].toUpperCase() && clean[0] !== clean[0].toLowerCase();
let guessedArticle = null; let guessedArticle = null;
let morphNote = "Klicke auf 'Ins SRS-Deck speichern', um dieses Wort zu trainieren."; let morphNote = "Klicke auf 'Ins SRS-Deck speichern', um dieses Wort zu trainieren.";
@@ -888,16 +1048,26 @@ class WortKraftApp {
article: guessedArticle, article: guessedArticle,
plural: null, plural: null,
pos: isCapitalized ? "noun" : "word", pos: isCapitalized ? "noun" : "word",
english: "Klicke zum Nachschlagen oder füge deine eigene Übersetzung hinzu", english: "Übersetze...",
note: morphNote, note: morphNote,
isTarget: false, isTarget: false,
inDeck: false inDeck: false,
needsLiveTranslate: true
}; };
} }
inspectReaderWord(rawToken) { inspectReaderWord(rawToken) {
const story = this.getAllStories().find(s => s.id === this.activeStoryId); 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.speak(rawToken);
this.renderReaderMain(); this.renderReaderMain();
} }
@@ -926,7 +1096,7 @@ class WortKraftApp {
pos: info.pos || "word", pos: info.pos || "word",
level: "A2/B1", level: "A2/B1",
category: "Saved from Reading", 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, breakdown: info.note,
forms: null, forms: null,
exampleDe: matchingParagraph ? matchingParagraph.de : `Ich habe das Wort „${info.lemma}“ im Text gelesen.`, exampleDe: matchingParagraph ? matchingParagraph.de : `Ich habe das Wort „${info.lemma}“ im Text gelesen.`,
@@ -959,7 +1129,7 @@ class WortKraftApp {
const paragraphsHtml = story.paragraphs const paragraphsHtml = story.paragraphs
.map( .map(
(p, idx) => ` p => `
<div class="story-paragraph"> <div class="story-paragraph">
<div class="story-de-line"> <div class="story-de-line">
${this.tokenizeParagraphToInteractiveHtml(p.de, story)} ${this.tokenizeParagraphToInteractiveHtml(p.de, story)}
@@ -1010,7 +1180,7 @@ class WortKraftApp {
} else { } else {
inspectorHtml = ` inspectorHtml = `
<div class="word-inspector-drawer" style="text-align: center; color: var(--text-secondary); font-size: 0.9rem;"> <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> </div>
`; `;
} }
@@ -1022,7 +1192,7 @@ class WortKraftApp {
quizHtml = ` quizHtml = `
<div style="margin-top: 1.75rem; padding-top: 1.5rem; border-top: 1px solid var(--border-color);"> <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 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;"> <div style="display: flex; flex-direction: column; gap: 0.5rem;">
${q.options ${q.options
.map((opt, idx) => { .map((opt, idx) => {
@@ -1067,14 +1237,17 @@ class WortKraftApp {
<p style="font-size: 0.88rem; color: var(--text-secondary);">${story.subtitle}</p> <p style="font-size: 0.88rem; color: var(--text-secondary);">${story.subtitle}</p>
</div> </div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;"> <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)"> <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>
<button class="btn btn-secondary" style="padding: 0.45rem 0.85rem; font-size: 0.84rem;" onclick="app.readWholeStoryAloud(1.0)"> <button class="btn btn-secondary" style="padding: 0.45rem 0.85rem; font-size: 0.84rem;" onclick="app.readWholeStoryAloud(1.0)">
🔊 Normal (1.0x) 🔊 Normal (1.0x)
</button> </button>
<button class="btn ${this.showReaderTranslation ? "btn-primary" : "btn-secondary"}" style="padding: 0.45rem 0.85rem; font-size: 0.84rem;" onclick="app.toggleReaderTranslation()"> <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> </button>
</div> </div>
</div> </div>
@@ -1087,7 +1260,7 @@ class WortKraftApp {
// === CONVERSATION & WRITING PRACTICE (SPRACHPRAXIS) === // === CONVERSATION & WRITING PRACTICE (SPRACHPRAXIS) ===
initScenarioStates() { initScenarioStates() {
window.CONVERSATION_SCENARIOS.forEach(scen => { (window.CONVERSATION_SCENARIOS || []).forEach(scen => {
if (!this.scenarioState[scen.id]) { if (!this.scenarioState[scen.id]) {
this.resetScenario(scen.id, false); this.resetScenario(scen.id, false);
} }
@@ -1095,7 +1268,7 @@ class WortKraftApp {
} }
resetScenario(scenarioId, reRender = true) { 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; if (!scen) return;
this.scenarioState[scenarioId] = { this.scenarioState[scenarioId] = {
turnIndex: 0, turnIndex: 0,
@@ -1116,7 +1289,7 @@ class WortKraftApp {
const container = document.getElementById("scenario-list-container"); const container = document.getElementById("scenario-list-container");
if (!container) return; if (!container) return;
container.innerHTML = window.CONVERSATION_SCENARIOS.map(scen => { container.innerHTML = (window.CONVERSATION_SCENARIOS || []).map(scen => {
const st = this.scenarioState[scen.id]; const st = this.scenarioState[scen.id];
const usedCount = st ? st.usedWords.length : 0; const usedCount = st ? st.usedWords.length : 0;
return ` return `
@@ -1149,7 +1322,7 @@ class WortKraftApp {
} }
updateLiveTargetWordHighlights(currentInputText) { 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; if (!scen) return;
const st = this.scenarioState[scen.id]; const st = this.scenarioState[scen.id];
const newlyTyped = this.detectTargetWordsInText(currentInputText, scen); const newlyTyped = this.detectTargetWordsInText(currentInputText, scen);
@@ -1168,20 +1341,17 @@ class WortKraftApp {
const tips = []; const tips = [];
const trimmed = userText.trim(); const trimmed = userText.trim();
// Celebrate target vocabulary usage
if (newlyUsedTargetWords.length > 0) { if (newlyUsedTargetWords.length > 0) {
tips.push(`🎯 <strong>Zielwörter aktiv eingebaut:</strong> ${newlyUsedTargetWords.join(", ")}!`); tips.push(`🎯 <strong>Zielwörter aktiv eingebaut:</strong> ${newlyUsedTargetWords.join(", ")}!`);
} else { } else {
tips.push(`💡 <strong>Tipp:</strong> Versuche in deiner Antwort mindestens eines der oben gelisteten <em>Zielwörter</em> zu verwenden.`); 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(); const lower = trimmed.toLowerCase();
if (lower.includes("weil ") || lower.includes("obwohl ") || lower.includes("dass ")) { 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.`); 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 ")) { if (lower.includes("deshalb ") || lower.includes("trotzdem ")) {
const badOrder = /deshalb\s+ich\b|trotzdem\s+ich\b/i.test(trimmed); const badOrder = /deshalb\s+ich\b|trotzdem\s+ich\b/i.test(trimmed);
if (badOrder) { if (badOrder) {
@@ -1192,7 +1362,7 @@ class WortKraftApp {
} }
if (currentTurn && currentTurn.sampleResponse) { 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/>"); return tips.join("<br/>");
@@ -1205,7 +1375,7 @@ class WortKraftApp {
const text = input.value.trim(); const text = input.value.trim();
if (!text) return; 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 st = this.scenarioState[scen.id];
const currentTurn = scen.turns[st.turnIndex]; const currentTurn = scen.turns[st.turnIndex];
@@ -1222,7 +1392,6 @@ class WortKraftApp {
this.stats.sentencesWritten++; this.stats.sentencesWritten++;
input.value = ""; input.value = "";
// Generate feedback (either via optional Live AI Key or instant built-in Smart Coach)
let feedbackHtml = this.analyzeGermanSentence(text, matchedWords, currentTurn); let feedbackHtml = this.analyzeGermanSentence(text, matchedWords, currentTurn);
if (this.settings.aiProvider !== "builtin" && this.settings.apiKey) { if (this.settings.aiProvider !== "builtin" && this.settings.apiKey) {
const aiCoachNote = await this.fetchOptionalAiFeedback(text, scen, currentTurn); const aiCoachNote = await this.fetchOptionalAiFeedback(text, scen, currentTurn);
@@ -1236,7 +1405,6 @@ class WortKraftApp {
html: feedbackHtml html: feedbackHtml
}); });
// Advance to next partner turn if available
if (st.turnIndex + 1 < scen.turns.length) { if (st.turnIndex + 1 < scen.turns.length) {
st.turnIndex += 1; st.turnIndex += 1;
const nextTurn = scen.turns[st.turnIndex]; const nextTurn = scen.turns[st.turnIndex];
@@ -1299,7 +1467,7 @@ class WortKraftApp {
} }
insertSampleHint() { 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 st = this.scenarioState[scen.id];
const currentTurn = scen?.turns[st.turnIndex]; const currentTurn = scen?.turns[st.turnIndex];
const input = document.getElementById("practice-user-input"); const input = document.getElementById("practice-user-input");
@@ -1314,7 +1482,7 @@ class WortKraftApp {
const container = document.getElementById("practice-stage-area"); const container = document.getElementById("practice-stage-area");
if (!container) return; 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; if (!scen) return;
const st = this.scenarioState[scen.id]; const st = this.scenarioState[scen.id];
const currentTurn = scen.turns[st.turnIndex]; 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> <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> <button class="icon-btn" style="width: 26px; height: 26px; font-size: 0.75rem;" onclick="app.speak('${this.escapeJs(m.de)}')">🔊</button>
</div> </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>` : ""} ${m.en ? `<div style="font-size: 0.82rem; color: var(--text-secondary); margin-top: 0.25rem;">${m.en}</div>` : ""}
</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>` : ""} ${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-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;"> <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> </div>
${w.breakdown ? `<div style="font-size: 0.78rem; color: var(--text-muted); margin-top: 0.45rem;">🧱 ${w.breakdown}</div>` : ""} ${w.breakdown ? `<div style="font-size: 0.78rem; color: var(--text-muted); margin-top: 0.45rem;">🧱 ${w.breakdown}</div>` : ""}
</div> </div>
@@ -1555,7 +1723,7 @@ class WortKraftApp {
const newStory = { const newStory = {
id: "custom_story_" + Date.now(), id: "custom_story_" + Date.now(),
title, title,
subtitle: "Eigener importierter Text (Jedes Wort ist klickbar)", subtitle: "Eigener importierter Text (Jedes Wort ist klickbar & hoverbar)",
level: "A2 / B1", level: "A2 / B1",
type: "Eigener Text", type: "Eigener Text",
readTime: "3 min", readTime: "3 min",
@@ -1623,6 +1791,14 @@ class WortKraftApp {
} }
} }
window.addEventListener("DOMContentLoaded", () => { function initWortKraft() {
window.app = new WortKraftApp(); if (!window.app) {
}); window.app = new WortKraftApp();
}
}
if (document.readyState === "loading") {
window.addEventListener("DOMContentLoaded", initWortKraft);
} else {
initWortKraft();
}
+183
View File
@@ -0,0 +1,183 @@
// Instant German -> English Hover Dictionary covering all words across stories, scenarios, and examples
window.HOVER_DICT = {
// Pronouns, Articles, Prepositions, Conjunctions & Particles
"ich": "I", "du": "you", "er": "he / it", "sie": "she / they / you (formal)", "es": "it",
"wir": "we", "ihr": "you all / her / their", "ihnen": "to them / to you (formal)", "ihre": "her / their / your (formal)", "ihren": "her / their / your (formal)",
"mich": "me / myself", "mir": "to me", "dich": "you / yourself", "dir": "to you", "sich": "oneself / himself / herself / themselves",
"uns": "us / ourselves", "ihn": "him / it", "ihm": "to him / it",
"mein": "my", "meine": "my", "meinen": "my", "meinem": "my", "meiner": "my",
"dein": "your", "deine": "your", "deinen": "your", "deinem": "your",
"unser": "our", "unsere": "our", "unseren": "our", "unserem": "our",
"der": "the (masc.) / who", "die": "the (fem./pl.) / who", "das": "the (neut.) / that",
"den": "the (acc. masc. / dat. pl.)", "dem": "the (dat. masc./neut.)", "des": "of the (gen.)",
"ein": "a / an / one", "eine": "a / an", "einen": "a / an (acc. masc.)", "einem": "a / an (dat.)", "einer": "a / an (dat./gen. fem.)",
"kein": "no / not a", "keine": "no / not a", "keinen": "no / not a",
"und": "and", "oder": "or", "aber": "but / however", "denn": "because / then (particle)", "sondern": "but rather",
"weil": "because (verb to end)", "dass": "that (conjunction)", "ob": "whether / if", "wenn": "if / when / whenever", "als": "when (past) / than / as",
"obwohl": "although / even though", "trotzdem": "nevertheless / anyway", "deshalb": "therefore / that's why", "deswegen": "that's why / therefore",
"bevor": "before", "sobald": "as soon as", "damit": "so that / with that", "dadurch": "thereby / as a result of that",
"in": "in / into", "im": "in the (in + dem)", "ins": "into the (in + das)",
"an": "at / on / to", "am": "at the / on the (an + dem)", "ans": "to the (an + das)",
"auf": "on / upon / for", "aufs": "onto the", "über": "over / about / above / across", "übers": "over the / for the",
"unter": "under / among", "vor": "before / in front of / ago", "vorm": "in front of the / of",
"hinter": "behind", "neben": "next to / beside", "zwischen": "between",
"mit": "with / by", "ohne": "without", "für": "for", "fürs": "for the", "gegen": "against / around (time)",
"um": "at (time) / around / in order to", "durch": "through / by",
"aus": "out of / from", "bei": "at / near / with", "beim": "at the / while (bei + dem)",
"nach": "after / to / according to", "von": "from / of / by", "vom": "from the / of the (von + dem)",
"zu": "to / at / too", "zum": "to the (zu + dem)", "zur": "to the (zu + der)",
"bis": "until / up to / by", "seit": "since / for (time)", "wegen": "because of", "trotz": "despite", "innerhalb": "within",
"nicht": "not", "noch": "still / yet / another", "schon": "already / yet", "erst": "only / just / first",
"nur": "only / just", "auch": "also / too", "sehr": "very / very much", "viel": "much / a lot", "viele": "many",
"mehr": "more", "meisten": "most", "wenigstens": "at least", "fast": "almost / nearly", "rund": "around / approximately",
"ganz": "whole / quite / completely", "ganze": "whole / entire", "alle": "all / everyone", "alles": "everything / all",
"etwas": "something / a little", "jemand": "someone / anybody", "oft": "often", "immer": "always", "manchmal": "sometimes",
"heute": "today", "morgen": "tomorrow / morning", "gestern": "yesterday", "jetzt": "now", "gerade": "just now / right now",
"gleich": "right away / same", "sofort": "immediately / right away", "bald": "soon", "später": "later", "spätestens": "at the latest",
"zuerst": "at first / first of all", "dann": "then", "danach": "afterwards", "plötzlich": "suddenly", "endlich": "finally",
"hier": "here", "da": "there / here / since", "dort": "there", "draußen": "outside", "drinnen": "indoors / inside", "überall": "everywhere",
"weg": "away / gone", "zurück": "back", "vorbei": "by / over / past", "zusammen": "together", "dabei": "with you / included",
"wie": "how / as / like", "was": "what", "wer": "who", "wo": "where", "wann": "when", "warum": "why", "worauf": "what ... forward to", "worum": "what ... about",
"welche": "which", "dieser": "this", "diese": "this / these", "dieses": "this", "diesen": "this / these", "diesem": "this",
"jeden": "every / each", "jedem": "every / each", "beiden": "both",
"ja": "yes", "nein": "no", "bitte": "please / you're welcome", "danke": "thank you", "vielen": "many (Vielen Dank = thank you very much)",
"dank": "thanks (der Dank)", "entschuldigung": "excuse me / sorry", "entschuldigen": "to excuse / apologize",
"leider": "unfortunately", "vielleicht": "maybe / perhaps", "natürlich": "naturally / of course", "bestimmt": "definitely / certainly",
"wirklich": "really / truly", "eigentlich": "actually / originally", "übrigens": "by the way", "zufällig": "by chance / coincidentally",
"gerne": "gladly / would like to", "unbedingt": "definitely / by all means", "rechtzeitig": "in time / on time", "pünktlich": "on time / punctual",
"kurz": "briefly / short", "kurze": "short / brief", "kurzen": "short / brief", "kurzfristig": "at short notice",
"schnell": "fast / quickly", "schnelle": "fast / quick", "schnellen": "fast / quick", "langsam": "slowly / slow",
// Verbs & Inflected Forms
"sein": "to be", "bin": "am", "bist": "are", "ist": "is", "sind": "are", "war": "was", "wäre": "would be",
"haben": "to have", "habe": "have", "hast": "have", "hat": "has", "hatte": "had", "hätte": "would have / would like",
"werden": "to become / will", "wird": "becomes / will", "geworden": "become",
"können": "can / to be able to", "kann": "can", "kannst": "can", "konnte": "could / was able to", "könnte": "could (polite)",
"müssen": "must / to have to", "muss": "must / have to", "musst": "must / have to", "musste": "had to", "mussten": "had to",
"wollen": "to want to", "wollte": "wanted to", "möchte": "would like to", "möchten": "would like to", "möchtest": "would like to",
"sollen": "should / supposed to", "soll": "should / is supposed to", "darf": "may / is allowed to",
"machen": "to make / do", "macht": "makes / does", "gemacht": "made / done", "ausmachen": "to turn off / agree on",
"tun": "to do", "tut": "does (tut mir leid = I'm sorry)",
"gehen": "to go / walk", "gehe": "go", "geht": "goes", "gegangen": "gone / went",
"kommen": "to come", "komme": "come", "kommst": "come", "kommt": "comes", "kam": "came", "kamen": "came",
"angekommen": "arrived", "mitkommst": "come along", "reinkommt": "comes in / gets in", "vorbeikommen": "to come by / drop in",
"fahren": "to drive / travel / ride", "fahre": "drive / travel / ride", "fährt": "drives / travels", "einfährt": "pulls in (train)",
"reisen": "to travel", "stehen": "to stand", "stehe": "stand", "steht": "stands", "stand": "stood",
"sitzen": "to sit", "bleiben": "to stay / remain", "bleibt": "stays / remains",
"lassen": "to let / leave", "lass": "let's (lass uns = let's)",
"geben": "to give", "gebe": "give", "gibt": "gives (es gibt = there is/are)", "gibst": "give", "gegeben": "given", "abzugeben": "to hand in / submit", "zurückgeben": "to give back / return",
"nehmen": "to take", "mitnehmen": "to take along / bring", "teilnehmen": "to participate / take part",
"bringen": "to bring", "bringe": "bring", "mitgebracht": "brought along",
"holen": "to fetch / get", "holt": "gets / fetches", "abholen": "to pick up",
"sagen": "to say / tell", "sag": "say / tell", "sagt": "says", "absagen": "to cancel",
"sprechen": "to speak / talk", "deutschsprechen": "speaking German", "erklären": "to explain", "erklärt": "explains",
"fragen": "to ask", "fragt": "asks", "antworten": "to answer", "antworte": "answer", "bitten": "to ask / request politely",
"rufen": "to call", "rufe": "call (rufe ... an = call on phone)", "angerufen": "called (on the phone)",
"hören": "to hear / listen", "hört": "hears / listens", "anhören": "to listen to",
"sehen": "to see", "lesen": "to read", "schreiben": "to write", "notiere": "note down", "unterschreiben": "to sign",
"tippt": "types", "prüft": "checks / examines", "überprüfe": "check / verify", "nachgeschaut": "looked up / checked",
"suchen": "to look for / search", "suche": "am looking for", "suchst": "are looking for", "zusammengesucht": "gathered together",
"finden": "to find", "findet": "finds / takes place (findet ... statt)", "gefunden": "found", "stattfinden": "to take place", "statt": "takes place (part of stattfinden) / instead of",
"treffen": "to meet / make (an agreement)", "getroffen": "met / made (eine Vereinbarung getroffen)",
"kaufen": "to buy", "gekauft": "bought", "zahlen": "to pay", "zahlt": "pays", "bezahlt": "paid", "überweist": "transfer (money)", "kostet": "costs", "übernimmt": "covers / takes over",
"tauschen": "to swap / exchange", "umtauschen": "to exchange (at a store)",
"essen": "to eat", "gegessen": "eaten", "trinken": "to drink", "trinkst": "drink", "grillen": "to barbecue / grill",
"joggen": "to jog", "schwimmen": "to swim", "spielen": "to play", "spielt": "plays (spielt mit = cooperates)", "fußballspielen": "playing soccer",
"lachen": "to laugh", "lacht": "laughs", "lächelt": "smiles",
"helfen": "to help", "übersetzen": "to translate / translating", "empfehlen": "to recommend",
"brauchen": "to need", "brauche": "need", "brauchst": "need", "braucht": "needs",
"wissen": "to know", "weiß": "know / knows", "gedacht": "thought", "vergisst": "forgets", "vergiss": "forget (imperative)", "vergessen": "to forget / forgotten",
"lernen": "to learn / study", "lerne": "learn / study", "lernt": "learns / studies", "verbessern": "to improve",
"bestanden": "passed (an exam)", "geschafft": "managed / made it", "klappt": "works out", "funktioniert": "works / functions",
"passieren": "to happen", "dauert": "lasts / takes (time)", "gilt": "applies / is valid", "folgt": "follows",
"gestiegen": "risen / increased", "beträgt": "amounts to", "enthalten": "included / contained",
"einlösen": "to redeem / fill (a prescription)", "einzulösen": "to fill (a prescription)", "löse": "redeem / fill (löse ... ein)",
"einziehen": "to move in", "besuchen": "to visit", "begleiten": "to accompany", "erledigen": "to take care of / finish", "erledigt": "finished / taken care of",
"vereinbaren": "to schedule / arrange", "verschieben": "to postpone / reschedule", "beantragen": "to apply for",
"untersuchen": "to examine", "untersucht": "examines / examined",
"vorschlagen": "to suggest / propose", "schlage": "suggest (schlage ... vor)", "schlägst": "suggest (schlägst ... vor)",
"teilen": "to share / split", "organisiert": "organizes", "gesorgt": "ensured / provided (gesorgt für)",
"regnen": "to rain", "regnet": "rains / is raining", "scheint": "shines / seems", "knackt": "crackles / clicks",
"bietet": "offers (bietet ... an)", "klingt": "sounds", "gefällt": "pleases / like (wie gefällt Ihnen = how do you like)",
// Nouns & Adjectives
"vereinbarung": "agreement / appointment (die Vereinbarung)",
"anmeldung": "registration (die Anmeldung)",
"unterlagen": "documents / paperwork (die Unterlagen)",
"bescheid": "notice / notification (Bescheid sagen = let know)",
"miete": "rent (die Miete)", "kaltmiete": "base rent without utilities (die Kaltmiete)",
"nebenkosten": "utility costs (die Nebenkosten)",
"kassenbon": "receipt (der Kassenbon)", "pfandbon": "bottle deposit slip (der Pfandbon)", "kaufbeleg": "proof of purchase / receipt (der Kaufbeleg)",
"besichtigung": "viewing / inspection (die Besichtigung)",
"schlange": "queue / waiting line (die Schlange)",
"pfand": "bottle/cup deposit (das Pfand)",
"krankenkasse": "health insurance provider (die Krankenkasse)", "versichertenkarte": "health insurance card (die Versichertenkarte)",
"rezept": "prescription / recipe (das Rezept)",
"krankschreibung": "sick note for work (die Krankschreibung)",
"verletzung": "injury (die Verletzung)", "untersuchung": "medical examination (die Untersuchung)", "behandlung": "treatment (die Behandlung)",
"feierabend": "end of workday / evening off (der Feierabend)",
"vorschlag": "suggestion / proposal (der Vorschlag)",
"verständnis": "understanding / sympathy (das Verständnis)",
"erfahrung": "experience (die Erfahrung)", "erfahrungen": "experiences (die Erfahrungen)",
"verbindung": "connection (die Verbindung)", "anschluss": "connecting train / connection (der Anschluss)",
"verspätung": "delay / lateness (die Verspätung)",
"unterkunft": "accommodation / lodging (die Unterkunft)",
"gelegenheit": "opportunity / chance (die Gelegenheit)",
"stimmung": "atmosphere / mood (die Stimmung)",
"gewohnheit": "habit / custom (die Gewohnheit)",
"unterschied": "difference (der Unterschied)",
"vermieter": "landlord (der Vermieter)", "interessenten": "prospective tenants / interested people",
"dreizimmerwohnung": "three-room apartment (die Dreizimmerwohnung)", "mietvertrag": "lease / rental contract (der Mietvertrag)", "stock": "floor / story (der Stock)",
"termin": "appointment (der Termin)", "termine": "appointments (die Termine)", "treffen": "meeting / get-together (das Treffen)",
"zahnarzt": "dentist (der Zahnarzt)", "arzt": "doctor (der Arzt)", "ärztin": "doctor (female, die Ärztin)", "arzthelfer": "medical assistant (der Arzthelfer)",
"praxis": "doctor's office (die Praxis)", "hausarztpraxis": "family doctor's office (die Hausarztpraxis)", "wartezimmer": "waiting room (das Wartezimmer)",
"erkältung": "cold (illness, die Erkältung)", "infekt": "infection (der Infekt)", "grippaler": "flu-like (grippaler Infekt)",
"hals": "throat / neck (der Hals)", "knie": "knee (das Knie)", "fuß": "foot (der Fuß)",
"medikament": "medication (das Medikament)", "medikamente": "medications (die Medikamente)", "halstabletten": "throat lozenges (die Halstabletten)",
"finanzamt": "tax office (das Finanzamt)", "behörden": "authorities / government offices (die Behörden)",
"visum": "visa (das Visum)", "stadt": "city (die Stadt)", "städten": "cities (den Städten)", "deutschland": "Germany", "norddeutschland": "Northern Germany",
"strom": "electricity / power (der Strom)", "heizung": "heating (die Heizung)", "wasser": "water (das Wasser)",
"kasse": "checkout / cash register (die Kasse)", "bäckerei": "bakery (die Bäckerei)", "bäcker": "baker / bakery (der Bäcker)",
"jacke": "jacket (die Jacke)", "pullover": "sweater (der Pullover)", "schuhe": "shoes (die Schuhe)", "reißverschluss": "zipper (der Reißverschluss)", "größe": "size (die Größe)", "lager": "stockroom / warehouse (das Lager)",
"glasflasche": "glass bottle (die Glasflasche)", "einkäufe": "purchases / groceries (die Einkäufe)",
"tag": "day (der Tag)", "tage": "days (die Tage)", "tagen": "days (Dative)", "monat": "month (der Monat)", "monats": "of the month", "monaten": "months",
"jahr": "year (das Jahr)", "jahren": "years (Dative)", "woche": "week (die Woche)", "arbeitswoche": "workweek (die Arbeitswoche)", "wochenendprogramm": "weekend program",
"vormittag": "morning / forenoon (der Vormittag)", "vormittags": "in the mornings", "mittag": "noon / lunch (zu Mittag = for lunch)", "nachmittag": "afternoon (der Nachmittag)", "nachmittags": "in the afternoons", "abend": "evening (der Abend)",
"freitag": "Friday", "freitagnachmittag": "Friday afternoon", "samstag": "Saturday", "sonntag": "Sunday", "montagmorgen": "Monday morning", "mai": "May", "sommer": "summer (der Sommer)",
"uhr": "o'clock / clock / watch (die Uhr)", "uhrzeit": "time of day (die Uhrzeit)", "minuten": "minutes (die Minuten)",
"büro": "office (das Büro)", "job": "job (der Job)", "kollegin": "colleague (female, die Kollegin)", "chefin": "boss (female, die Chefin)",
"rechnung": "bill / check / invoice (die Rechnung)", "preis": "price (der Preis)", "kosten": "costs (die Kosten)", "euro": "euros", "cent": "cents",
"reservierung": "reservation (die Reservierung)", "restaurant": "restaurant (das Restaurant)", "kino": "movie theater / cinema (das Kino)",
"urlaub": "vacation / holiday (der Urlaub)", "reise": "trip / journey (die Reise)", "pflanzen": "plants (die Pflanzen)",
"stammtisch": "regulars' meetup / language table (der Stammtisch)", "sprachkurs": "language course (der Sprachkurs)", "deutschkurs": "German course (der Deutschkurs)", "prüfung": "exam / test (die Prüfung)",
"konzert": "concert (das Konzert)", "straßenfest": "street festival (das Straßenfest)", "straßenfesten": "street festivals", "weihnachtsmarkt": "Christmas market (der Weihnachtsmarkt)", "flohmarkt": "flea market (der Flohmarkt)", "markt": "market (der Markt)", "märkten": "markets", "park": "park (der Park)",
"zugticket": "train ticket (das Zugticket)", "zugtickets": "train tickets (die Zugtickets)", "hauptbahnhof": "main train station (der Hauptbahnhof)", "regionalzug": "regional train (der Regionalzug)", "ice": "Intercity Express high-speed train", "fahrrad": "bicycle (das Fahrrad)", "platten": "flat tire (einen Platten)", "bus": "bus (der Bus)",
"see": "lake (der See)", "wannsee": "Lake Wannsee (in Berlin)", "bodensee": "Lake Constance (der Bodensee)",
"sonne": "sun (die Sonne)", "kälte": "cold (noun, die Kälte)", "musik": "music (die Musik)", "feier": "party / celebration (die Feier)",
"hunger": "hunger (Hunger haben = to be hungry)", "tasse": "cup (die Tasse)", "tee": "tea (der Tee)", "brot": "bread (das Brot)", "käse": "cheese (der Käse)", "keks": "cookie / biscuit (der Keks)", "apfelkuchen": "apple cake (der Apfelkuchen)", "apfelschorle": "apple juice spritzer (die Apfelschorle)", "fischbrötchen": "fish roll sandwich (das Fischbrötchen)", "getränke": "drinks / beverages (die Getränke)", "picknick": "picnic (das Picknick)",
"buch": "book (das Buch)", "wörter": "words (die Wörter)", "wörtern": "words (Dative)", "kontext": "context (der Kontext)", "info": "info / information (die Info)", "information": "information / announcement (die Information)", "tipps": "tips (die Tipps)", "idee": "idea (die Idee)",
"bruder": "brother (der Bruder)", "familie": "family (die Familie)", "freunden": "friends (Dative)", "personen": "people / persons (die Personen)", "neuankömmlinge": "newcomers (die Neuankömmlinge)", "herr": "gentleman / Mr. (der Herr)", "frau": "Ms. / Mrs. / woman (die Frau)", "mitarbeiterin": "employee (female, die Mitarbeiterin)",
"glück": "luck (Zum Glück = fortunately)", "sorge": "worry (Keine Sorge = no worries)", "angst": "fear (Angst haben vor = to be afraid of)", "ruhe": "quiet / calm / peace (die Ruhe)", "stress": "stress (der Stress)", "hilfe": "help (die Hilfe)", "einladung": "invitation (die Einladung)", "eindruck": "impression (der Eindruck)", "tradition": "tradition (die Tradition)",
"mappe": "folder / binder (die Mappe)", "papiere": "papers / documents (die Papiere)", "karte": "card / ticket (die Karte)", "nummer": "number (die Nummer)", "computer": "computer (der Computer)", "telefon": "telephone (am Telefon = on the phone)", "apparat": "telephone receiver (am Apparat = speaking)", "rucksack": "backpack (der Rucksack)", "nähe": "vicinity (in der Nähe = nearby)", "heimweg": "way home (auf dem Heimweg = on the way home)", "hafenstädte": "harbor cities (die Hafenstädte)", "supermärkte": "supermarkets (die Supermärkte)",
// Adjectives & Adverbs
"gut": "good / well", "gute": "good", "besseren": "better", "beste": "best", "schön": "nice / beautiful", "schönen": "nice / lovely", "wunderbar": "wonderful", "wunderbare": "wonderful", "hervorragenden": "excellent / outstanding", "super": "super / great", "toll": "great / awesome", "perfekt": "perfect", "nett": "nice / kind", "freundlich": "friendly / kind",
"groß": "big / large / tall", "großen": "big / large / great", "klein": "small / little", "kleine": "small", "kleines": "small", "kleinen": "small", "riesig": "huge / hugely", "hoch": "high",
"neu": "new", "neue": "new", "neues": "new", "neuen": "new", "alt": "old", "alte": "old", "alten": "old", "älterer": "older / elderly",
"wichtig": "important", "wichtige": "important", "wichtigen": "important", "offizielle": "official",
"schwer": "hard / difficult / heavy", "einfach": "simply / easy", "einfacher": "easier", "möglich": "possible", "kaputt": "broken / out of order",
"krank": "sick / ill", "müde": "tired", "nervös": "nervous", "neugierig": "curious", "entspannt": "relaxed", "ruhig": "calm / quiet", "ruhige": "quiet / calm", "überraschend": "surprisingly",
"stark": "strongly / heavily", "starke": "strong / severe", "leichtes": "slight / light", "leichten": "slight / minor", "schlimm": "bad / serious", "schlechtem": "bad",
"warmen": "warm", "kalt": "cold / base rent", "hell": "bright", "dunkel": "dark", "frisches": "fresh", "gemütliche": "cozy", "günstige": "affordable / cheap",
"klare": "clear", "richtig": "really / right / correct", "genau": "exactly / thoroughly", "gründlich": "thoroughly", "vollkommen": "completely / totally", "ziemlich": "quite / pretty", "ordentlich": "tidy / proper", "telefonisch": "by phone", "monatlichen": "monthly", "passiv": "passively", "aktiv": "actively", "laut": "aloud / loud", "geschlossen": "closed", "typisch": "typical",
"erste": "first", "ersten": "first", "zwei": "two", "drei": "three", "dritten": "third", "fünf": "five", "zehn": "ten", "elf": "eleven", "fünfzehn": "fifteen (15:00 = 3 PM)", "siebzehn": "seventeen (17:00 = 5 PM)", "zwanzig": "twenty", "fünfundzwanzig": "twenty-five", "dreißig": "thirty", "fünfzig": "fifty", "letzten": "last / recent", "nächste": "next", "einmal": "once", "wieder": "again", "weiter": "further / continue",
"mehrere": "several / multiple", "hause": "home (zu Hause = at home)", "man": "one / you / people", "plus": "plus", "lange": "long", "lang": "long (for ... long)", "ab": "starting from / off", "früh": "early / in the morning",
"stück": "piece / bit (das Stück)", "bahn": "train / railway (die Bahn)", "station": "station / stop (die Station)", "paar": "couple / a few (ein paar)", "dinge": "things (die Dinge)",
"deutsch": "German", "deutsche": "German", "deutschen": "German", "sammeln": "to collect / gain (experience)", "gemeinsames": "shared / together",
"münchen": "Munich", "hamburg": "Hamburg", "berlin": "Berlin", "frankfurt": "Frankfurt", "hannover": "Hanover",
"app": "app (die App)", "verpasst": "missed (a train/bus)", "einsteigen": "to board / get on", "aussteigen": "to exit / get off",
"direkt": "directly / right", "café": "café (das Café)", "fall": "case (auf jeden Fall = definitely)", "bekommt": "gets / receives",
"per": "by / via", "mail": "email (die E-Mail)", "selbst": "even (selbst wenn = even if) / oneself", "so": "so / this way", "doch": "yet / however / after all",
"system": "system (das System)", "guten": "good (Guten Morgen/Tag)", "leid": "sorry (tut mir leid = I am sorry)", "klar": "clear / all right (Alles klar!)",
"sonst": "otherwise / else", "hoffentlich": "hopefully", "hallo": "hello", "herzlich": "warmly / cordially", "willkommen": "welcome (Herzlich willkommen!)"
};
+1 -1
View File
@@ -6,7 +6,7 @@ services:
container_name: wortkraft container_name: wortkraft
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- /home/chrisf/docker/appdata/wortkraft/data:/app/data - /home/chrisf/docker/appdata/wortkraft/data:/app/store
networks: networks:
- t3_proxy - t3_proxy
ports: ports:
+7
View File
@@ -45,6 +45,9 @@
</nav> </nav>
<div class="header-actions"> <div class="header-actions">
<button id="hover-translate-toggle" class="hover-toggle-btn active" onclick="app.toggleHoverTranslate()" title="Wörter beim Drüberfahren (Hover) automatisch ins Englische übersetzen">
🔍 Hover-Übersetzer: <span id="hover-translate-status">AN</span>
</button>
<div class="streak-chip" title="1400+ Days Foundation + Active Recall Streak"> <div class="streak-chip" title="1400+ Days Foundation + Active Recall Streak">
🔥 <span id="streak-count">1401</span> Tage 🔥 <span id="streak-count">1401</span> Tage
</div> </div>
@@ -398,6 +401,9 @@
</div> </div>
</div> </div>
<!-- HOVER TRANSLATE TOOLTIP -->
<div id="hover-translate-tooltip" class="hover-translate-tooltip"></div>
<!-- TOAST NOTIFICATION --> <!-- TOAST NOTIFICATION -->
<div id="toast" class="toast-notification"></div> <div id="toast" class="toast-notification"></div>
@@ -405,6 +411,7 @@
<script src="data/vocabulary.js"></script> <script src="data/vocabulary.js"></script>
<script src="data/stories.js"></script> <script src="data/stories.js"></script>
<script src="data/scenarios.js"></script> <script src="data/scenarios.js"></script>
<script src="data/hover_dict.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>
+41 -10
View File
@@ -11,29 +11,30 @@ const app = express();
const PORT = process.env.PORT || 80; const PORT = process.env.PORT || 80;
const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, '../data_store'); const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, '../data_store');
const STATE_FILE = path.join(DATA_DIR, 'wortkraft_state.json'); const STATE_FILE = path.join(DATA_DIR, 'wortkraft_state.json');
const TRANSLATE_CACHE_FILE = path.join(DATA_DIR, 'translations_cache.json');
if (!fs.existsSync(DATA_DIR)) { if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(DATA_DIR, { recursive: true });
} }
function readState() { function readJson(filePath, fallback) {
try { try {
if (fs.existsSync(STATE_FILE)) { if (fs.existsSync(filePath)) {
const raw = fs.readFileSync(STATE_FILE, 'utf8'); const raw = fs.readFileSync(filePath, 'utf8');
return JSON.parse(raw); return JSON.parse(raw);
} }
} catch (err) { } catch (err) {
console.error('[WORTKRAFT] Error reading state file:', err); console.error(`[WORTKRAFT] Error reading ${filePath}:`, err);
} }
return null; return fallback;
} }
function writeState(stateObj) { function writeJson(filePath, obj) {
try { try {
fs.writeFileSync(STATE_FILE, JSON.stringify(stateObj, null, 2), 'utf8'); fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf8');
return true; return true;
} catch (err) { } catch (err) {
console.error('[WORTKRAFT] Error writing state file:', err); console.error(`[WORTKRAFT] Error writing ${filePath}:`, err);
return false; return false;
} }
} }
@@ -53,7 +54,7 @@ app.get('/api/health', (_req, res) => {
// Get persisted learning state (SRS deck, streak, custom words & stories) // Get persisted learning state (SRS deck, streak, custom words & stories)
app.get('/api/state', (_req, res) => { app.get('/api/state', (_req, res) => {
const state = readState(); const state = readJson(STATE_FILE, null);
res.json({ exists: Boolean(state), state }); res.json({ exists: Boolean(state), state });
}); });
@@ -64,10 +65,40 @@ app.post('/api/state', (req, res) => {
return res.status(400).json({ error: 'Invalid state payload' }); return res.status(400).json({ error: 'Invalid state payload' });
} }
payload.updatedAt = new Date().toISOString(); payload.updatedAt = new Date().toISOString();
const ok = writeState(payload); const ok = writeJson(STATE_FILE, payload);
res.json({ success: ok, updatedAt: payload.updatedAt }); res.json({ success: ok, updatedAt: payload.updatedAt });
}); });
// On-the-fly German -> English word translation fallback for custom texts
app.get('/api/translate', async (req, res) => {
const word = String(req.query.word || '').trim();
if (!word) {
return res.status(400).json({ error: 'Missing word parameter' });
}
const key = word.toLowerCase();
const cache = readJson(TRANSLATE_CACHE_FILE, {});
if (cache[key]) {
return res.json({ word, english: cache[key], cached: true });
}
try {
const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(word)}&langpair=de|en`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
const translated = data?.responseData?.translatedText;
if (translated && translated.toLowerCase() !== key) {
cache[key] = translated;
writeJson(TRANSLATE_CACHE_FILE, cache);
return res.json({ word, english: translated, cached: false });
}
}
} catch (err) {
console.warn('[WORTKRAFT] Translation fallback error:', err.message);
}
res.json({ word, english: null });
});
// Serve static frontend files from project root // Serve static frontend files from project root
const PUBLIC_DIR = path.resolve(__dirname, '..'); const PUBLIC_DIR = path.resolve(__dirname, '..');
app.use('/data', express.static(path.join(PUBLIC_DIR, 'data'))); app.use('/data', express.static(path.join(PUBLIC_DIR, 'data')));
+92
View File
@@ -1033,3 +1033,95 @@ body {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
/* === HOVER-TO-TRANSLATE MODE & FLOATING TOOLTIP === */
.hover-toggle-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: inherit;
font-size: 0.82rem;
font-weight: 700;
padding: 0.42rem 0.8rem;
border-radius: 999px;
cursor: pointer;
border: 1px solid var(--border-strong);
background: var(--bg-subtle);
color: var(--text-secondary);
transition: all 0.15s ease;
white-space: nowrap;
}
.hover-toggle-btn.active {
background: var(--indigo-light);
color: var(--indigo);
border-color: #a5b4fc;
}
.hover-toggle-btn:hover {
transform: translateY(-1px);
}
body.hover-translate-on .word-token {
border-bottom: 1px dashed rgba(79, 70, 229, 0.35);
}
body.hover-translate-on .word-token:hover {
background: #e0e7ff;
color: #1e1b4b;
}
.hover-translate-tooltip {
position: fixed;
z-index: 9999;
background: #18181b;
color: #ffffff;
padding: 0.55rem 0.85rem;
border-radius: 10px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.28);
pointer-events: none;
opacity: 0;
transform: translate(-50%, -100%) translateY(-8px);
transition: opacity 0.12s ease;
max-width: 310px;
font-size: 0.84rem;
line-height: 1.35;
border: 1px solid rgba(255, 255, 255, 0.14);
}
.hover-translate-tooltip.visible {
opacity: 1;
}
.hover-translate-tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 6px;
border-style: solid;
border-color: #18181b transparent transparent transparent;
}
.hover-tt-top {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.18rem;
font-weight: 700;
color: #f4f4f5;
}
.hover-tt-en {
color: #fde68a;
font-weight: 700;
font-size: 0.9rem;
}
.hover-tt-note {
color: #a1a1aa;
font-size: 0.74rem;
margin-top: 0.15rem;
}