diff --git a/src/components/PairCards.css b/src/components/PairCards.css new file mode 100644 index 0000000..1ea8f70 --- /dev/null +++ b/src/components/PairCards.css @@ -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; } diff --git a/src/components/PairSentenceCard.jsx b/src/components/PairSentenceCard.jsx new file mode 100644 index 0000000..2c76428 --- /dev/null +++ b/src/components/PairSentenceCard.jsx @@ -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 ( + + + + + + + + {/* dim area outside bbox */} + + {/* glowing amber border */} + + {/* word label below bbox */} + {label && ( + + {label} + + )} + + ) +} + +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 ( + { e.stopPropagation(); onChipClick?.(id, entry) }} + > + {label} + + ) + } + 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 ( +
setActiveChip(null)}> +
+ {lang.toUpperCase()} + +{card.meta?.points ?? 2} Pkt +
+ +
+ {pic + ? + :
🖼️
+ } + {isWord && ( +
+
+ + {activeChip[lang] || activeChip.de || '…'} + + {(activeChip[native] || activeChip.en) && ( + + {activeChip[native] || activeChip.en} + + )} +
+
+ )} + {isObject && } +
+ +
e.stopPropagation()}> +

+ {resolveSentence(sentence, card.placeholders, lang, handleChipClick, activeChip?.id)} +

+ {hint && ( +

+ {resolveSentence(hint, card.placeholders, native)} +

+ )} + +
+ +
+
+
+ ) +} diff --git a/src/components/PairWordCard.jsx b/src/components/PairWordCard.jsx new file mode 100644 index 0000000..9794264 --- /dev/null +++ b/src/components/PairWordCard.jsx @@ -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 ( + + + + + + + + + + {label && ( + + {label} + + )} + + ) +} + +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 ( + { e.stopPropagation(); onChipClick?.(id, entry) }} + > + {label} + + ) + } + 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 ( +
setActiveChip(null)}> +
+ {lang.toUpperCase()} + +{card.meta?.points ?? 3} Pkt +
+ +
+ {pic + ? + :
🖼️
+ } + {isWord && ( +
+
+ + {activeChip[lang] || activeChip.de || '…'} + + {(activeChip[native] || activeChip.en) && ( + + {activeChip[native] || activeChip.en} + + )} +
+
+ )} + {isObject && } +
+ +
e.stopPropagation()}> +

+ {resolveSentence(sentence, card.placeholders, lang, handleChipClick, activeChip?.id)} +

+ {hint && !picked && ( +

+ {resolveSentence(hint, card.placeholders, native)} +

+ )} + +
+ {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 ( + + ) + })} +
+ + {picked && ( +

+ {picked.correct + ? '✓ Richtig!' + : `✗ Richtig wäre: ${options.find(o => o.correct)?.[lang] || options.find(o => o.correct)?.de || '?'}` + } +

+ )} +
+
+ ) +} diff --git a/src/components/PairYesNoCard.jsx b/src/components/PairYesNoCard.jsx new file mode 100644 index 0000000..8cbab2a --- /dev/null +++ b/src/components/PairYesNoCard.jsx @@ -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 ( + + + + + + + + + + {label && ( + + {label} + + )} + + ) +} + +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 ( + { e.stopPropagation(); onChipClick?.(id, entry) }} + > + {label} + + ) + } + 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 ( +
setActiveChip(null)}> +
+ {lang.toUpperCase()} + +{card.meta?.points ?? 2} Pkt +
+ +
+ {pic + ? + :
🖼️
+ } + {isWord && ( +
+
+ + {activeChip[lang] || activeChip.de || '…'} + + {(activeChip[native] || activeChip.en) && ( + + {activeChip[native] || activeChip.en} + + )} +
+
+ )} + {isObject && } +
+ +
e.stopPropagation()}> +

+ {resolveSentence(sentence, card.placeholders, lang, handleChipClick, activeChip?.id)} +

+ {hint && !result && ( +

+ {resolveSentence(hint, card.placeholders, native)} +

+ )} + + {result && ( +

+ {result === 'correct' ? '✓ Richtig!' : `✗ Die Antwort war: ${correct ? 'Ja' : 'Nein'}`} +

+ )} + +
+ + +
+
+
+ ) +}