Compare commits
2 Commits
520d0d139c
...
b674178771
| Author | SHA1 | Date | |
|---|---|---|---|
| b674178771 | |||
| fb71af5f1d |
329
src/components/PairCards.css
Normal file
329
src/components/PairCards.css
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
/* ─────────────────────────────────────────────────────────────
|
||||||
|
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 (sits below image) ── */
|
||||||
|
.pair-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 20px 10px;
|
||||||
|
}
|
||||||
|
.pair-lang-pill {
|
||||||
|
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: #C4A85A;
|
||||||
|
font-size: 12px;
|
||||||
|
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: 1 / 1;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: default;
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
.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%;
|
||||||
|
min-height: 220px;
|
||||||
|
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: 18px 20px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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;
|
||||||
|
font-style: italic;
|
||||||
|
color: #7A5C2E;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: #C4A85A;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
text-decoration-thickness: 1.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
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 {
|
||||||
|
color: #5C3D22;
|
||||||
|
text-decoration-color: #B07840;
|
||||||
|
}
|
||||||
|
.pair-word-chip.active {
|
||||||
|
color: #B07840;
|
||||||
|
text-decoration-color: #B07840;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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; }
|
||||||
248
src/components/PairSentenceCard.jsx
Normal file
248
src/components/PairSentenceCard.jsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { useState, useRef } 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 [showTranslation, setShowTranslation] = useState(false)
|
||||||
|
const holdTimer = useRef(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'
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
setDone(true)
|
||||||
|
setActiveChip(null)
|
||||||
|
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)}>
|
||||||
|
|
||||||
|
{/* Image — flush, 1:1 */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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()}>
|
||||||
|
|
||||||
|
{/* 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" style={{ marginTop: 20 }}>
|
||||||
|
<button
|
||||||
|
className={`pair-btn ${done ? 'pair-btn-correct' : 'pair-btn-primary'}`}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={done}
|
||||||
|
>
|
||||||
|
{done ? '✓ Verstanden' : 'Verstanden'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
197
src/components/PairWordCard.jsx
Normal file
197
src/components/PairWordCard.jsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
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 [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'
|
||||||
|
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 handlePick(opt) {
|
||||||
|
if (picked) return
|
||||||
|
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)}>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* 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, handleChipClick, activeChip?.id)}
|
||||||
|
</p>
|
||||||
|
{hint && !picked && (
|
||||||
|
<p className="pair-hint">
|
||||||
|
{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 || '…'
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
173
src/components/PairYesNoCard.jsx
Normal file
173
src/components/PairYesNoCard.jsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { useState } 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
const isObject = activeChip && activeChip.type === 'object' && activeChip.selections?.length
|
||||||
|
|
||||||
|
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)
|
||||||
|
if (isCorrect) triggerConfetti()
|
||||||
|
setTimeout(() => onComplete(r), 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>
|
||||||
|
|
||||||
|
{/* 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, handleChipClick, activeChip?.id)}
|
||||||
|
</p>
|
||||||
|
{hint && !result && (
|
||||||
|
<p className="pair-hint">
|
||||||
|
{resolveSentence(hint, card.placeholders, null, null)}
|
||||||
|
</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' : '6px' }}>
|
||||||
|
<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