feat: remove header row, add unlock gate + ring animation to Satz card

- Remove lang/points header from all 3 card types
- PairSentenceCard: Verstanden button locked until audio played OR
  translate button held for 2 seconds
- Hold button shows circular gold ring filling up over 2s while held
- TTS button highlights gold when unlocked
- .pair-btn-locked style for disabled state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 13:04:38 +02:00
parent f299769ee9
commit a0572c928b
4 changed files with 89 additions and 60 deletions

View File

@@ -71,12 +71,31 @@
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
flex-shrink: 0; flex-shrink: 0;
transition: background 0.15s; transition: background 0.15s, color 0.15s;
color: #7A6E55;
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
} }
.pair-icon-btn:hover { background: #E0DAC8; } .pair-icon-btn:hover { background: #E0DAC8; }
.pair-icon-btn.active { background: #E0DAC8; } .pair-icon-btn.active { background: #C4A85A22; color: #B07840; }
/* Hold-to-translate button */
.pair-hold-wrap {
width: 38px;
height: 38px;
flex-shrink: 0;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
border-radius: 10px;
overflow: visible;
}
/* Ring fill animation — 2 seconds */
@keyframes holdRing {
from { stroke-dashoffset: 100.53; }
to { stroke-dashoffset: 0; }
}
/* ── Image area ── */ /* ── Image area ── */
.pair-image-wrap { .pair-image-wrap {
@@ -278,6 +297,11 @@
background: #5C3D22; background: #5C3D22;
color: #F5EDE0; color: #F5EDE0;
} }
.pair-btn-locked {
background: #E0DDD5;
color: #B0A898;
cursor: not-allowed;
}
.pair-btn-yes { .pair-btn-yes {
background: #3D7055; background: #3D7055;
color: #fff; color: #fff;

View File

@@ -93,11 +93,16 @@ function toPlainText(sentence) {
const LANG_LABELS = { sv: 'Svenska', en: 'English', de: 'Deutsch' } const LANG_LABELS = { sv: 'Svenska', en: 'English', de: 'Deutsch' }
const LANG_TTS = { sv: 'sv-SE', en: 'en-US', de: 'de-DE' } const LANG_TTS = { sv: 'sv-SE', en: 'en-US', de: 'de-DE' }
// Circumference of r=16 circle ≈ 100.53
const RING_C = 2 * Math.PI * 16
export default function PairSentenceCard({ card, onComplete }) { export default function PairSentenceCard({ card, onComplete }) {
const [done, setDone] = useState(false) const [done, setDone] = useState(false)
const [activeChip, setActiveChip] = useState(null) const [activeChip, setActiveChip] = useState(null)
const [showTranslation, setShowTranslation] = useState(false) const [showTranslation, setShowTranslation] = useState(false)
const holdTimer = useRef(null) const [holding, setHolding] = useState(false)
const [unlocked, setUnlocked] = useState(false)
const holdCompleted = useRef(false)
const lang = card.lang || 'de' const lang = card.lang || 'de'
const native = lang === 'de' ? 'en' : 'de' const native = lang === 'de' ? 'en' : 'de'
@@ -117,6 +122,7 @@ export default function PairSentenceCard({ card, onComplete }) {
} }
function handleConfirm() { function handleConfirm() {
if (!unlocked) return
setDone(true) setDone(true)
setActiveChip(null) setActiveChip(null)
triggerConfetti() triggerConfetti()
@@ -130,14 +136,21 @@ export default function PairSentenceCard({ card, onComplete }) {
utt.lang = LANG_TTS[lang] || 'de-DE' utt.lang = LANG_TTS[lang] || 'de-DE'
utt.rate = 0.9 utt.rate = 0.9
window.speechSynthesis.speak(utt) window.speechSynthesis.speak(utt)
setUnlocked(true)
} }
function startTranslation() { function startHold() {
holdTimer.current = setTimeout(() => setShowTranslation(true), 150) holdCompleted.current = false
setHolding(true)
setShowTranslation(true)
} }
function endTranslation() { function endHold() {
clearTimeout(holdTimer.current) setHolding(false)
setShowTranslation(false) if (!holdCompleted.current) setShowTranslation(false)
}
function onHoldComplete() {
holdCompleted.current = true
setUnlocked(true)
} }
return ( return (
@@ -159,17 +172,7 @@ export default function PairSentenceCard({ card, onComplete }) {
{isObject && <SelectionOverlay chip={activeChip} />} {isObject && <SelectionOverlay chip={activeChip} />}
</div> </div>
{/* Header below image */} <div className="pair-card-body" onClick={e => e.stopPropagation()} style={{ paddingTop: 18 }}>
<div className="pair-card-header">
<span className="pair-lang-pill">{LANG_LABELS[lang] || lang}</span>
<span className="pair-points-pill">
<svg width="13" height="13" viewBox="0 0 24 24" fill="#C4A85A"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
+{card.meta?.points ?? 2} Punkte
</span>
</div>
<div className="pair-header-divider" />
<div className="pair-card-body" onClick={e => e.stopPropagation()}>
{/* Sentence + action buttons */} {/* Sentence + action buttons */}
<p className="pair-section-label">Satz</p> <p className="pair-section-label">Satz</p>
@@ -184,7 +187,7 @@ export default function PairSentenceCard({ card, onComplete }) {
color: '#7A7060', color: '#7A7060',
transition: 'opacity 0.18s', transition: 'opacity 0.18s',
margin: 0, margin: 0,
marginTop: showTranslation ? 0 : '-1.7em', /* overlay effect */ marginTop: showTranslation ? 0 : '-1.7em',
pointerEvents: 'none', pointerEvents: 'none',
}}> }}>
{resolveSentence(hint, card.placeholders, null, null)} {resolveSentence(hint, card.placeholders, null, null)}
@@ -193,30 +196,52 @@ export default function PairSentenceCard({ card, onComplete }) {
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 2 }}>
{/* TTS */} {/* TTS — playing unlocks "Verstanden" */}
<button className="pair-icon-btn" onClick={handleTTS} title="Vorlesen"> <button className={`pair-icon-btn${unlocked ? ' active' : ''}`} onClick={handleTTS} title="Vorlesen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#7A6E55" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/> <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/> <path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/> <path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
</svg> </svg>
</button> </button>
{/* Hold-to-translate */}
{/* Hold-to-translate: 2 s hold unlocks "Verstanden" */}
{hint && ( {hint && (
<button <div className="pair-hold-wrap"
className={`pair-icon-btn${showTranslation ? ' active' : ''}`} onMouseDown={startHold}
onMouseDown={startTranslation} onMouseUp={endHold}
onMouseUp={endTranslation} onMouseLeave={endHold}
onMouseLeave={endTranslation} onTouchStart={e => { e.preventDefault(); startHold() }}
onTouchStart={e => { e.preventDefault(); startTranslation() }} onTouchEnd={endHold}
onTouchEnd={endTranslation} title="2 s halten zum Übersetzen"
title="Übersetzung halten"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#7A6E55" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"> <svg width="38" height="38" viewBox="0 0 38 38" style={{ display: 'block' }}>
{/* Button fill */}
<rect x="1" y="1" width="36" height="36" rx="9" ry="9"
fill="#F0EDE3" stroke="#D8D3C5" strokeWidth="0.5"/>
{/* Progress ring track */}
<circle cx="19" cy="19" r="16" fill="none" stroke="#E0DDD5" strokeWidth="2.5"/>
{/* Progress ring — animates when holding */}
{holding && (
<circle
cx="19" cy="19" r="16"
fill="none"
stroke="#C4A85A"
strokeWidth="2.5"
strokeDasharray={RING_C}
strokeDashoffset={RING_C}
strokeLinecap="round"
style={{ transformOrigin: '19px 19px', transform: 'rotate(-90deg)', animation: 'holdRing 2s linear forwards' }}
onAnimationEnd={onHoldComplete}
/>
)}
{/* Translate icon */}
<g transform="translate(10, 10)" fill="none" stroke="#7A6E55" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 8l6 6"/><path d="M4 14l6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/> <path d="M5 8l6 6"/><path d="M4 14l6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/>
<path d="M22 22l-5-10-5 10"/><path d="M14 18h6"/> <path d="M14 14l-3-6-3 6"/><path d="M6 12h4" transform="translate(8,0)"/>
</g>
</svg> </svg>
</button> </div>
)} )}
</div> </div>
</div> </div>
@@ -235,9 +260,9 @@ export default function PairSentenceCard({ card, onComplete }) {
<div className="pair-btn-row" style={{ marginTop: 20 }}> <div className="pair-btn-row" style={{ marginTop: 20 }}>
<button <button
className={`pair-btn ${done ? 'pair-btn-correct' : 'pair-btn-primary'}`} className={`pair-btn ${done ? 'pair-btn-correct' : unlocked ? 'pair-btn-primary' : 'pair-btn-locked'}`}
onClick={handleConfirm} onClick={handleConfirm}
disabled={done} disabled={done || !unlocked}
> >
{done ? '✓ Verstanden' : 'Verstanden'} {done ? '✓ Verstanden' : 'Verstanden'}
</button> </button>

View File

@@ -139,17 +139,7 @@ export default function PairWordCard({ card, onComplete }) {
{isObject && <SelectionOverlay chip={activeChip} />} {isObject && <SelectionOverlay chip={activeChip} />}
</div> </div>
{/* Header below image */} <div className="pair-card-body" onClick={e => e.stopPropagation()} style={{ paddingTop: 18 }}>
<div className="pair-card-header">
<span className="pair-lang-pill">{lang === 'sv' ? 'Svenska' : lang === 'en' ? 'English' : 'Deutsch'}</span>
<span className="pair-points-pill">
<svg width="13" height="13" viewBox="0 0 24 24" fill="#C4A85A"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
+{card.meta?.points ?? 3} Punkte
</span>
</div>
<div className="pair-header-divider" />
<div className="pair-card-body" onClick={e => e.stopPropagation()}>
<p className="pair-section-label">Frage</p> <p className="pair-section-label">Frage</p>
<p className="pair-question"> <p className="pair-question">
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)} {resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}

View File

@@ -124,17 +124,7 @@ export default function PairYesNoCard({ card, onComplete }) {
{isObject && <SelectionOverlay chip={activeChip} />} {isObject && <SelectionOverlay chip={activeChip} />}
</div> </div>
{/* Header below image */} <div className="pair-card-body" onClick={e => e.stopPropagation()} style={{ paddingTop: 18 }}>
<div className="pair-card-header">
<span className="pair-lang-pill">{lang === 'sv' ? 'Svenska' : lang === 'en' ? 'English' : 'Deutsch'}</span>
<span className="pair-points-pill">
<svg width="13" height="13" viewBox="0 0 24 24" fill="#C4A85A"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
+{card.meta?.points ?? 2} Punkte
</span>
</div>
<div className="pair-header-divider" />
<div className="pair-card-body" onClick={e => e.stopPropagation()}>
<p className="pair-section-label">Frage</p> <p className="pair-section-label">Frage</p>
<p className="pair-question"> <p className="pair-question">
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)} {resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}