feat: card redesign with new placeholder format, confetti & layout

- New placeholder format {{label.w/o:uuid}} parsed in all card types
- 1:1 image ratio, header below image, section labels (Satz/Frage/Vokabeln)
- Chips styled as underline-italic in sentences
- Vocabulary pill chips (Lora italic, rounded)
- TTS + hold-to-translate buttons in PairSentenceCard
- Confetti on correct answers (canvas-confetti)
- Picture loaded via object_pairs join in feed API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 13:47:52 +02:00
parent fb71af5f1d
commit b674178771
4 changed files with 395 additions and 198 deletions

View File

@@ -15,41 +15,77 @@
0 12px 40px rgba(60, 40, 20, 0.06);
}
/* ── Header ── */
/* ── Header (sits below image) ── */
.pair-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px 12px;
padding: 14px 20px 10px;
}
.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;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.09em;
color: #6B6556;
font-family: 'DM Sans', 'Nunito', sans-serif;
text-transform: uppercase;
}
.pair-points-pill {
color: #B07840;
color: #C4A85A;
font-size: 12px;
font-weight: 700;
padding: 5px 13px;
border-radius: 99px;
border: 1.5px solid #E8C9A8;
background: #FFF6EC;
font-family: 'Nunito', sans-serif;
font-weight: 500;
font-family: 'DM Sans', 'Nunito', sans-serif;
display: flex;
align-items: center;
gap: 4px;
}
/* Thin divider below header */
.pair-header-divider {
height: 0.5px;
background: #E0DDD5;
margin: 0 20px;
}
/* ── Section labels ── */
.pair-section-label {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.10em;
text-transform: uppercase;
color: #A89F8C;
font-family: 'DM Sans', 'Nunito', sans-serif;
margin-bottom: 8px;
}
/* ── Icon buttons (TTS / translate) ── */
.pair-icon-btn {
background: #F0EDE3;
border: 0.5px solid #D8D3C5;
border-radius: 10px;
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
flex-shrink: 0;
transition: background 0.15s;
-webkit-user-select: none;
user-select: none;
}
.pair-icon-btn:hover { background: #E0DAC8; }
.pair-icon-btn.active { background: #E0DAC8; }
/* ── Image area ── */
.pair-image-wrap {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
aspect-ratio: 1 / 1;
overflow: hidden;
cursor: default;
background: #1a1a1a;
}
.pair-image {
width: 100%;
@@ -66,6 +102,7 @@
.pair-image-placeholder {
width: 100%;
height: 100%;
min-height: 220px;
background: linear-gradient(145deg, #EDE0CE 0%, #C8A882 100%);
display: flex;
align-items: center;
@@ -125,34 +162,67 @@
/* ── Card body ── */
.pair-card-body {
padding: 20px 20px 22px;
padding: 18px 20px 22px;
}
/* Word chips inside sentences */
/* ── Vocabulary chips section ── */
.pair-vocab-section {
margin-top: 16px;
}
.pair-vocab-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pair-vocab-word {
padding: 6px 14px;
background: #F0EDE3;
border-radius: 30px;
border: 0.5px solid #D8D3C5;
font-family: 'Lora', Georgia, serif;
font-size: 13px;
font-style: italic;
color: #7A5C2E;
cursor: default;
}
/* Sentence action row (sentence + icon buttons) */
.pair-sentence-row {
display: flex;
align-items: flex-start;
gap: 12px;
}
.pair-sentence-text {
flex: 1;
}
/* Word chips inside sentences — underline italic style */
.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;
display: inline;
font-style: italic;
color: #7A5C2E;
text-decoration: underline;
text-decoration-color: #C4A85A;
text-underline-offset: 3px;
text-decoration-thickness: 1.5px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.1s;
transition: color 0.15s, text-decoration-color 0.15s;
user-select: none;
background: none;
border: none;
padding: 0;
border-radius: 0;
font-weight: inherit;
vertical-align: baseline;
}
.pair-word-chip:hover {
background: #FFE4C4;
border-color: #C8956A;
color: #5C3D22;
text-decoration-color: #B07840;
}
.pair-word-chip.active {
background: #B07840;
color: #FFF8F0;
border-color: #B07840;
transform: scale(1.04);
color: #B07840;
text-decoration-color: #B07840;
text-decoration-thickness: 2px;
}
/* Sentence text (text-type cards) */

View File

@@ -1,42 +1,47 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import confetti from 'canvas-confetti'
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)
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" />
<rect x={x*100} y={y*100} width={w*100} height={h*100} fill="black" />
{sels.map((s, i) => <polygon key={i} points={toPoints(s.points)} 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 */}
<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={(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))' }}
>
<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>
)}
@@ -44,20 +49,22 @@ function BboxOverlay({ chip, lang, native }) {
)
}
function resolveSentence(sentence, placeholders, lang, onChipClick, activeId) {
// Sentence format: {{label.w:uuid}} or {{label.o:uuid}}
function resolveSentence(sentence, placeholders, onChipClick, activeId) {
if (!sentence) return null
const parts = sentence.split(/(\{\{[0-9a-f-]{36}\}\})/)
const parts = sentence.split(/(\{\{[^}]+\.[wo]:[0-9a-f-]{36}\}\})/)
return parts.map((part, i) => {
const m = part.match(/^\{\{([0-9a-f-]{36})\}\}$/)
const m = part.match(/^\{\{([^.]+)\.(w|o):([0-9a-f-]{36})\}\}$/)
if (m) {
const id = m[1]
const entry = placeholders?.[id]
const label = entry?.[lang] || entry?.de || '…'
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, entry) }}
onClick={e => { e.stopPropagation(); onChipClick?.(id, { label, type, ...entry }) }}
>
{label}
</span>
@@ -67,9 +74,30 @@ function resolveSentence(sentence, placeholders, lang, onChipClick, activeId) {
})
}
// 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' }
export default function PairSentenceCard({ card, onComplete }) {
const [done, setDone] = useState(false)
const [activeChip, setActiveChip] = useState(null)
const [done, setDone] = useState(false)
const [activeChip, setActiveChip] = useState(null)
const [showTranslation, setShowTranslation] = useState(false)
const holdTimer = useRef(null)
const lang = card.lang || 'de'
const native = lang === 'de' ? 'en' : 'de'
@@ -79,8 +107,10 @@ export default function PairSentenceCard({ card, onComplete }) {
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
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 })
@@ -89,16 +119,31 @@ export default function PairSentenceCard({ card, onComplete }) {
function handleConfirm() {
setDone(true)
setActiveChip(null)
setTimeout(() => onComplete('correct'), 600)
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)
}
function startTranslation() {
holdTimer.current = setTimeout(() => setShowTranslation(true), 150)
}
function endTranslation() {
clearTimeout(holdTimer.current)
setShowTranslation(false)
}
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>
{/* Image — flush, 1:1 */}
<div className={`pair-image-wrap${isWord ? ' chip-active' : ''}`}>
{pic
? <img src={pic} alt="" className="pair-image" loading="lazy" />
@@ -107,31 +152,88 @@ export default function PairSentenceCard({ card, onComplete }) {
{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>
)}
<span className="pair-chip-highlight-target">{activeChip.label || '…'}</span>
</div>
</div>
)}
{isObject && <BboxOverlay chip={activeChip} lang={lang} native={native} />}
{isObject && <SelectionOverlay chip={activeChip} />}
</div>
{/* Header below image */}
<div className="pair-card-header">
<span className="pair-lang-pill">{LANG_LABELS[lang] || lang}</span>
<span className="pair-points-pill">
<svg width="13" height="13" viewBox="0 0 24 24" fill="#C4A85A"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
+{card.meta?.points ?? 2} Punkte
</span>
</div>
<div className="pair-header-divider" />
<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>
{/* Sentence + action buttons */}
<p className="pair-section-label">Satz</p>
<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', /* overlay effect */
pointerEvents: 'none',
}}>
{resolveSentence(hint, card.placeholders, null, null)}
</p>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 2 }}>
{/* TTS */}
<button className="pair-icon-btn" onClick={handleTTS} title="Vorlesen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#7A6E55" 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 */}
{hint && (
<button
className={`pair-icon-btn${showTranslation ? ' active' : ''}`}
onMouseDown={startTranslation}
onMouseUp={endTranslation}
onMouseLeave={endTranslation}
onTouchStart={e => { e.preventDefault(); startTranslation() }}
onTouchEnd={endTranslation}
title="Übersetzung halten"
>
<svg width="18" height="18" viewBox="0 0 24 24" 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="M22 22l-5-10-5 10"/><path d="M14 18h6"/>
</svg>
</button>
)}
</div>
</div>
{/* Vocabulary */}
{vocab.length > 0 && (
<div className="pair-vocab-section">
<p className="pair-section-label">Vokabeln</p>
<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">
<div className="pair-btn-row" style={{ marginTop: 20 }}>
<button
className={`pair-btn ${done ? 'pair-btn-correct' : 'pair-btn-primary'}`}
onClick={handleConfirm}

View File

@@ -1,39 +1,47 @@
import { useState, useMemo } from 'react'
import confetti from 'canvas-confetti'
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)
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" />
<rect x={x*100} y={y*100} width={w*100} height={h*100} fill="black" />
{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.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"
/>
<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={(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))' }}
>
<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>
)}
@@ -41,20 +49,22 @@ function BboxOverlay({ chip, lang }) {
)
}
function resolveSentence(sentence, placeholders, lang, onChipClick, activeId) {
// Sentence format: {{label.w:uuid}} or {{label.o:uuid}}
function resolveSentence(sentence, placeholders, onChipClick, activeId) {
if (!sentence) return null
const parts = sentence.split(/(\{\{[0-9a-f-]{36}\}\})/)
const parts = sentence.split(/(\{\{[^}]+\.[wo]:[0-9a-f-]{36}\}\})/)
return parts.map((part, i) => {
const m = part.match(/^\{\{([0-9a-f-]{36})\}\}$/)
const m = part.match(/^\{\{([^.]+)\.(w|o):([0-9a-f-]{36})\}\}$/)
if (m) {
const id = m[1]
const entry = placeholders?.[id]
const label = entry?.[lang] || entry?.de || '…'
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, entry) }}
onClick={e => { e.stopPropagation(); onChipClick?.(id, { label, type, ...entry }) }}
>
{label}
</span>
@@ -80,8 +90,8 @@ export default function PairWordCard({ card, onComplete }) {
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 isWord = activeChip && activeChip.type === 'word'
const isObject = activeChip && activeChip.type === 'object' && activeChip.selections?.length
const q = card.question
const stmt = card.positive_statement
@@ -106,16 +116,14 @@ export default function PairWordCard({ card, onComplete }) {
setActiveChip(null)
setPicked(opt)
const r = opt.correct ? 'correct' : 'wrong'
if (opt.correct) triggerConfetti()
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>
{/* Image — flush to card top */}
<div className={`pair-image-wrap${isWord ? ' chip-active' : ''}`}>
{pic
? <img src={pic} alt="" className="pair-image" loading="lazy" />
@@ -124,30 +132,35 @@ export default function PairWordCard({ card, onComplete }) {
{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>
)}
<span className="pair-chip-highlight-target">{activeChip.label || '…'}</span>
</div>
</div>
)}
{isObject && <BboxOverlay chip={activeChip} lang={lang} native={native} />}
{isObject && <SelectionOverlay chip={activeChip} />}
</div>
{/* Header below image */}
<div className="pair-card-header">
<span className="pair-lang-pill">{lang === 'sv' ? 'Svenska' : lang === 'en' ? 'English' : 'Deutsch'}</span>
<span className="pair-points-pill">
<svg width="13" height="13" viewBox="0 0 24 24" fill="#C4A85A"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
+{card.meta?.points ?? 3} Punkte
</span>
</div>
<div className="pair-header-divider" />
<div className="pair-card-body" onClick={e => e.stopPropagation()}>
<p className="pair-section-label">Frage</p>
<p className="pair-question">
{resolveSentence(sentence, card.placeholders, lang, handleChipClick, activeChip?.id)}
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}
</p>
{hint && !picked && (
<p className="pair-hint">
{resolveSentence(hint, card.placeholders, native)}
{resolveSentence(hint, card.placeholders, null, null)}
</p>
)}
<p className="pair-section-label" style={{ marginTop: 14 }}>Antwort</p>
<div className="pair-options">
{options.map((opt) => {
const label = opt[lang] || opt.de || '…'

View File

@@ -1,39 +1,47 @@
import { useState } from 'react'
import confetti from 'canvas-confetti'
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)
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" />
<rect x={x*100} y={y*100} width={w*100} height={h*100} fill="black" />
{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.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"
/>
<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={(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))' }}
>
<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>
)}
@@ -41,20 +49,22 @@ function BboxOverlay({ chip, lang }) {
)
}
function resolveSentence(sentence, placeholders, lang, onChipClick, activeId) {
// Sentence format: {{label.w:uuid}} or {{label.o:uuid}}
function resolveSentence(sentence, placeholders, onChipClick, activeId) {
if (!sentence) return null
const parts = sentence.split(/(\{\{[0-9a-f-]{36}\}\})/)
const parts = sentence.split(/(\{\{[^}]+\.[wo]:[0-9a-f-]{36}\}\})/)
return parts.map((part, i) => {
const m = part.match(/^\{\{([0-9a-f-]{36})\}\}$/)
const m = part.match(/^\{\{([^.]+)\.(w|o):([0-9a-f-]{36})\}\}$/)
if (m) {
const id = m[1]
const entry = placeholders?.[id]
const label = entry?.[lang] || entry?.de || '…'
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, entry) }}
onClick={e => { e.stopPropagation(); onChipClick?.(id, { label, type, ...entry }) }}
>
{label}
</span>
@@ -75,8 +85,8 @@ export default function PairYesNoCard({ card, onComplete }) {
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 isWord = activeChip && activeChip.type === 'word'
const isObject = activeChip && activeChip.type === 'object' && activeChip.selections?.length
const sentence = q?.[`sentence_${lang}`] || q?.sentence_de
const hint = q?.[`sentence_${native}`] || null
@@ -91,16 +101,14 @@ export default function PairYesNoCard({ card, onComplete }) {
const isCorrect = answer === correct
const r = isCorrect ? 'correct' : 'wrong'
setResult(r)
if (isCorrect) triggerConfetti()
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>
{/* Image — flush to card top */}
<div className={`pair-image-wrap${isWord ? ' chip-active' : ''}`}>
{pic
? <img src={pic} alt="" className="pair-image" loading="lazy" />
@@ -109,27 +117,31 @@ export default function PairYesNoCard({ card, onComplete }) {
{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>
)}
<span className="pair-chip-highlight-target">{activeChip.label || '…'}</span>
</div>
</div>
)}
{isObject && <BboxOverlay chip={activeChip} lang={lang} native={native} />}
{isObject && <SelectionOverlay chip={activeChip} />}
</div>
{/* Header below image */}
<div className="pair-card-header">
<span className="pair-lang-pill">{lang === 'sv' ? 'Svenska' : lang === 'en' ? 'English' : 'Deutsch'}</span>
<span className="pair-points-pill">
<svg width="13" height="13" viewBox="0 0 24 24" fill="#C4A85A"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
+{card.meta?.points ?? 2} Punkte
</span>
</div>
<div className="pair-header-divider" />
<div className="pair-card-body" onClick={e => e.stopPropagation()}>
<p className="pair-section-label">Frage</p>
<p className="pair-question">
{resolveSentence(sentence, card.placeholders, lang, handleChipClick, activeChip?.id)}
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}
</p>
{hint && !result && (
<p className="pair-hint">
{resolveSentence(hint, card.placeholders, native)}
{resolveSentence(hint, card.placeholders, null, null)}
</p>
)}
@@ -139,7 +151,7 @@ export default function PairYesNoCard({ card, onComplete }) {
</p>
)}
<div className="pair-btn-row" style={{ marginTop: result ? '12px' : 0 }}>
<div className="pair-btn-row" style={{ marginTop: result ? '12px' : '6px' }}>
<button
className={`pair-btn ${result === 'wrong' && !correct ? 'pair-btn-wrong' : result === 'correct' && !correct ? 'pair-btn-correct' : 'pair-btn-no'}`}
onClick={() => handleAnswer(false)}