From ffe25e782aa7e43e3f82bc826d315a03cf96d8aa Mon Sep 17 00:00:00 2001 From: "Chris W. Fuller" Date: Wed, 21 Oct 2026 04:32:47 +0000 Subject: [PATCH] Fix static data volume mount and add global hover-to-translate mode --- Dockerfile | 4 +- app.js | 310 +++++++++++++++++++++++++++++++++++---------- data/hover_dict.js | 183 ++++++++++++++++++++++++++ docker-compose.yml | 2 +- index.html | 7 + server/index.js | 51 ++++++-- styles.css | 92 ++++++++++++++ 7 files changed, 569 insertions(+), 80 deletions(-) create mode 100644 data/hover_dict.js diff --git a/Dockerfile b/Dockerfile index a8df0fe..31a4131 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /app ENV NODE_ENV=production ENV PORT=80 -ENV DATA_DIR=/app/data +ENV DATA_DIR=/app/store COPY package*.json ./ RUN npm install --omit=dev @@ -13,7 +13,7 @@ COPY index.html styles.css app.js ./ COPY data/ ./data/ COPY server/ ./server/ -RUN mkdir -p /app/data +RUN mkdir -p /app/store EXPOSE 80 diff --git a/app.js b/app.js index 0e75721..5b542da 100644 --- a/app.js +++ b/app.js @@ -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 + ? `${info.article}` + : ""; + + tooltip.innerHTML = ` +
+ ${articleHtml} + ${info.lemma || rawWord} +
+
${info.english}
+ ${info.plural ? `
Plural: ${info.plural}
` : ""} + `; + + 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 { ${w.word} — ${w.english} -
- „${w.exampleDe}“ +
+ „${this.tokenizeSentenceForHover(w.exampleDe)}“
@@ -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(`🎯 Zielwörter aktiv eingebaut: ${newlyUsedTargetWords.join(", ")}!`); } else { tips.push(`💡 Tipp: Versuche in deiner Antwort mindestens eines der oben gelisteten Zielwörter 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(`🧱 Nebensatz-Struktur genutzt (weil/obwohl/dass): 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(`🇩🇪 Muster-Formulierung zum Vergleich: „${currentTurn.sampleResponse}“`); + tips.push(`🇩🇪 Muster-Formulierung zum Vergleich: „${this.tokenizeSentenceForHover(currentTurn.sampleResponse)}“`); } return tips.join("
"); @@ -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 { ${scen.avatar} ${scen.rolePartner} -
${m.de}
+
${this.tokenizeSentenceForHover(m.de)}
${m.en ? `
${m.en}
` : ""} `; @@ -1474,7 +1642,7 @@ class WortKraftApp { ${w.plural ? `
Plural: ${w.plural}
` : ""}
${w.english}
- „${w.exampleDe}“ + „${this.tokenizeSentenceForHover(w.exampleDe)}“
${w.breakdown ? `
🧱 ${w.breakdown}
` : ""} @@ -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(); +} diff --git a/data/hover_dict.js b/data/hover_dict.js new file mode 100644 index 0000000..9b1b97b --- /dev/null +++ b/data/hover_dict.js @@ -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!)" +}; diff --git a/docker-compose.yml b/docker-compose.yml index b42931d..16c1ef9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: container_name: wortkraft restart: unless-stopped volumes: - - /home/chrisf/docker/appdata/wortkraft/data:/app/data + - /home/chrisf/docker/appdata/wortkraft/data:/app/store networks: - t3_proxy ports: diff --git a/index.html b/index.html index d4e9b7a..eaeabb2 100644 --- a/index.html +++ b/index.html @@ -45,6 +45,9 @@
+
🔥 1401 Tage
@@ -398,6 +401,9 @@
+ +
+
@@ -405,6 +411,7 @@ + diff --git a/server/index.js b/server/index.js index d8a6c74..9441ee9 100644 --- a/server/index.js +++ b/server/index.js @@ -11,29 +11,30 @@ const app = express(); const PORT = process.env.PORT || 80; const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, '../data_store'); 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)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } -function readState() { +function readJson(filePath, fallback) { try { - if (fs.existsSync(STATE_FILE)) { - const raw = fs.readFileSync(STATE_FILE, 'utf8'); + if (fs.existsSync(filePath)) { + const raw = fs.readFileSync(filePath, 'utf8'); return JSON.parse(raw); } } 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 { - fs.writeFileSync(STATE_FILE, JSON.stringify(stateObj, null, 2), 'utf8'); + fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf8'); return true; } catch (err) { - console.error('[WORTKRAFT] Error writing state file:', err); + console.error(`[WORTKRAFT] Error writing ${filePath}:`, err); return false; } } @@ -53,7 +54,7 @@ app.get('/api/health', (_req, res) => { // Get persisted learning state (SRS deck, streak, custom words & stories) app.get('/api/state', (_req, res) => { - const state = readState(); + const state = readJson(STATE_FILE, null); 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' }); } payload.updatedAt = new Date().toISOString(); - const ok = writeState(payload); + const ok = writeJson(STATE_FILE, payload); 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 const PUBLIC_DIR = path.resolve(__dirname, '..'); app.use('/data', express.static(path.join(PUBLIC_DIR, 'data'))); diff --git a/styles.css b/styles.css index fc843b4..838306d 100644 --- a/styles.css +++ b/styles.css @@ -1033,3 +1033,95 @@ body { opacity: 1; 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; +} +