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:
259
src/components/PairCards.css
Normal file
259
src/components/PairCards.css
Normal file
@@ -0,0 +1,259 @@
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
Pair Feed Cards – Shared Styles
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Card shell ── */
|
||||
.pair-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
border-radius: 22px;
|
||||
overflow: hidden;
|
||||
background: #FDFAF6;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(60, 40, 20, 0.06),
|
||||
0 4px 16px rgba(60, 40, 20, 0.09),
|
||||
0 12px 40px rgba(60, 40, 20, 0.06);
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.pair-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px 12px;
|
||||
}
|
||||
.pair-lang-pill {
|
||||
background: #5C3D22;
|
||||
color: #F5EDE0;
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
padding: 5px 13px;
|
||||
border-radius: 99px;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.pair-points-pill {
|
||||
color: #B07840;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 5px 13px;
|
||||
border-radius: 99px;
|
||||
border: 1.5px solid #E8C9A8;
|
||||
background: #FFF6EC;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
}
|
||||
|
||||
/* ── Image area ── */
|
||||
.pair-image-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
}
|
||||
.pair-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: filter 0.28s ease;
|
||||
}
|
||||
.pair-image-wrap.chip-active .pair-image,
|
||||
.pair-image-wrap.chip-active .pair-image-placeholder {
|
||||
filter: brightness(0.45) saturate(0.7);
|
||||
}
|
||||
|
||||
.pair-image-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(145deg, #EDE0CE 0%, #C8A882 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
transition: filter 0.28s ease;
|
||||
}
|
||||
|
||||
/* Overlay badge that appears above the image when a chip is active */
|
||||
.pair-chip-highlight {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
animation: chipFadeIn 0.18s ease;
|
||||
}
|
||||
@keyframes chipFadeIn {
|
||||
from { opacity: 0; transform: scale(0.88); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.pair-chip-highlight-badge {
|
||||
background: rgba(255, 252, 248, 0.97);
|
||||
border-radius: 18px;
|
||||
padding: 16px 28px 14px;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.28);
|
||||
border: 1px solid rgba(200, 160, 100, 0.3);
|
||||
}
|
||||
.pair-chip-highlight-target {
|
||||
display: block;
|
||||
font-family: 'Lora', Georgia, serif;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #3A2515;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.pair-chip-highlight-native {
|
||||
display: block;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 13px;
|
||||
color: #9A7D60;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Object-chip SVG bbox overlay */
|
||||
.pair-bbox-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
animation: chipFadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
/* ── Card body ── */
|
||||
.pair-card-body {
|
||||
padding: 20px 20px 22px;
|
||||
}
|
||||
|
||||
/* Word chips inside sentences */
|
||||
.pair-word-chip {
|
||||
display: inline-block;
|
||||
background: #FFF0E0;
|
||||
color: #6B4430;
|
||||
border-radius: 7px;
|
||||
padding: 2px 9px 3px;
|
||||
font-size: inherit;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
border: 1.5px solid #E0BFA0;
|
||||
vertical-align: baseline;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.1s;
|
||||
user-select: none;
|
||||
}
|
||||
.pair-word-chip:hover {
|
||||
background: #FFE4C4;
|
||||
border-color: #C8956A;
|
||||
}
|
||||
.pair-word-chip.active {
|
||||
background: #B07840;
|
||||
color: #FFF8F0;
|
||||
border-color: #B07840;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
/* Sentence text (text-type cards) */
|
||||
.pair-sentence {
|
||||
font-family: 'Lora', Georgia, serif;
|
||||
font-size: 18px;
|
||||
line-height: 1.7;
|
||||
color: #3A2515;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Question text */
|
||||
.pair-question {
|
||||
font-family: 'Lora', Georgia, serif;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
line-height: 1.65;
|
||||
color: #3A2515;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Hint — native language translation */
|
||||
.pair-hint {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 13px;
|
||||
color: #A08868;
|
||||
line-height: 1.5;
|
||||
margin: -8px 0 16px;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.pair-btn-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.pair-btn {
|
||||
flex: 1;
|
||||
padding: 14px 10px;
|
||||
border-radius: 13px;
|
||||
border: none;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.01em;
|
||||
transition: transform 0.1s, filter 0.15s, opacity 0.15s;
|
||||
}
|
||||
.pair-btn:hover:not(:disabled) { filter: brightness(1.07); }
|
||||
.pair-btn:active:not(:disabled) { transform: scale(0.96); }
|
||||
.pair-btn:disabled { opacity: 0.75; cursor: default; }
|
||||
|
||||
.pair-btn-primary {
|
||||
background: #5C3D22;
|
||||
color: #F5EDE0;
|
||||
}
|
||||
.pair-btn-yes {
|
||||
background: #3D7055;
|
||||
color: #fff;
|
||||
}
|
||||
.pair-btn-no {
|
||||
background: #A84040;
|
||||
color: #fff;
|
||||
}
|
||||
.pair-btn-correct { background: #3D7055; color: #fff; }
|
||||
.pair-btn-wrong { background: #A84040; color: #fff; }
|
||||
|
||||
/* ── Word option buttons ── */
|
||||
.pair-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.pair-option-btn {
|
||||
padding: 11px 18px;
|
||||
border-radius: 11px;
|
||||
border: 1.5px solid #DDD0BF;
|
||||
background: #FDFAF6;
|
||||
color: #3A2515;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.pair-option-btn:hover:not(:disabled) {
|
||||
background: #FFF0E0;
|
||||
border-color: #C8956A;
|
||||
}
|
||||
.pair-option-btn:active:not(:disabled) { transform: scale(0.95); }
|
||||
.pair-option-btn.correct { background: #3D7055; color: #fff; border-color: #3D7055; }
|
||||
.pair-option-btn.wrong { background: #A84040; color: #fff; border-color: #A84040; }
|
||||
.pair-option-btn:disabled { cursor: default; }
|
||||
|
||||
/* ── Feedback ── */
|
||||
.pair-feedback {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
padding: 12px 0 2px;
|
||||
color: #3A2515;
|
||||
}
|
||||
.pair-feedback.correct { color: #3D7055; }
|
||||
.pair-feedback.wrong { color: #A84040; }
|
||||
146
src/components/PairSentenceCard.jsx
Normal file
146
src/components/PairSentenceCard.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react'
|
||||
import './PairCards.css'
|
||||
|
||||
function BboxOverlay({ chip, lang, native }) {
|
||||
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>
|
||||
{/* dim area outside bbox */}
|
||||
<rect width="100" height="100" fill="rgba(0,0,0,0.48)" mask={`url(#${maskId})`} />
|
||||
{/* glowing amber border */}
|
||||
<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"
|
||||
/>
|
||||
{/* word label below bbox */}
|
||||
{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
|
||||
})
|
||||
}
|
||||
|
||||
export default function PairSentenceCard({ card, onComplete }) {
|
||||
const [done, setDone] = useState(false)
|
||||
const [activeChip, setActiveChip] = useState(null)
|
||||
|
||||
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' || !activeChip.bbox)
|
||||
const isObject = activeChip && activeChip.type === 'object' && activeChip.bbox
|
||||
|
||||
function handleChipClick(id, entry) {
|
||||
setActiveChip(prev => prev?.id === id ? null : { id, ...entry })
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
setDone(true)
|
||||
setActiveChip(null)
|
||||
setTimeout(() => onComplete('correct'), 600)
|
||||
}
|
||||
|
||||
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 ?? 2} 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-sentence">
|
||||
{resolveSentence(sentence, card.placeholders, lang, handleChipClick, activeChip?.id)}
|
||||
</p>
|
||||
{hint && (
|
||||
<p className="pair-hint">
|
||||
{resolveSentence(hint, card.placeholders, native)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="pair-btn-row">
|
||||
<button
|
||||
className={`pair-btn ${done ? 'pair-btn-correct' : 'pair-btn-primary'}`}
|
||||
onClick={handleConfirm}
|
||||
disabled={done}
|
||||
>
|
||||
{done ? '✓ Verstanden' : 'Verstanden'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
184
src/components/PairWordCard.jsx
Normal file
184
src/components/PairWordCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
161
src/components/PairYesNoCard.jsx
Normal file
161
src/components/PairYesNoCard.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useState } 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
|
||||
})
|
||||
}
|
||||
|
||||
export default function PairYesNoCard({ card, onComplete }) {
|
||||
const [result, setResult] = useState(null)
|
||||
const [activeChip, setActiveChip] = useState(null)
|
||||
|
||||
const lang = card.lang || 'de'
|
||||
const native = lang === 'de' ? 'en' : 'de'
|
||||
const q = card.question
|
||||
const stmt = card.positive_statement
|
||||
const correct = stmt?.answer
|
||||
const pic = card.picture?.url
|
||||
|
||||
const isWord = activeChip && (activeChip.type === 'word' || !activeChip.bbox)
|
||||
const isObject = activeChip && activeChip.type === 'object' && activeChip.bbox
|
||||
|
||||
const sentence = q?.[`sentence_${lang}`] || q?.sentence_de
|
||||
const hint = q?.[`sentence_${native}`] || null
|
||||
|
||||
function handleChipClick(id, entry) {
|
||||
setActiveChip(prev => prev?.id === id ? null : { id, ...entry })
|
||||
}
|
||||
|
||||
function handleAnswer(answer) {
|
||||
if (result) return
|
||||
setActiveChip(null)
|
||||
const isCorrect = answer === correct
|
||||
const r = isCorrect ? 'correct' : 'wrong'
|
||||
setResult(r)
|
||||
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 ?? 2} 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 && !result && (
|
||||
<p className="pair-hint">
|
||||
{resolveSentence(hint, card.placeholders, native)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<p className={`pair-feedback ${result}`}>
|
||||
{result === 'correct' ? '✓ Richtig!' : `✗ Die Antwort war: ${correct ? 'Ja' : 'Nein'}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="pair-btn-row" style={{ marginTop: result ? '12px' : 0 }}>
|
||||
<button
|
||||
className={`pair-btn ${result === 'wrong' && !correct ? 'pair-btn-wrong' : result === 'correct' && !correct ? 'pair-btn-correct' : 'pair-btn-no'}`}
|
||||
onClick={() => handleAnswer(false)}
|
||||
disabled={!!result}
|
||||
>
|
||||
✗ Nein
|
||||
</button>
|
||||
<button
|
||||
className={`pair-btn ${result === 'wrong' && correct ? 'pair-btn-wrong' : result === 'correct' && correct ? 'pair-btn-correct' : 'pair-btn-yes'}`}
|
||||
onClick={() => handleAnswer(true)}
|
||||
disabled={!!result}
|
||||
>
|
||||
✓ Ja
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user