A subset of correct words with no wrong selections now triggers confetti and marks the card as solved. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
215 lines
7.2 KiB
JavaScript
215 lines
7.2 KiB
JavaScript
import { useState, useMemo } 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
|
||
})
|
||
}
|
||
|
||
function shuffle(arr) {
|
||
const a = [...arr]
|
||
for (let i = a.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[a[i], a[j]] = [a[j], a[i]]
|
||
}
|
||
return a
|
||
}
|
||
|
||
export default function PairWordCard({ card, onComplete }) {
|
||
const [selectedIds, setSelectedIds] = useState(new Set()) // toggled, not yet confirmed
|
||
const [confirmed, setConfirmed] = useState(false) // after Bestätigen
|
||
const [isCorrect, setIsCorrect] = useState(false)
|
||
const [activeChip, setActiveChip] = useState(null)
|
||
|
||
const lang = card.lang || 'de'
|
||
const native = lang === 'de' ? 'en' : 'de'
|
||
|
||
const isWord = activeChip && activeChip.type === 'word'
|
||
const isObject = activeChip && activeChip.type === 'object' && activeChip.selections?.length
|
||
|
||
const q = card.question
|
||
const stmt = card.positive_statement
|
||
const neg = card.negative_statement
|
||
|
||
const sentence = q?.[`sentence_${lang}`] || q?.sentence_de
|
||
const hint = q?.[`sentence_${native}`] || null
|
||
const pic = card.picture?.url
|
||
|
||
const options = useMemo(() => {
|
||
const pos = (stmt?.positive_words || []).map(w => ({ ...w, correct: true }))
|
||
const neg2 = (neg?.negative_words || []).map(w => ({ ...w, correct: false }))
|
||
return shuffle([...pos, ...neg2])
|
||
}, [stmt, neg])
|
||
|
||
function handleChipClick(id, entry) {
|
||
setActiveChip(prev => prev?.id === id ? null : { id, ...entry })
|
||
}
|
||
|
||
function handleSelect(opt) {
|
||
if (confirmed) return
|
||
setSelectedIds(prev => {
|
||
const next = new Set(prev)
|
||
next.has(opt.id) ? next.delete(opt.id) : next.add(opt.id)
|
||
return next
|
||
})
|
||
}
|
||
|
||
function handleConfirm() {
|
||
if (selectedIds.size === 0 || confirmed) return
|
||
setActiveChip(null)
|
||
const correctIds = new Set(options.filter(o => o.correct).map(o => o.id))
|
||
const noWrongSelected = [...selectedIds].every(id => correctIds.has(id))
|
||
const ok = noWrongSelected
|
||
setIsCorrect(ok)
|
||
setConfirmed(true)
|
||
if (ok) triggerConfetti()
|
||
setTimeout(() => onComplete(ok ? 'correct' : 'wrong'), 900)
|
||
}
|
||
|
||
return (
|
||
<div className="pair-card" onClick={() => setActiveChip(null)}>
|
||
|
||
{/* Image — flush to card top */}
|
||
<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 }}>
|
||
|
||
<p className="pair-question">
|
||
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}
|
||
</p>
|
||
{hint && !confirmed && (
|
||
<p className="pair-hint">
|
||
{resolveSentence(hint, card.placeholders, null, null)}
|
||
</p>
|
||
)}
|
||
|
||
<div className="pair-options">
|
||
{options.map((opt) => {
|
||
const label = opt[lang] || opt.de || '…'
|
||
const hint2 = opt[native] || null
|
||
let cls = 'pair-option-btn'
|
||
if (confirmed) {
|
||
if (selectedIds.has(opt.id)) cls += opt.correct ? ' correct' : ' wrong'
|
||
else if (opt.correct) cls += ' correct'
|
||
} else if (selectedIds.has(opt.id)) {
|
||
cls += ' selected'
|
||
}
|
||
return (
|
||
<button key={opt.id} className={cls} onClick={() => handleSelect(opt)} disabled={confirmed}>
|
||
{label}
|
||
{confirmed && hint2 && opt.correct && (
|
||
<span style={{ display: 'block', fontSize: '11px', opacity: 0.85, marginTop: 2 }}>
|
||
{hint2}
|
||
</span>
|
||
)}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{confirmed && (
|
||
<p className={`pair-feedback ${isCorrect ? 'correct' : 'wrong'}`}>
|
||
{isCorrect
|
||
? '✓ Richtig!'
|
||
: `✗ Richtig wären: ${options.filter(o => o.correct).map(o => o[lang] || o.de).join(', ')}`
|
||
}
|
||
</p>
|
||
)}
|
||
|
||
{!confirmed && (
|
||
<div className="pair-btn-row" style={{ marginTop: 12 }}>
|
||
<button
|
||
className={`pair-btn ${selectedIds.size > 0 ? 'pair-btn-primary' : 'pair-btn-locked'}`}
|
||
onClick={handleConfirm}
|
||
disabled={selectedIds.size === 0}
|
||
>
|
||
Bestätigen
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|