One-tap grids everywhere, hideable translation/hints, cleaner top and bottom bars
- Possessives, pronouns, and Kontraktionen switch from typed input to the same 4-choice tap grid as articles. Possessive choices all share the question's stem; pronoun choices lead with the pronoun's own case forms. - The translation line is hideable everywhere (shows by default, hidden state persists like the other hints, revealed again on a wrong answer). The possessive and pronoun hint lines get hide toggles too. - Header: Charts moves out; One-tap becomes a pill — plain grey when off, green outlined when on. More margin below the header across the board. - Bottom bar: progress dots centered with 'n/10 · level name' underneath, outlined Charts button (capital C) pinned bottom-right. - Touch devices hide the 1-4/enter/esc keyboard chips; answer buttons keep their full size via min-heights so tap targets stay big.
This commit is contained in:
+77
-58
@@ -169,6 +169,10 @@ const ROUND = 10;
|
||||
const GREEN = '#58a700';
|
||||
const RED = '#dc2626';
|
||||
|
||||
// Touch devices don't get keyboard hints (1–4, ↵) — but the answer buttons
|
||||
// keep the same footprint so tap targets stay big.
|
||||
const IS_TOUCH = typeof window !== 'undefined' && !!window.matchMedia?.('(pointer: coarse)').matches;
|
||||
|
||||
export default function App() {
|
||||
const [scr, setScr] = useState('home');
|
||||
const [tab, setTab] = useState('prep');
|
||||
@@ -282,7 +286,7 @@ export default function App() {
|
||||
if (chart) { if (e.key === 'Escape') setChart(false); return; }
|
||||
if (scr === 'summary' && e.key === 'Enter') { e.preventDefault(); summaryRef.current?.(); return; }
|
||||
if (scr !== 'practice') return;
|
||||
if (!rev && (q?.type === 'article' || q?.type === 'chunk') && q.choices) {
|
||||
if (!rev && q?.choices) {
|
||||
const idx = ['1','2','3','4'].indexOf(e.key);
|
||||
if (idx >= 0 && q.choices[idx]) { e.preventDefault(); submitRef.current(q.choices[idx]); return; }
|
||||
}
|
||||
@@ -296,7 +300,7 @@ export default function App() {
|
||||
if (e.key === '1') { e.preventDefault(); submitRef.current('accusative'); return; }
|
||||
if (e.key === '2') { e.preventDefault(); submitRef.current('dative'); return; }
|
||||
}
|
||||
if (e.key === 'Enter') { e.preventDefault(); if (rev) nextRef.current(); else if (q?.type !== 'article' && q?.type !== 'chunk') submitRef.current(); }
|
||||
if (e.key === 'Enter') { e.preventDefault(); if (rev) nextRef.current(); else if (!q?.choices && q?.type !== 'prep' && q?.type !== 'wechselContext') submitRef.current(); }
|
||||
if (rev && (e.key === 'ArrowRight' || e.key === ' ')) { e.preventDefault(); nextRef.current(); }
|
||||
};
|
||||
window.addEventListener('keydown', h);
|
||||
@@ -353,16 +357,15 @@ export default function App() {
|
||||
{scr !== 'home' && scr !== 'menu'
|
||||
? <button style={S.linkBtn} onClick={()=>setScr('menu')}>← back</button>
|
||||
: <div style={S.logo}>Beug</div>}
|
||||
<div style={{display:'flex',gap:14,alignItems:'center'}}>
|
||||
<div style={{display:'flex',gap:12,alignItems:'center'}}>
|
||||
{scr === 'practice' && (
|
||||
<button
|
||||
style={{...S.linkBtn, color: quickActive ? GREEN : '#999', fontWeight: quickActive ? 700 : 500}}
|
||||
style={{...S.pillBtn, ...(quickActive ? S.pillBtnOn : S.pillBtnOff)}}
|
||||
title={cur?.daily ? 'One-tap is always on in Daily Practice' : 'Answer with one tap, then auto-advance'}
|
||||
onClick={()=>{ if (cur?.daily) return; save({...st, auto: !st.auto}); }}>
|
||||
⚡ {quickActive ? 'One-tap' : 'One-tap off'}
|
||||
⚡ One-tap
|
||||
</button>
|
||||
)}
|
||||
{scr === 'practice' && <button style={S.linkBtn} onClick={()=>setChart(true)}>charts</button>}
|
||||
{PAYMENTS_ENABLED && hasPaidAccess && <span style={S.stat}>Unlocked</span>}
|
||||
<StreakBadge st={st}/>
|
||||
</div>
|
||||
@@ -549,7 +552,9 @@ export default function App() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={S.translation}>{q.translation}</div>
|
||||
<Hideable hidden={hintHidden('translation') && !rev} onToggle={()=>toggleHint('translation')} label="translation" style={S.translation}>
|
||||
{q.translation}
|
||||
</Hideable>
|
||||
|
||||
{(q.type === 'article' || q.type === 'chunk') ? (
|
||||
<Hideable hidden={hintHidden('gender')} onToggle={()=>toggleHint('gender')} label="gender" style={S.keyInfo}>
|
||||
@@ -557,18 +562,17 @@ export default function App() {
|
||||
{' '}{q.noun.w}
|
||||
<span style={S.keyInfoMute}> ({q.noun.en})</span>
|
||||
</Hideable>
|
||||
) : q.type === 'poss' ? (
|
||||
<Hideable hidden={hintHidden('possHint')} onToggle={()=>toggleHint('possHint')} label="possessive" style={S.keyInfo}>
|
||||
<span style={{fontWeight:700}}>{q.stem}-</span>
|
||||
<span style={S.keyInfoMute}> ({q.possMeaning})</span>
|
||||
</Hideable>
|
||||
) : (
|
||||
<div style={S.keyInfo}>
|
||||
{q.type === 'poss' && <>
|
||||
<span style={{fontWeight:700}}>{q.stem}-</span>
|
||||
<span style={S.keyInfoMute}> ({q.possMeaning})</span>
|
||||
</>}
|
||||
{q.type === 'pronoun' && <>
|
||||
<span style={{fontWeight:700}}>{q.pron.nom}</span>
|
||||
<span style={S.keyInfoMute}> ({q.pron.en})</span>
|
||||
<span style={S.keyInfoMute}> · {q.pron.tag}</span>
|
||||
</>}
|
||||
</div>
|
||||
<Hideable hidden={hintHidden('pronounHint')} onToggle={()=>toggleHint('pronounHint')} label="pronoun" style={S.keyInfo}>
|
||||
<span style={{fontWeight:700}}>{q.pron.nom}</span>
|
||||
<span style={S.keyInfoMute}> ({q.pron.en})</span>
|
||||
<span style={S.keyInfoMute}> · {q.pron.tag}</span>
|
||||
</Hideable>
|
||||
)}
|
||||
|
||||
<Hideable hidden={hintHidden('caseInfo')} onToggle={()=>toggleHint('caseInfo')} label="case" style={S.secondaryContext}>
|
||||
@@ -579,15 +583,8 @@ export default function App() {
|
||||
</Hideable>
|
||||
|
||||
{!rev ? (
|
||||
(q.type === 'article' || q.type === 'chunk') ? (
|
||||
<div style={S.articleGrid}>
|
||||
{q.choices.map((choice, i) => (
|
||||
<button key={choice} style={S.articleBtn} onClick={()=>doSubmit(choice)}>
|
||||
<div style={{fontSize: choice.includes(' ') ? 17 : 22, fontWeight:700}}>{choice}</div>
|
||||
<div style={{fontSize:11,color:'#999',marginTop:4}}><kbd style={S.kbdSmall}>{i+1}</kbd></div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
q.choices ? (
|
||||
<AnswerGrid choices={q.choices} onPick={doSubmit}/>
|
||||
) : (
|
||||
<div style={S.inputWrap}>
|
||||
<div style={S.inputRow}>
|
||||
@@ -599,7 +596,7 @@ export default function App() {
|
||||
inputMode="text"/>
|
||||
<button style={S.checkBtn} onClick={()=>doSubmit()}>Check</button>
|
||||
</div>
|
||||
<div style={S.hintText}>or press <kbd style={S.kbd}>Enter</kbd></div>
|
||||
{!IS_TOUCH && <div style={S.hintText}>or press <kbd style={S.kbd}>Enter</kbd></div>}
|
||||
</div>
|
||||
)
|
||||
) : quickTick ? (
|
||||
@@ -629,7 +626,7 @@ export default function App() {
|
||||
<button key={val} style={{...S.choiceBtn, borderColor: CASE_C[val]+'40'}}
|
||||
onClick={()=>doSubmit(val)}>
|
||||
<div style={{fontWeight:700, color: CASE_C[val]}}>{lab}</div>
|
||||
<div style={{fontSize:11,color:'#999',marginTop:2}}><kbd style={S.kbdSmall}>{key}</kbd></div>
|
||||
{!IS_TOUCH && <div style={{fontSize:11,color:'#999',marginTop:2}}><kbd style={S.kbdSmall}>{key}</kbd></div>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -645,11 +642,11 @@ export default function App() {
|
||||
{scr === 'practice' && q?.type === 'wechselContext' && (
|
||||
<div style={{textAlign:'center', paddingTop:18}}>
|
||||
<div style={{...S.sentence, animation:shake?'shake 0.3s':'none'}}>{q.sentence}</div>
|
||||
{(!hintHidden('meaning') || rev) ? (
|
||||
{(!hintHidden('translation') || rev) ? (
|
||||
<>
|
||||
<div style={S.translation}>
|
||||
{q.translation}
|
||||
{!rev && <button style={{...S.hintToggle, marginLeft:8}} onClick={()=>toggleHint('meaning')}>hide</button>}
|
||||
{!rev && <button style={{...S.hintToggle, marginLeft:8}} onClick={()=>toggleHint('translation')}>hide</button>}
|
||||
</div>
|
||||
<div style={S.keyInfo}>
|
||||
<span style={{fontWeight:700}}>{q.prepKey}</span>
|
||||
@@ -659,7 +656,7 @@ export default function App() {
|
||||
) : (
|
||||
<>
|
||||
<div style={S.translation}>
|
||||
<button style={S.hintToggle} onClick={()=>toggleHint('meaning')}>show translation</button>
|
||||
<button style={S.hintToggle} onClick={()=>toggleHint('translation')}>show translation</button>
|
||||
</div>
|
||||
<div style={S.keyInfo}>
|
||||
<span style={{fontWeight:700}}>{q.prepKey}</span>
|
||||
@@ -674,7 +671,7 @@ export default function App() {
|
||||
<button key={val} style={{...S.choiceBtn, borderColor: CASE_C[val]+'40', minWidth:130}}
|
||||
onClick={()=>doSubmit(val)}>
|
||||
<div style={{fontWeight:700, color: CASE_C[val]}}>{lab}</div>
|
||||
<div style={{fontSize:11,color:'#999',marginTop:2}}><kbd style={S.kbdSmall}>{key}</kbd></div>
|
||||
{!IS_TOUCH && <div style={{fontSize:11,color:'#999',marginTop:2}}><kbd style={S.kbdSmall}>{key}</kbd></div>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -697,16 +694,7 @@ export default function App() {
|
||||
<span style={{color:CASE_C[q.cas], fontWeight:600}}>{CL[q.cas]}</span>
|
||||
</div>
|
||||
{!rev ? (
|
||||
<div style={S.inputWrap}>
|
||||
<div style={S.inputRow}>
|
||||
<input ref={iref} value={inp} onChange={e=>setInp(e.target.value)}
|
||||
style={S.input} placeholder="contraction…"
|
||||
autoComplete="off" autoCapitalize="off" spellCheck={false}
|
||||
enterKeyHint="go" inputMode="text"/>
|
||||
<button style={S.checkBtn} onClick={()=>doSubmit()}>Check</button>
|
||||
</div>
|
||||
<div style={S.hintText}>or press <kbd style={S.kbd}>Enter</kbd></div>
|
||||
</div>
|
||||
<AnswerGrid choices={q.choices} onPick={doSubmit}/>
|
||||
) : quickTick ? (
|
||||
<QuickTick/>
|
||||
) : (
|
||||
@@ -716,20 +704,25 @@ export default function App() {
|
||||
)}
|
||||
|
||||
{scr === 'practice' && (
|
||||
<div style={S.dotsRow}>
|
||||
{Array.from({length:ROUND},(_,i)=>(
|
||||
<div key={i} style={{...S.dot,
|
||||
background: i<res.length?(res[i]?GREEN:RED):i===res.length?'#333':'#ddd',
|
||||
}}/>
|
||||
))}
|
||||
<span style={S.dotsLabel}>{Math.min(res.length+(rev?0:1), ROUND)}/{ROUND} · {cur?.name}</span>
|
||||
<div style={S.bottomBar}>
|
||||
<div style={S.bottomBarInner}>
|
||||
<div style={S.dotsRow}>
|
||||
{Array.from({length:ROUND},(_,i)=>(
|
||||
<div key={i} style={{...S.dot,
|
||||
background: i<res.length?(res[i]?GREEN:RED):i===res.length?'#333':'#ddd',
|
||||
}}/>
|
||||
))}
|
||||
</div>
|
||||
<div style={S.dotsLabel}>{Math.min(res.length+(rev?0:1), ROUND)}/{ROUND} · {cur?.name}</div>
|
||||
<button style={S.chartsPill} onClick={()=>setChart(true)}>Charts</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scr === 'practice' && rev && !quickTick && (
|
||||
<div style={{textAlign:'center', marginTop:14}}>
|
||||
<button style={S.nextBtn} onClick={doNext}>
|
||||
{res.length >= ROUND ? 'See results' : 'Next'} <span style={{opacity:0.5,fontSize:12,marginLeft:4}}>↵</span>
|
||||
{res.length >= ROUND ? 'See results' : 'Next'}{!IS_TOUCH && <span style={{opacity:0.5,fontSize:12,marginLeft:4}}>↵</span>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -750,7 +743,7 @@ export default function App() {
|
||||
{cur?.daily && nextDaily ? (
|
||||
<>
|
||||
<button style={S.nextBtn} onClick={()=>startLv(nextDaily.level.id)}>
|
||||
Continue: {nextDaily.tab.label} Daily Practice → <span style={{opacity:0.5,fontSize:12,marginLeft:4}}>↵</span>
|
||||
Continue: {nextDaily.tab.label} Daily Practice →{!IS_TOUCH && <span style={{opacity:0.5,fontSize:12,marginLeft:4}}>↵</span>}
|
||||
</button>
|
||||
<div style={{marginTop:14}}>
|
||||
<button style={S.linkBtn} onClick={()=>startLv(lv)}>play again</button>
|
||||
@@ -950,6 +943,21 @@ function PaywallScreen({ message, onCheckout, onBack }) {
|
||||
// FEEDBACK COMPONENTS
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
// Shared 2×2 answer grid. On touch devices the number-key chips disappear
|
||||
// but the buttons keep their footprint so tap targets stay big.
|
||||
function AnswerGrid({ choices, onPick }) {
|
||||
return (
|
||||
<div style={S.articleGrid}>
|
||||
{choices.map((choice, i) => (
|
||||
<button key={choice} style={S.articleBtn} onClick={()=>onPick(choice)}>
|
||||
<div style={{fontSize: choice.includes(' ') ? 17 : 22, fontWeight:700}}>{choice}</div>
|
||||
{!IS_TOUCH && <div style={{fontSize:11,color:'#999',marginTop:4}}><kbd style={S.kbdSmall}>{i+1}</kbd></div>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One-tap correct answer: no solution shown, just a tick before auto-skip.
|
||||
function QuickTick() {
|
||||
return (
|
||||
@@ -1267,7 +1275,7 @@ function ChartModal({ section, q, onClose }) {
|
||||
)}
|
||||
|
||||
<button style={{...S.nextBtn, width:'100%', marginTop:10}} onClick={onClose}>
|
||||
Close <span style={{opacity:0.5,fontSize:12,marginLeft:4}}>esc</span>
|
||||
Close{!IS_TOUCH && <span style={{opacity:0.5,fontSize:12,marginLeft:4}}>esc</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1364,11 +1372,16 @@ const S = {
|
||||
wrap: { background:'#f7f7f7', minHeight:'100vh', color:'#1a1a1a', fontFamily:'"DM Sans",system-ui,sans-serif' },
|
||||
inner: { maxWidth:520, margin:'0 auto', padding:'16px 20px 100px' },
|
||||
|
||||
hdr: { display:'flex', justifyContent:'space-between', alignItems:'center', padding:'10px 0 8px', position:'sticky', top:0, background:'#f7f7f7', zIndex:10 },
|
||||
hdr: { display:'flex', justifyContent:'space-between', alignItems:'center', padding:'10px 0 8px', position:'sticky', top:0, background:'#f7f7f7', zIndex:10, marginBottom:16 },
|
||||
logo: { fontSize:17, fontWeight:700, color:'#1a1a1a' },
|
||||
linkBtn: { background:'none', border:'none', color:'#999', fontSize:13, cursor:'pointer', padding:0, fontWeight:500, fontFamily:'inherit' },
|
||||
stat: { fontSize:13, color:'#999', fontWeight:500 },
|
||||
|
||||
pillBtn: { borderRadius:999, padding:'5px 12px', fontSize:12, fontWeight:600, cursor:'pointer', fontFamily:'inherit', lineHeight:1.2, background:'none' },
|
||||
pillBtnOff: { border:'none', color:'#999' },
|
||||
pillBtnOn: { border:`1.5px solid ${GREEN}`, color:GREEN, background:'#f3ffe8' },
|
||||
chartsPill: { position:'absolute', right:20, bottom:2, borderRadius:999, padding:'5px 13px', fontSize:12, fontWeight:600, cursor:'pointer', fontFamily:'inherit', lineHeight:1.2, border:'1.5px solid #d5d5d5', color:'#777', background:'#fff' },
|
||||
|
||||
tabs: { display:'flex', gap:0, borderBottom:'1px solid #eee', marginTop:6, marginBottom:12 },
|
||||
tab: { flex:1, background:'none', border:'none', padding:'12px 8px', cursor:'pointer', color:'#999', fontSize:13, fontWeight:600, fontFamily:'inherit', borderBottomWidth:2, borderBottomStyle:'solid', borderBottomColor:'transparent', marginBottom:-1, transition:'all 0.15s' },
|
||||
tabActive: { color:'#1a1a1a', borderBottomColor:GREEN },
|
||||
@@ -1410,9 +1423,13 @@ const S = {
|
||||
kbdSmall: { background:'#f0f0f0', padding:'1px 4px', borderRadius:3, fontSize:9, color:'#888', border:'1px solid #e0e0e0' },
|
||||
|
||||
articleGrid: { display:'grid', gridTemplateColumns:'1fr 1fr', gap:10, maxWidth:320, margin:'12px auto 0' },
|
||||
articleBtn: { padding:'18px 16px', borderRadius:10, border:'1.5px solid #ddd', background:'#fff', color:'#1a1a1a', cursor:'pointer', fontFamily:'inherit', textAlign:'center', transition:'all 0.12s' },
|
||||
// On touch the keyboard chips are hidden; the min-heights keep the tap
|
||||
// targets exactly as big as the desktop buttons with chips.
|
||||
articleBtn: { padding:'18px 16px', borderRadius:10, border:'1.5px solid #ddd', background:'#fff', color:'#1a1a1a', cursor:'pointer', fontFamily:'inherit', textAlign:'center', transition:'all 0.12s',
|
||||
...(IS_TOUCH ? {minHeight:80, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center'} : {}) },
|
||||
|
||||
choiceBtn: { flex:'1 1 auto', minWidth:90, padding:'14px 16px', borderRadius:10, border:'1.5px solid', background:'#fff', color:'#1a1a1a', cursor:'pointer', fontSize:14, fontFamily:'inherit', textAlign:'center', transition:'all 0.12s' },
|
||||
choiceBtn: { flex:'1 1 auto', minWidth:90, padding:'14px 16px', borderRadius:10, border:'1.5px solid', background:'#fff', color:'#1a1a1a', cursor:'pointer', fontSize:14, fontFamily:'inherit', textAlign:'center', transition:'all 0.12s',
|
||||
...(IS_TOUCH ? {minHeight:66, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center'} : {}) },
|
||||
|
||||
feedback: { textAlign:'left', background:'#fff', borderRadius:10, padding:'14px 18px', margin:'0 auto', maxWidth:400, border:'1px solid #eee' },
|
||||
|
||||
@@ -1420,8 +1437,10 @@ const S = {
|
||||
stripH: { padding:'4px 6px', fontSize:10, fontWeight:600, textTransform:'uppercase', letterSpacing:0.3, color:'#bbb', textAlign:'center' },
|
||||
stripD: { padding:'6px', textAlign:'center', fontSize:13 },
|
||||
|
||||
dotsRow: { display:'flex', gap:5, justifyContent:'center', alignItems:'center', position:'fixed', bottom:20, left:0, right:0 },
|
||||
dotsLabel: { fontSize:11, color:'#aaa', marginLeft:10 },
|
||||
bottomBar: { position:'fixed', bottom:14, left:0, right:0, zIndex:5 },
|
||||
bottomBarInner: { position:'relative', maxWidth:520, margin:'0 auto', padding:'0 20px' },
|
||||
dotsRow: { display:'flex', gap:5, justifyContent:'center', alignItems:'center' },
|
||||
dotsLabel: { fontSize:11, color:'#aaa', textAlign:'center', marginTop:6 },
|
||||
dot: { width:7, height:7, borderRadius:4, transition:'background 0.2s' },
|
||||
|
||||
nextBtn: { padding:'11px 22px', borderRadius:8, border:'none', background:GREEN, color:'#fff', fontSize:14, fontWeight:700, cursor:'pointer', fontFamily:'inherit' },
|
||||
|
||||
Reference in New Issue
Block a user