Redesign pair cards + add chip image highlight

- Complete CSS overhaul: refined typography, warm palette, 4:3 images, elegant chips
- Word chip click → popup badge with word + translation
- Object chip click → SVG bbox overlay dims image outside object bounds, draws amber outline
- All three card types (Sentence, YesNo, Word) updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 21:30:38 +02:00
parent 520d0d139c
commit fb71af5f1d
4 changed files with 750 additions and 0 deletions

View File

@@ -0,0 +1,184 @@
import { useState, useMemo } from 'react'
import './PairCards.css'
function BboxOverlay({ chip, lang }) {
if (!chip?.bbox) return null
const { x, y, w, h } = chip.bbox
const maskId = `bbmask-${chip.id.slice(0, 8)}`
const label = chip[lang] || chip.de || ''
const labelY = Math.min((y + h + 0.07) * 100, 93)
return (
<svg className="pair-bbox-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
<defs>
<mask id={maskId}>
<rect width="100" height="100" fill="white" />
<rect x={x*100} y={y*100} width={w*100} height={h*100} fill="black" />
</mask>
</defs>
<rect width="100" height="100" fill="rgba(0,0,0,0.48)" mask={`url(#${maskId})`} />
<rect
x={x*100} y={y*100} width={w*100} height={h*100}
fill="rgba(255,215,100,0.08)"
stroke="rgba(255,215,100,0.92)"
strokeWidth="1.4"
rx="1.5"
/>
{label && (
<text
x={(x + w / 2) * 100}
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>
)
}
function resolveSentence(sentence, placeholders, lang, onChipClick, activeId) {
if (!sentence) return null
const parts = sentence.split(/(\{\{[0-9a-f-]{36}\}\})/)
return parts.map((part, i) => {
const m = part.match(/^\{\{([0-9a-f-]{36})\}\}$/)
if (m) {
const id = m[1]
const entry = placeholders?.[id]
const label = entry?.[lang] || entry?.de || '…'
return (
<span
key={i}
className={`pair-word-chip${activeId === id ? ' active' : ''}`}
onClick={e => { e.stopPropagation(); onChipClick?.(id, 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 [picked, setPicked] = useState(null)
const [activeChip, setActiveChip] = useState(null)
const lang = card.lang || 'de'
const native = lang === 'de' ? 'en' : 'de'
const isWord = activeChip && (activeChip.type === 'word' || !activeChip.bbox)
const isObject = activeChip && activeChip.type === 'object' && activeChip.bbox
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 handlePick(opt) {
if (picked) return
setActiveChip(null)
setPicked(opt)
const r = opt.correct ? 'correct' : 'wrong'
setTimeout(() => onComplete(r), 900)
}
return (
<div className="pair-card" onClick={() => setActiveChip(null)}>
<div className="pair-card-header">
<span className="pair-lang-pill">{lang.toUpperCase()}</span>
<span className="pair-points-pill">+{card.meta?.points ?? 3} Pkt</span>
</div>
<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[lang] || activeChip.de || '…'}
</span>
{(activeChip[native] || activeChip.en) && (
<span className="pair-chip-highlight-native">
{activeChip[native] || activeChip.en}
</span>
)}
</div>
</div>
)}
{isObject && <BboxOverlay chip={activeChip} lang={lang} native={native} />}
</div>
<div className="pair-card-body" onClick={e => e.stopPropagation()}>
<p className="pair-question">
{resolveSentence(sentence, card.placeholders, lang, handleChipClick, activeChip?.id)}
</p>
{hint && !picked && (
<p className="pair-hint">
{resolveSentence(hint, card.placeholders, native)}
</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 (picked) {
if (opt.id === picked.id) cls += opt.correct ? ' correct' : ' wrong'
else if (opt.correct) cls += ' correct'
}
return (
<button key={opt.id} className={cls} onClick={() => handlePick(opt)} disabled={!!picked}>
{label}
{picked && hint2 && opt.correct && (
<span style={{ display: 'block', fontSize: '11px', opacity: 0.85, marginTop: 2 }}>
{hint2}
</span>
)}
</button>
)
})}
</div>
{picked && (
<p className={`pair-feedback ${picked.correct ? 'correct' : 'wrong'}`}>
{picked.correct
? '✓ Richtig!'
: `✗ Richtig wäre: ${options.find(o => o.correct)?.[lang] || options.find(o => o.correct)?.de || '?'}`
}
</p>
)}
</div>
</div>
)
}