diff --git a/Dockerfile b/Dockerfile index 31a4131..ad0c97c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ ENV DATA_DIR=/app/store COPY package*.json ./ RUN npm install --omit=dev -COPY index.html styles.css app.js ./ +COPY index.html styles.css app.js icon.svg ./ COPY data/ ./data/ COPY server/ ./server/ diff --git a/app.js b/app.js index 70b1078..c01cb2c 100644 --- a/app.js +++ b/app.js @@ -218,38 +218,65 @@ class WortKraftApp { setupHoverListeners() { let lastHoveredWord = null; - document.addEventListener("mousemove", e => { - if (!this.hoverTranslateEnabled) return; + const handlePointLookup = (clientX, clientY, targetEl) => { + if (!this.hoverTranslateEnabled) return false; - // 1. Check if directly over an explicit .word-token span - const tokenEl = e.target.closest?.(".word-token"); + // 1. Check if directly over/tapping an explicit .word-token span + const tokenEl = targetEl?.closest?.(".word-token"); if (tokenEl) { const word = tokenEl.dataset.word || tokenEl.textContent.trim(); if (word) { this.showHoverTooltipForRect(tokenEl.getBoundingClientRect(), word, true); lastHoveredWord = word; - return; + return true; } } - // 2. Universal German word detector under cursor (works on headings, cards, buttons, chips, etc.) - const hit = this.getWordAtPoint(e.clientX, e.clientY); + // 2. Universal German word detector under cursor/tap + const hit = this.getWordAtPoint(clientX, clientY); if (hit && hit.word) { const info = this.lookupWordInfo(hit.word, null); - // Show tooltip if word is in our German dictionaries if (!info.needsLiveTranslate) { this.showHoverTooltipForRect(hit.rect, hit.word, false); lastHoveredWord = hit.word; - return; + return true; } } + return false; + }; - if (lastHoveredWord) { + document.addEventListener("mousemove", e => { + if (!this.hoverTranslateEnabled) return; + const matched = handlePointLookup(e.clientX, e.clientY, e.target); + if (!matched && lastHoveredWord) { lastHoveredWord = null; this.hideHoverTooltip(); } }); + // Mobile & Touch support: tap a word to show translation tooltip, tap elsewhere to dismiss + document.addEventListener( + "touchstart", + e => { + if (!this.hoverTranslateEnabled) return; + const touch = e.touches?.[0]; + if (!touch) return; + // Ignore taps on interactive controls like buttons/inputs unless it's a .word-token + const isToken = e.target?.closest?.(".word-token"); + const isInteractiveControl = e.target?.closest?.("button, input, select, textarea, a"); + if (isInteractiveControl && !isToken) { + this.hideHoverTooltip(); + return; + } + const matched = handlePointLookup(touch.clientX, touch.clientY, e.target); + if (!matched) { + lastHoveredWord = null; + this.hideHoverTooltip(); + } + }, + { passive: true } + ); + window.addEventListener("scroll", () => this.hideHoverTooltip(), { passive: true }); } @@ -353,11 +380,14 @@ class WortKraftApp { ${info.plural ? `
Plural: ${info.plural}
` : ""} `; + const vw = window.innerWidth || 360; + const maxW = Math.min(300, vw - 24); + const halfW = Math.round(maxW / 2) + 12; const left = Math.min( - window.innerWidth - 160, - Math.max(160, rect.left + rect.width / 2) + Math.max(halfW, vw - halfW), + Math.max(halfW, rect.left + rect.width / 2) ); - const top = Math.max(55, rect.top); + const top = Math.max(64, rect.top); // Apply explicit inline critical styles in addition to CSS class so it is 100% immune to stale CSS cache Object.assign(tooltip.style, { @@ -374,7 +404,7 @@ class WortKraftApp { transform: "translate(-50%, -100%) translateY(-8px)", opacity: "1", display: "block", - maxWidth: "310px", + maxWidth: `${maxW}px`, fontSize: "0.84rem", lineHeight: "1.35", border: "1px solid rgba(255,255,255,0.16)" @@ -2368,7 +2398,7 @@ class WortKraftApp { -
+
${optionsHtml}
@@ -2416,7 +2446,7 @@ class WortKraftApp {
Gesuchtes Verb: ${d.verb} (${d.english})
-
+
${daChoices .map( ch => ` diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..093039d --- /dev/null +++ b/icon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/index.html b/index.html index 703d8a3..68d60bf 100644 --- a/index.html +++ b/index.html @@ -2,12 +2,17 @@ - + + + + WortKraft — Beyond Duolingo German A2/B1 + + - + @@ -54,11 +59,11 @@
-
- 🔥 1401 Tage + 🔥 1401 Tage
@@ -491,11 +496,11 @@
- - - - - - + + + + + + diff --git a/server/index.js b/server/index.js index c85282a..ba20816 100644 --- a/server/index.js +++ b/server/index.js @@ -196,6 +196,7 @@ app.use((req, res, next) => { next(); }); app.use('/data', express.static(path.join(PUBLIC_DIR, 'data'))); +app.get('/icon.svg', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'icon.svg'))); app.get('/styles.css', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'styles.css'))); app.get('/app.js', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'app.js'))); app.get('/', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'index.html'))); diff --git a/styles.css b/styles.css index 678ba32..185e893 100644 --- a/styles.css +++ b/styles.css @@ -1261,4 +1261,359 @@ body.hover-translate-on .word-token:hover { border-radius: 6px; } +.prep-options-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + margin: 1.25rem 0; +} + +.dawo-options-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.65rem; + margin: 1.25rem 0; +} + +/* === MOBILE & TABLET RESPONSIVE CLEANUP === */ +.nav-tabs::-webkit-scrollbar, +.mode-pills::-webkit-scrollbar { + display: none; +} + +.nav-tabs, +.mode-pills { + scrollbar-width: none; + -webkit-overflow-scrolling: touch; +} + +@media (max-width: 940px) { + .header-inner { + padding: 0.6rem 0.9rem; + gap: 0.55rem; + } + + .brand { + order: 1; + } + + .header-actions { + order: 2; + margin-left: auto; + gap: 0.35rem; + } + + .nav-tabs { + order: 3; + width: 100%; + padding: 0.25rem; + gap: 0.25rem; + } + + .nav-tab { + padding: 0.42rem 0.7rem; + font-size: 0.82rem; + flex-shrink: 0; + } + + /* Turn story and scenario lists into horizontal swipe strips on mobile/tablet so content is immediately visible */ + #story-list-container, + #scenario-list-container { + flex-direction: row !important; + overflow-x: auto; + padding-bottom: 0.35rem; + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; + } + + #story-list-container .story-card-item, + #scenario-list-container .story-card-item { + min-width: 245px; + max-width: 270px; + flex-shrink: 0; + scroll-snap-align: start; + padding: 0.8rem 0.95rem; + } +} + +@media (max-width: 640px) { + .brand-subtitle { + display: none; + } + + .brand-flag { + width: 30px; + height: 30px; + border-radius: 8px; + } + + .brand-title { + font-size: 1.02rem; + } + + .hover-toggle-btn .hover-label-text { + display: none; + } + + .hover-toggle-btn { + padding: 0.36rem 0.6rem; + font-size: 0.78rem; + } + + .streak-chip { + padding: 0.34rem 0.55rem; + font-size: 0.76rem; + } + + .streak-chip .streak-days-word { + display: none; + } + + .icon-btn { + width: 34px; + height: 34px; + border-radius: 8px; + font-size: 0.9rem; + } + + .main-container { + padding: 0.9rem 0.75rem 4.5rem; + } + + .hero-banner { + padding: 1.15rem; + margin-bottom: 1rem; + border-radius: var(--radius-md); + gap: 1rem; + } + + .hero-text h1 { + font-size: 1.22rem; + line-height: 1.3; + } + + .hero-text p { + font-size: 0.86rem; + } + + .gender-legend-bar { + gap: 0.35rem; + margin-top: 0.75rem; + } + + .gender-badge { + font-size: 0.72rem; + padding: 0.12rem 0.45rem; + } + + .hero-cta-group { + width: 100%; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + } + + .hero-cta-group .btn { + justify-content: center; + padding: 0.6rem 0.5rem; + font-size: 0.82rem; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + gap: 0.6rem; + margin-bottom: 1.1rem; + } + + .stat-card { + padding: 0.85rem 0.9rem; + } + + .stat-label { + font-size: 0.68rem; + } + + .stat-value { + font-size: 1.35rem; + } + + .stat-sub { + font-size: 0.72rem; + } + + .panel-card, + .srs-card-stage, + .reader-main { + padding: 1.1rem 0.95rem; + border-radius: var(--radius-md); + } + + .launcher-item { + padding: 0.8rem 0.85rem; + gap: 0.65rem; + } + + .launcher-icon { + width: 38px; + height: 38px; + font-size: 1.1rem; + } + + .launcher-title { + font-size: 0.9rem; + } + + .launcher-desc { + font-size: 0.78rem; + line-height: 1.35; + } + + /* Horizontal scrollable mode pills on mobile */ + .trainer-controls { + gap: 0.65rem; + margin-bottom: 1rem; + } + + .mode-pills { + flex-wrap: nowrap; + overflow-x: auto; + width: 100%; + padding-bottom: 0.15rem; + } + + .mode-pill { + flex-shrink: 0; + font-size: 0.8rem; + padding: 0.45rem 0.8rem; + } + + #trainer-category-select { + width: 100%; + } + + .prompt-main { + font-size: 1.25rem; + } + + .cloze-box { + padding: 0.8rem 0.95rem; + } + + .cloze-de { + font-size: 1.02rem; + } + + /* Stack input and buttons cleanly on mobile; 16px prevents iOS Safari auto-zoom */ + .answer-input-row { + flex-wrap: wrap; + gap: 0.5rem; + } + + .german-input { + width: 100%; + flex: 1 1 100%; + font-size: 16px !important; + padding: 0.68rem 0.85rem; + } + + .answer-input-row .btn { + flex: 1; + justify-content: center; + padding: 0.65rem 0.75rem; + } + + .umlaut-bar { + gap: 0.3rem; + margin-bottom: 0.9rem; + } + + .umlaut-key { + min-width: 36px; + height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.92rem; + padding: 0; + } + + /* 2x2 thumb-friendly grids on mobile */ + .gender-choice-grid, + .srs-grade-buttons { + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; + } + + .gender-choice-btn { + padding: 0.8rem; + font-size: 1rem; + } + + .prep-options-grid { + grid-template-columns: 1fr; + gap: 0.5rem; + } + + .dawo-options-grid { + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; + } + + /* Compound blocks wrap cleanly on mobile */ + .compound-blocks-row { + flex-direction: column; + gap: 0.35rem; + } + + .compound-plus { + justify-content: center; + font-size: 1rem; + line-height: 1; + } + + .compound-block { + width: 100%; + padding: 0.6rem 0.8rem; + } + + .story-de-line { + font-size: 1.05rem; + line-height: 1.75; + } + + .chat-bubble { + max-width: 94%; + padding: 0.8rem 0.95rem; + font-size: 0.9rem; + } + + .vocab-filter-bar { + flex-direction: column; + gap: 0.5rem; + } + + .vocab-filter-bar .german-input { + max-width: 100% !important; + } + + .vocab-grid, + .prefix-grid { + grid-template-columns: 1fr; + } + + .modal-card { + padding: 1.2rem; + max-height: 90vh; + overflow-y: auto; + } + + .toast-notification { + left: 0.75rem; + right: 0.75rem; + bottom: 0.75rem; + text-align: center; + } +} + +