From b6741787712845fde441386a2176f26e9607c901 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 27 May 2026 13:47:52 +0200 Subject: [PATCH] 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 --- src/components/PairCards.css | 144 +++++++++++++----- src/components/PairSentenceCard.jsx | 226 ++++++++++++++++++++-------- src/components/PairWordCard.jsx | 111 ++++++++------ src/components/PairYesNoCard.jsx | 112 ++++++++------ 4 files changed, 395 insertions(+), 198 deletions(-) diff --git a/src/components/PairCards.css b/src/components/PairCards.css index 1ea8f70..d5b82dd 100644 --- a/src/components/PairCards.css +++ b/src/components/PairCards.css @@ -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) */ diff --git a/src/components/PairSentenceCard.jsx b/src/components/PairSentenceCard.jsx index 2c76428..97263fc 100644 --- a/src/components/PairSentenceCard.jsx +++ b/src/components/PairSentenceCard.jsx @@ -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 ( - + {sels.map((s, i) => )} - {/* dim area outside bbox */} - - {/* glowing amber border */} - - {/* word label below bbox */} + + {sels.map((s, i) => ( + + ))} {label && ( - + {label} )} @@ -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 ( { e.stopPropagation(); onChipClick?.(id, entry) }} + onClick={e => { e.stopPropagation(); onChipClick?.(id, { label, type, ...entry }) }} > {label} @@ -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 (
setActiveChip(null)}> -
- {lang.toUpperCase()} - +{card.meta?.points ?? 2} Pkt -
+ {/* Image — flush, 1:1 */}
{pic ? @@ -107,31 +152,88 @@ export default function PairSentenceCard({ card, onComplete }) { {isWord && (
- - {activeChip[lang] || activeChip.de || '…'} - - {(activeChip[native] || activeChip.en) && ( - - {activeChip[native] || activeChip.en} - - )} + {activeChip.label || '…'}
)} - {isObject && } + {isObject && }
+ {/* Header below image */} +
+ {LANG_LABELS[lang] || lang} + + + +{card.meta?.points ?? 2} Punkte + +
+
+
e.stopPropagation()}> -

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

- {hint && ( -

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

+ + {/* Sentence + action buttons */} +

Satz

+
+
+

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

+ {hint && ( +

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

+ )} +
+ +
+ {/* TTS */} + + {/* Hold-to-translate */} + {hint && ( + + )} +
+
+ + {/* Vocabulary */} + {vocab.length > 0 && ( +
+

Vokabeln

+
+ {vocab.map(v => ( + {v.label} + ))} +
+
)} -
+