Improve mobile UI responsiveness, touch tap-to-translate, and PWA icon
This commit is contained in:
1 parent
5f013dbdfe
commit
c3e66b4c98
6 files changed
+442
-28
No files matched your search
+1
-1
@@ -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/
|
||||
|
||||
|
||||
@@ -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 ? `<div class="hover-tt-note" style="color:#a1a1aa;font-size:0.74rem;margin-top:0.15rem;">Plural: ${info.plural}</div>` : ""}
|
||||
`;
|
||||
|
||||
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 {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem; margin: 1.25rem 0;">
|
||||
<div class="prep-options-grid">
|
||||
${optionsHtml}
|
||||
</div>
|
||||
|
||||
@@ -2416,7 +2446,7 @@ class WortKraftApp {
|
||||
<div class="cloze-en" style="margin-top: 0.3rem;">Gesuchtes Verb: <strong>${d.verb} (${d.english})</strong></div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.65rem; margin: 1.25rem 0;">
|
||||
<div class="dawo-options-grid">
|
||||
${daChoices
|
||||
.map(
|
||||
ch => `
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="WortKraft">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1E2633"/>
|
||||
<stop offset="100%" stop-color="#0F141C"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gold" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FBBF24"/>
|
||||
<stop offset="100%" stop-color="#D97706"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="16" fill="url(#bg)"/>
|
||||
<rect x="1" y="1" width="62" height="62" rx="15" fill="none" stroke="#FFFFFF" stroke-opacity="0.08" stroke-width="1"/>
|
||||
<path d="M42 4 H50 V16 L46 13 L42 16 Z" fill="#DC2626"/>
|
||||
<path d="M42 4 H50 V8 H42 Z" fill="#18181B"/>
|
||||
<path d="M42 11 H50 V16 L46 13 L42 16 Z" fill="#F59E0B"/>
|
||||
<path d="M12 20 C18 18 26 19 32 23 C38 19 46 18 52 20 V47 C46 45 38 46 32 50 C26 46 18 45 12 47 Z" fill="#FFFBEB" stroke="#FDE68A" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="32" y1="23" x2="32" y2="50" stroke="#D97706" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="17" y1="27" x2="27" y2="28" stroke="#2563EB" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<line x1="17" y1="33" x2="27" y2="34" stroke="#E11D48" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<line x1="17" y1="39" x2="25" y2="40" stroke="#059669" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<polygon points="43,24 36,34 41,34 39,44 48,32 42,32" fill="url(#gold)" stroke="#B45309" stroke-width="0.8" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
+16
-11
@@ -2,12 +2,17 @@
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#18181b" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<title>WortKraft — Beyond Duolingo German A2/B1</title>
|
||||
<link rel="icon" type="image/svg+xml" href="icon.svg" />
|
||||
<link rel="apple-touch-icon" href="icon.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="styles.css?v=5" />
|
||||
<link rel="stylesheet" href="styles.css?v=6" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -54,11 +59,11 @@
|
||||
</nav>
|
||||
|
||||
<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 id="hover-translate-toggle" class="hover-toggle-btn active" onclick="app.toggleHoverTranslate()" title="Wörter beim Drüberfahren (Hover) oder Antippen automatisch ins Englische übersetzen">
|
||||
🔍 <span class="hover-label-text">Hover-Übersetzer: </span><span id="hover-translate-status">AN</span>
|
||||
</button>
|
||||
<div class="streak-chip" title="1400+ Days Foundation + Active Recall Streak">
|
||||
🔥 <span id="streak-count">1401</span> Tage
|
||||
🔥 <span id="streak-count">1401</span> <span class="streak-days-word">Tage</span>
|
||||
</div>
|
||||
<button class="icon-btn" onclick="app.openModal('add-word-modal')" title="Neues Wort hinzufügen (+)">➕</button>
|
||||
<button class="icon-btn" onclick="app.openModal('settings-modal')" title="KI & Einstellungen">⚙️</button>
|
||||
@@ -491,11 +496,11 @@
|
||||
<div id="toast" class="toast-notification"></div>
|
||||
|
||||
<!-- SCRIPTS -->
|
||||
<script src="data/vocabulary.js?v=5"></script>
|
||||
<script src="data/stories.js?v=5"></script>
|
||||
<script src="data/scenarios.js?v=5"></script>
|
||||
<script src="data/hover_dict.js?v=5"></script>
|
||||
<script src="data/labs.js?v=5"></script>
|
||||
<script src="app.js?v=5"></script>
|
||||
<script src="data/vocabulary.js?v=6"></script>
|
||||
<script src="data/stories.js?v=6"></script>
|
||||
<script src="data/scenarios.js?v=6"></script>
|
||||
<script src="data/hover_dict.js?v=6"></script>
|
||||
<script src="data/labs.js?v=6"></script>
|
||||
<script src="app.js?v=6"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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')));
|
||||
|
||||
+355
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user