274 lines
9.9 KiB
JavaScript
274 lines
9.9 KiB
JavaScript
import { useState, useRef } from 'react'
|
||
import confetti from 'canvas-confetti'
|
||
import './PairCards.css'
|
||
|
||
function triggerConfetti() {
|
||
confetti({
|
||
particleCount: 90,
|
||
spread: 70,
|
||
origin: { y: 0.55 },
|
||
colors: ['#C4A85A', '#7A5C2E', '#3D7055', '#E8C9A8', '#fff'],
|
||
scalar: 0.9,
|
||
gravity: 1.1,
|
||
})
|
||
}
|
||
|
||
function SelectionOverlay({ chip }) {
|
||
const sels = chip?.selections
|
||
if (!sels?.length) return null
|
||
const maskId = `selmask-${chip.id.slice(0, 8)}`
|
||
const label = chip.label || ''
|
||
const toPoints = pts => pts.map(p => `${p.x * 100},${p.y * 100}`).join(' ')
|
||
const firstPts = sels[0].points
|
||
const xs = firstPts.map(p => p.x * 100)
|
||
const ys = firstPts.map(p => p.y * 100)
|
||
const cx = (Math.min(...xs) + Math.max(...xs)) / 2
|
||
const labelY = Math.min(Math.max(...ys) + 6, 94)
|
||
return (
|
||
<svg className="pair-bbox-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||
<defs>
|
||
<mask id={maskId}>
|
||
<rect width="100" height="100" fill="white" />
|
||
{sels.map((s, i) => <polygon key={i} points={toPoints(s.points)} fill="black" />)}
|
||
</mask>
|
||
</defs>
|
||
<rect width="100" height="100" fill="rgba(0,0,0,0.5)" mask={`url(#${maskId})`} />
|
||
{sels.map((s, i) => (
|
||
<polygon key={i} points={toPoints(s.points)}
|
||
fill="rgba(255,215,100,0.08)" stroke="rgba(255,215,100,0.92)"
|
||
strokeWidth="0.8" strokeLinejoin="round" />
|
||
))}
|
||
{label && (
|
||
<text x={cx} y={labelY} textAnchor="middle"
|
||
fill="white" fontSize="5.5" fontWeight="700" fontFamily="Nunito, sans-serif"
|
||
style={{ filter: 'drop-shadow(0 1px 4px rgba(0,0,0,1))' }}>
|
||
{label}
|
||
</text>
|
||
)}
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
// Sentence format: {{label.w:uuid}} or {{label.o:uuid}}
|
||
function resolveSentence(sentence, placeholders, onChipClick, activeId) {
|
||
if (!sentence) return null
|
||
const parts = sentence.split(/(\{\{[^}]+\.[wo]:[0-9a-f-]{36}\}\})/)
|
||
return parts.map((part, i) => {
|
||
const m = part.match(/^\{\{([^.]+)\.(w|o):([0-9a-f-]{36})\}\}$/)
|
||
if (m) {
|
||
const label = m[1]
|
||
const type = m[2] === 'w' ? 'word' : 'object'
|
||
const id = m[3]
|
||
const entry = placeholders?.[id] || {}
|
||
return (
|
||
<span
|
||
key={i}
|
||
className={`pair-word-chip${activeId === id ? ' active' : ''}`}
|
||
onClick={e => { e.stopPropagation(); onChipClick?.(id, { label, type, ...entry }) }}
|
||
>
|
||
{label}
|
||
</span>
|
||
)
|
||
}
|
||
return part
|
||
})
|
||
}
|
||
|
||
// Extract unique vocab entries from a sentence string
|
||
function extractVocab(sentence) {
|
||
if (!sentence) return []
|
||
const matches = [...sentence.matchAll(/\{\{([^.]+)\.[wo]:([0-9a-f-]{36})\}\}/g)]
|
||
const seen = new Set()
|
||
return matches
|
||
.filter(m => { const ok = !seen.has(m[2]); seen.add(m[2]); return ok })
|
||
.map(m => ({ label: m[1], id: m[2] }))
|
||
}
|
||
|
||
// Strip placeholders to plain text for TTS
|
||
function toPlainText(sentence) {
|
||
if (!sentence) return ''
|
||
return sentence.replace(/\{\{([^.]+)\.[wo]:[0-9a-f-]{36}\}\}/g, '$1')
|
||
}
|
||
|
||
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 [holding, setHolding] = useState(false)
|
||
const [unlocked, setUnlocked] = useState(false)
|
||
const holdCompleted = useRef(false)
|
||
|
||
const lang = card.lang || 'de'
|
||
const native = lang === 'de' ? 'en' : 'de'
|
||
|
||
const stmt = card.positive_statement
|
||
const sentence = stmt?.[`sentence_${lang}`] || stmt?.sentence_de
|
||
const hint = stmt?.[`sentence_${native}`] || null
|
||
const pic = card.picture?.url
|
||
|
||
const isWord = activeChip && activeChip.type === 'word'
|
||
const isObject = activeChip && activeChip.type === 'object' && activeChip.selections?.length
|
||
|
||
const vocab = extractVocab(sentence)
|
||
|
||
function handleChipClick(id, entry) {
|
||
setActiveChip(prev => prev?.id === id ? null : { id, ...entry })
|
||
}
|
||
|
||
function handleConfirm() {
|
||
if (!unlocked) return
|
||
setDone(true)
|
||
setActiveChip(null)
|
||
triggerConfetti()
|
||
setTimeout(() => onComplete('correct'), 900)
|
||
}
|
||
|
||
function handleTTS() {
|
||
if (!window.speechSynthesis || !sentence) return
|
||
window.speechSynthesis.cancel()
|
||
const utt = new SpeechSynthesisUtterance(toPlainText(sentence))
|
||
utt.lang = LANG_TTS[lang] || 'de-DE'
|
||
utt.rate = 0.9
|
||
window.speechSynthesis.speak(utt)
|
||
setUnlocked(true)
|
||
}
|
||
|
||
function startHold() {
|
||
holdCompleted.current = false
|
||
setHolding(true)
|
||
setShowTranslation(true)
|
||
}
|
||
function endHold() {
|
||
setHolding(false)
|
||
if (!holdCompleted.current) setShowTranslation(false)
|
||
}
|
||
function onHoldComplete() {
|
||
holdCompleted.current = true
|
||
setUnlocked(true)
|
||
}
|
||
|
||
return (
|
||
<div className="pair-card" onClick={() => setActiveChip(null)}>
|
||
|
||
{/* Image — flush, 1:1 */}
|
||
<div className={`pair-image-wrap${isWord ? ' chip-active' : ''}`}>
|
||
{pic
|
||
? <img src={pic} alt="" className="pair-image" loading="lazy" />
|
||
: <div className="pair-image-placeholder">🖼️</div>
|
||
}
|
||
{isWord && (
|
||
<div className="pair-chip-highlight">
|
||
<div className="pair-chip-highlight-badge">
|
||
<span className="pair-chip-highlight-target">{activeChip.label || '…'}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{isObject && <SelectionOverlay chip={activeChip} />}
|
||
</div>
|
||
|
||
<div className="pair-card-body" onClick={e => e.stopPropagation()} style={{ paddingTop: 18 }}>
|
||
|
||
{/* Sentence + action buttons */}
|
||
|
||
<div className="pair-sentence-row">
|
||
<div className="pair-sentence-text">
|
||
<p className="pair-sentence" style={{ opacity: showTranslation ? 0 : 1, transition: 'opacity 0.18s', margin: 0 }}>
|
||
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}
|
||
</p>
|
||
{hint && (
|
||
<p className="pair-sentence" style={{
|
||
opacity: showTranslation ? 1 : 0,
|
||
color: '#7A7060',
|
||
transition: 'opacity 0.18s',
|
||
margin: 0,
|
||
marginTop: showTranslation ? 0 : '-1.7em',
|
||
pointerEvents: 'none',
|
||
}}>
|
||
{resolveSentence(hint, card.placeholders, null, null)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 2 }}>
|
||
{/* 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: 2 s hold unlocks "Verstanden" */}
|
||
{hint && (
|
||
<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="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>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Vocabulary */}
|
||
{vocab.length > 0 && (
|
||
<div className="pair-vocab-section">
|
||
|
||
<div className="pair-vocab-chips">
|
||
{vocab.map(v => (
|
||
<span key={v.id} className="pair-vocab-word">{v.label}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="pair-btn-row" style={{ marginTop: 20 }}>
|
||
<button
|
||
className={`pair-btn ${done ? 'pair-btn-correct' : unlocked ? 'pair-btn-primary' : 'pair-btn-locked'}`}
|
||
onClick={handleConfirm}
|
||
disabled={done || !unlocked}
|
||
>
|
||
{done ? '✓ Verstanden' : 'Verstanden'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|