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;
padding: 0;
flex-shrink: 0;
transition: background 0.15s;
transition: background 0.15s, color 0.15s;
color: #7A6E55;
-webkit-user-select: none;
user-select: none;
}
.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 ── */
.pair-image-wrap {
@@ -278,6 +297,11 @@
background: #5C3D22;
color: #F5EDE0;
}
.pair-btn-locked {
background: #E0DDD5;
color: #B0A898;
cursor: not-allowed;
}
.pair-btn-yes {
background: #3D7055;
color: #fff;

View File

@@ -93,11 +93,16 @@ function toPlainText(sentence) {
const LANG_LABELS = { sv: 'Svenska', en: 'English', de: 'Deutsch' }
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 }) {
const [done, setDone] = useState(false)
const [activeChip, setActiveChip] = useState(null)
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 native = lang === 'de' ? 'en' : 'de'
@@ -117,6 +122,7 @@ export default function PairSentenceCard({ card, onComplete }) {
}
function handleConfirm() {
if (!unlocked) return
setDone(true)
setActiveChip(null)
triggerConfetti()
@@ -130,14 +136,21 @@ export default function PairSentenceCard({ card, onComplete }) {
utt.lang = LANG_TTS[lang] || 'de-DE'
utt.rate = 0.9
window.speechSynthesis.speak(utt)
setUnlocked(true)
}
function startTranslation() {
holdTimer.current = setTimeout(() => setShowTranslation(true), 150)
function startHold() {
holdCompleted.current = false
setHolding(true)
setShowTranslation(true)
}
function endTranslation() {
clearTimeout(holdTimer.current)
setShowTranslation(false)
function endHold() {
setHolding(false)
if (!holdCompleted.current) setShowTranslation(false)
}
function onHoldComplete() {
holdCompleted.current = true
setUnlocked(true)
}
return (
@@ -159,17 +172,7 @@ export default function PairSentenceCard({ card, onComplete }) {
{isObject && <SelectionOverlay chip={activeChip} />}
</div>
{/* Header below image */}
<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()}>
<div className="pair-card-body" onClick={e => e.stopPropagation()} style={{ paddingTop: 18 }}>
{/* Sentence + action buttons */}
<p className="pair-section-label">Satz</p>
@@ -184,7 +187,7 @@ export default function PairSentenceCard({ card, onComplete }) {
color: '#7A7060',
transition: 'opacity 0.18s',
margin: 0,
marginTop: showTranslation ? 0 : '-1.7em', /* overlay effect */
marginTop: showTranslation ? 0 : '-1.7em',
pointerEvents: 'none',
}}>
{resolveSentence(hint, card.placeholders, null, null)}
@@ -193,30 +196,52 @@ export default function PairSentenceCard({ card, onComplete }) {
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 2 }}>
{/* TTS */}
<button className="pair-icon-btn" 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">
{/* TTS — playing unlocks "Verstanden" */}
<button className={`pair-icon-btn${unlocked ? ' active' : ''}`} onClick={handleTTS} title="Vorlesen">
<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"/>
<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"/>
</svg>
</button>
{/* Hold-to-translate */}
{/* Hold-to-translate: 2 s hold unlocks "Verstanden" */}
{hint && (
<button
className={`pair-icon-btn${showTranslation ? ' active' : ''}`}
onMouseDown={startTranslation}
onMouseUp={endTranslation}
onMouseLeave={endTranslation}
onTouchStart={e => { e.preventDefault(); startTranslation() }}
onTouchEnd={endTranslation}
title="Übersetzung halten"
<div className="pair-hold-wrap"
onMouseDown={startHold}
onMouseUp={endHold}
onMouseLeave={endHold}
onTouchStart={e => { e.preventDefault(); startHold() }}
onTouchEnd={endHold}
title="2 s halten zum Übersetzen"
>
<svg width="18" height="18" viewBox="0 0 24 24" 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="M22 22l-5-10-5 10"/><path d="M14 18h6"/>
<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="M14 14l-3-6-3 6"/><path d="M6 12h4" transform="translate(8,0)"/>
</g>
</svg>
</button>
</div>
)}
</div>
</div>
@@ -235,9 +260,9 @@ export default function PairSentenceCard({ card, onComplete }) {
<div className="pair-btn-row" style={{ marginTop: 20 }}>
<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}
disabled={done}
disabled={done || !unlocked}
>
{done ? '✓ Verstanden' : 'Verstanden'}
</button>

View File

@@ -139,17 +139,7 @@ export default function PairWordCard({ card, onComplete }) {
{isObject && <SelectionOverlay chip={activeChip} />}
</div>
{/* Header below image */}
<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()}>
<div className="pair-card-body" onClick={e => e.stopPropagation()} style={{ paddingTop: 18 }}>
<p className="pair-section-label">Frage</p>
<p className="pair-question">
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}

View File

@@ -124,17 +124,7 @@ export default function PairYesNoCard({ card, onComplete }) {
{isObject && <SelectionOverlay chip={activeChip} />}
</div>
{/* Header below image */}
<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()}>
<div className="pair-card-body" onClick={e => e.stopPropagation()} style={{ paddingTop: 18 }}>
<p className="pair-section-label">Frage</p>
<p className="pair-question">
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}