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:
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
Reference in New Issue
Block a user