Files
app-hejyou/src/components/PairSentenceCard.jsx
2026-05-28 13:13:48 +02:00

274 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}