init: HejYou Language Learning App (React + Vite)

React SPA with Vite, Directus backend, canvas-confetti.
Includes Dockerfile (multi-stage Node → nginx) for Coolify deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 22:15:51 +02:00
commit a708152fc1
45 changed files with 6188 additions and 0 deletions

View File

@@ -0,0 +1,191 @@
/* Dark card */
.aq-card { background: #F5EFE6; }
.aq-header {
background: #2C1A0E;
}
.aq-points-pill {
background: #2C1A0E !important;
border-color: #4A3020 !important;
color: #C4A882 !important;
}
/* Dark image area */
.aq-image {
background: #2C1A0E;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 32px 20px 20px;
min-height: 220px;
cursor: pointer;
}
/* Speaker ring */
.aq-speaker-ring {
width: 100px;
height: 100px;
border-radius: 50%;
border: 2px solid rgba(196, 168, 130, 0.25);
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.3s;
}
.aq-speaker-ring.aq-playing {
border-color: rgba(196, 168, 130, 0.6);
animation: aq-pulse 1.2s ease-in-out infinite;
}
@keyframes aq-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(196, 168, 130, 0.15); }
50% { box-shadow: 0 0 0 14px rgba(196, 168, 130, 0.05); }
}
.aq-speaker-icon {
width: 68px;
height: 68px;
border-radius: 14px;
background: rgba(196, 168, 130, 0.08);
border: 1.5px solid rgba(196, 168, 130, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
/* Dots */
.aq-dots {
display: flex;
gap: 5px;
}
.aq-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: rgba(196, 168, 130, 0.3);
}
/* Hint */
.aq-tap-hint {
font-family: 'Nunito', sans-serif;
font-size: 12px;
color: rgba(196, 168, 130, 0.5);
letter-spacing: 0.02em;
}
/* Question */
.aq-question {
font-family: 'Nunito', sans-serif;
font-size: 15px;
font-weight: 700;
color: #4A3728;
margin-bottom: 14px;
}
/* Options */
.aq-options {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.aq-option {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border-radius: 14px;
background: #EDE0CE;
border: 1px solid transparent;
cursor: pointer;
text-align: left;
transition: background 0.15s, border-color 0.15s;
}
.aq-option:hover { background: #D4C4AE; }
.aq-option-label {
font-family: 'Nunito', sans-serif;
font-size: 12px;
font-weight: 700;
color: #8C7A65;
width: 16px;
flex-shrink: 0;
}
.aq-option-text {
font-family: 'Nunito', sans-serif;
font-size: 14px;
font-weight: 600;
color: #4A3728;
}
.aq-option-selected {
background: rgba(122, 92, 58, 0.12);
border-color: #7A5C3A;
}
.aq-option-correct {
background: rgba(90, 122, 58, 0.12);
border-color: #5a7a3a;
}
.aq-option-correct .aq-option-label,
.aq-option-correct .aq-option-text { color: #3a5a1e; }
.aq-option-wrong {
background: rgba(160, 90, 58, 0.12);
border-color: #a05a3a;
}
.aq-option-wrong .aq-option-label,
.aq-option-wrong .aq-option-text { color: #a05a3a; }
.aq-option-reveal {
background: rgba(90, 122, 58, 0.08);
border-color: rgba(90, 122, 58, 0.4);
}
/* Success / retry */
.aq-success-bar {
display: flex;
justify-content: space-between;
align-items: center;
background: #EDE0CE;
border: 0.5px solid #D4B896;
border-radius: 12px;
padding: 12px 16px;
}
.aq-success-left {
font-family: 'Nunito', sans-serif;
font-size: 13px;
font-weight: 700;
color: #4A3728;
}
.aq-success-right {
font-family: 'Nunito', sans-serif;
font-size: 12px;
color: #8C7A65;
}
.aq-retry-btn {
width: 100%;
padding: 13px;
border: none;
border-radius: 14px;
background: #7A5C3A;
color: #F5EFE6;
font-family: 'Nunito', sans-serif;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: background 0.15s;
}
.aq-retry-btn:hover { background: #4A3728; }

View File

@@ -0,0 +1,106 @@
import { useState, useRef } from 'react'
import './CardShared.css'
import './AudioQuizCard.css'
import { triggerConfetti } from '../utils/confetti'
export default function AudioQuizCard({ card }) {
const [playing, setPlaying] = useState(false)
const [selected, setSelected] = useState(null)
const [status, setStatus] = useState('idle') // idle | correct | wrong
const timerRef = useRef(null)
const playAudio = () => {
if (playing) return
setPlaying(true)
if (card.audioSrc) {
const audio = new Audio(card.audioSrc)
audio.play()
audio.onended = () => setPlaying(false)
} else {
// Simulate playback
timerRef.current = setTimeout(() => setPlaying(false), 1800)
}
}
const pick = (id) => {
if (status === 'correct') return
setSelected(id)
const correct = id === card.correct
setStatus(correct ? 'correct' : 'wrong')
if (correct) triggerConfetti()
}
const reset = () => { setSelected(null); setStatus('idle') }
const optionClass = (id) => {
let cls = 'aq-option'
if (status === 'idle' && selected === id) cls += ' aq-option-selected'
if (status === 'correct' && id === card.correct) cls += ' aq-option-correct'
if (status === 'wrong' && id === selected) cls += ' aq-option-wrong'
if (status === 'wrong' && id === card.correct) cls += ' aq-option-reveal'
return cls
}
return (
<div className="nw-card aq-card">
<div className="nw-card-header aq-header">
<span className="nw-lang-pill">{card.language}</span>
<span className="nw-points-pill aq-points-pill"> +{card.points} Punkt</span>
</div>
<div className="aq-image" onClick={playAudio}>
<div className={`aq-speaker-ring${playing ? ' aq-playing' : ''}`}>
<div className="aq-speaker-icon">
<svg viewBox="0 0 48 48" width="40" height="40" fill="none" aria-hidden="true">
<rect x="10" y="14" width="10" height="20" rx="3" fill="rgba(196,168,130,0.5)"/>
<polygon points="20,14 34,6 34,42 20,34" fill="rgba(196,168,130,0.5)"/>
{playing && (
<>
<path d="M37 16 Q42 24 37 32" stroke="rgba(196,168,130,0.6)" strokeWidth="2.5" strokeLinecap="round" fill="none"/>
<path d="M40 11 Q48 24 40 37" stroke="rgba(196,168,130,0.35)" strokeWidth="2" strokeLinecap="round" fill="none"/>
</>
)}
</svg>
</div>
</div>
<div className="aq-dots">
{card.options.map((_, i) => (
<span key={i} className="aq-dot" />
))}
</div>
<p className="aq-tap-hint">Tippe zum Abspielen</p>
</div>
<div className="nw-content">
<p className="aq-question">{card.prompt}</p>
<div className="aq-options">
{card.options.map(opt => (
<button
key={opt.id}
className={optionClass(opt.id)}
onClick={() => pick(opt.id)}
>
<span className="aq-option-label">{opt.id}</span>
<span className="aq-option-text">{opt.text}</span>
</button>
))}
</div>
<div className="nw-divider" />
{status === 'correct' && (
<div className="aq-success-bar">
<span className="aq-success-left"> +{card.points} Punkt erhalten</span>
<span className="aq-success-right">Gesamt: {card.totalPoints} Punkte</span>
</div>
)}
{status === 'wrong' && (
<button className="aq-retry-btn" onClick={reset}>Nochmal versuchen</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
/* Shared styles for all feed cards */
.nw-card {
width: 100%;
max-width: 360px;
border-radius: 24px;
overflow: hidden;
border: 0.5px solid #D4B896;
box-shadow: 0 4px 24px rgba(74, 55, 40, 0.1);
background: #F5EFE6;
}
/* Pills row above the image */
.nw-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 14px 10px;
background: #F5EFE6;
}
.nw-lang-pill {
background: #7A5C3A;
color: #EDE0CE;
font-size: 12px;
font-weight: 700;
padding: 5px 12px;
border-radius: 99px;
font-family: 'Nunito', sans-serif;
letter-spacing: 0.03em;
}
.nw-points-pill {
background: #F5EFE6;
color: #7A5C3A;
font-size: 12px;
font-weight: 700;
padding: 5px 12px;
border-radius: 99px;
border: 0.5px solid #D4B896;
font-family: 'Nunito', sans-serif;
}
/* 1:1 square image area */
.nw-image {
background: #C4A882;
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.nw-bubble {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -70%);
background: rgba(237, 224, 206, 0.55);
color: rgba(74, 55, 40, 0.7);
font-family: 'Lora', Georgia, serif;
font-size: 26px;
font-weight: 700;
width: 100px;
height: 100px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.nw-content {
padding: 18px 20px 20px;
}
.nw-word-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.nw-word {
font-family: 'Lora', Georgia, serif;
font-size: 28px;
font-weight: 700;
color: #4A3728;
}
.nw-translation {
background: #D4B896;
color: #4A3728;
font-size: 12px;
font-weight: 600;
padding: 5px 12px;
border-radius: 99px;
font-family: 'Nunito', sans-serif;
}
.nw-divider {
height: 0.5px;
background: #D4B896;
margin-bottom: 14px;
}
.nw-label {
display: block;
font-size: 12px;
color: #8C7A65;
font-family: 'Nunito', sans-serif;
margin-bottom: 10px;
}

View File

@@ -0,0 +1,95 @@
.ip-image {
flex-direction: column;
justify-content: flex-end;
gap: 0;
padding-bottom: 12px;
}
/* Pagination dots */
.ip-dots {
display: flex;
gap: 5px;
margin-top: 10px;
}
.ip-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(74, 55, 40, 0.25);
transition: background 0.2s;
}
.ip-dot-active {
background: rgba(74, 55, 40, 0.7);
}
/* Buttons */
.ip-btn-row {
display: flex;
gap: 10px;
}
.ip-btn {
flex: 1;
padding: 13px 8px;
border-radius: 14px;
border: 1px solid #D4B896;
background: #F5EFE6;
font-family: 'Nunito', sans-serif;
font-size: 14px;
font-weight: 700;
color: #8C7A65;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 0.15s, color 0.15s;
}
.ip-btn:disabled {
opacity: 0.4;
cursor: default;
}
.ip-btn-ja {
background: #7A5C3A;
color: #F5EFE6;
border-color: #7A5C3A;
}
.ip-btn-ja:hover:not(:disabled) { background: #4A3728; border-color: #4A3728; }
.ip-btn-nein:hover:not(:disabled) { background: #EDE0CE; }
/* Feedback */
.ip-wrong-text {
font-family: 'Nunito', sans-serif;
font-size: 12px;
color: #a05a3a;
margin-bottom: 10px;
}
/* Success bar */
.ip-success-bar {
display: flex;
justify-content: space-between;
align-items: center;
background: #EDE0CE;
border: 0.5px solid #D4B896;
border-radius: 12px;
padding: 12px 16px;
}
.ip-success-left {
font-family: 'Nunito', sans-serif;
font-size: 13px;
font-weight: 700;
color: #4A3728;
}
.ip-success-right {
font-family: 'Nunito', sans-serif;
font-size: 12px;
color: #8C7A65;
}

View File

@@ -0,0 +1,125 @@
import { useState } from 'react'
import './CardShared.css'
import './ImagePickCard.css'
import { triggerConfetti } from '../utils/confetti'
const ILLUSTRATIONS = {
sofa: (
<svg viewBox="0 0 200 140" width="190" height="133" aria-hidden="true">
<rect x="16" y="46" width="14" height="50" rx="6" fill="rgba(74,55,40,0.16)"/>
<rect x="170" y="46" width="14" height="50" rx="6" fill="rgba(74,55,40,0.16)"/>
<rect x="22" y="36" width="156" height="22" rx="8" fill="rgba(74,55,40,0.14)"/>
<rect x="22" y="54" width="156" height="38" rx="10" fill="rgba(74,55,40,0.18)"/>
<rect x="32" y="56" width="64" height="32" rx="8" fill="rgba(74,55,40,0.11)"/>
<rect x="104" y="56" width="64" height="32" rx="8" fill="rgba(74,55,40,0.11)"/>
<rect x="30" y="92" width="10" height="22" rx="3" fill="rgba(74,55,40,0.13)"/>
<rect x="160" y="92" width="10" height="22" rx="3" fill="rgba(74,55,40,0.13)"/>
</svg>
),
lamp: (
<svg viewBox="0 0 200 160" width="190" height="152" aria-hidden="true">
<polygon points="80,30 120,30 140,90 60,90" fill="rgba(74,55,40,0.16)"/>
<rect x="96" y="90" width="8" height="55" rx="3" fill="rgba(74,55,40,0.18)"/>
<ellipse cx="100" cy="148" rx="30" ry="8" fill="rgba(74,55,40,0.12)"/>
<line x1="100" y1="30" x2="100" y2="10" stroke="rgba(74,55,40,0.2)" strokeWidth="4" strokeLinecap="round"/>
</svg>
),
table: (
<svg viewBox="0 0 200 160" width="190" height="152" aria-hidden="true">
<rect x="20" y="72" width="160" height="18" rx="4" fill="rgba(74,55,40,0.20)"/>
<rect x="34" y="90" width="14" height="50" rx="3" fill="rgba(74,55,40,0.15)"/>
<rect x="152" y="90" width="14" height="50" rx="3" fill="rgba(74,55,40,0.15)"/>
<rect x="28" y="134" width="24" height="8" rx="3" fill="rgba(74,55,40,0.11)"/>
<rect x="148" y="134" width="24" height="8" rx="3" fill="rgba(74,55,40,0.11)"/>
</svg>
),
chair: (
<svg viewBox="0 0 200 160" width="190" height="152" aria-hidden="true">
<rect x="65" y="30" width="70" height="55" rx="8" fill="rgba(74,55,40,0.14)"/>
<rect x="55" y="82" width="90" height="16" rx="5" fill="rgba(74,55,40,0.20)"/>
<rect x="62" y="98" width="14" height="50" rx="4" fill="rgba(74,55,40,0.15)"/>
<rect x="124" y="98" width="14" height="50" rx="4" fill="rgba(74,55,40,0.15)"/>
<rect x="56" y="138" width="24" height="8" rx="3" fill="rgba(74,55,40,0.11)"/>
<rect x="120" y="138" width="24" height="8" rx="3" fill="rgba(74,55,40,0.11)"/>
</svg>
),
}
export default function ImagePickCard({ card }) {
const [index, setIndex] = useState(0)
const [status, setStatus] = useState('idle') // idle | correct | wrong
const currentImage = card.images[index]
const isLast = index === card.images.length - 1
const handleJa = () => {
if (currentImage === card.correctImage) {
setStatus('correct')
triggerConfetti()
} else {
setStatus('wrong')
}
}
const handleNein = () => {
if (status === 'wrong') setStatus('idle')
if (index < card.images.length - 1) {
setIndex(index + 1)
setStatus('idle')
}
}
const reset = () => { setIndex(0); setStatus('idle') }
return (
<div className="nw-card">
<div className="nw-card-header">
<span className="nw-lang-pill">{card.language}</span>
<span className="nw-points-pill"> +{card.points} Punkt</span>
</div>
<div className="nw-image ip-image">
{ILLUSTRATIONS[currentImage]}
<div className="ip-dots">
{card.images.map((_, i) => (
<span key={i} className={`ip-dot${i === index ? ' ip-dot-active' : ''}`} />
))}
</div>
</div>
<div className="nw-content">
<div className="nw-word-row">
<span className="nw-word">{card.word}</span>
<span className="nw-translation">{card.translation}</span>
</div>
<div className="nw-divider" />
<p className="nw-label">{card.prompt}</p>
{status === 'correct' ? (
<div className="ip-success-bar">
<span className="ip-success-left"> +{card.points} Punkt erhalten</span>
<span className="ip-success-right">Gesamt: {card.totalPoints} Punkte</span>
</div>
) : (
<>
{status === 'wrong' && (
<p className="ip-wrong-text">Das ist nicht richtig weiter suchen!</p>
)}
<div className="ip-btn-row">
<button
className="ip-btn ip-btn-nein"
onClick={handleNein}
disabled={isLast && status !== 'wrong'}
>
<span></span> Nein
</button>
<button className="ip-btn ip-btn-ja" onClick={handleJa}>
<span></span> Ja, das ist es
</button>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,140 @@
.iq-image {
flex-direction: column;
gap: 0;
}
.iq-scene-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-family: 'Lora', Georgia, serif;
font-size: 22px;
font-weight: 700;
color: rgba(74, 55, 40, 0.55);
letter-spacing: 0.01em;
z-index: 1;
pointer-events: none;
}
/* Question row */
.iq-question-row {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 14px;
}
.iq-question {
font-family: 'Nunito', sans-serif;
font-size: 15px;
font-weight: 700;
color: #4A3728;
}
.iq-hint {
font-family: 'Nunito', sans-serif;
font-size: 12px;
color: #8C7A65;
}
/* Chip grid */
.iq-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.iq-chip {
font-family: 'Nunito', sans-serif;
font-size: 14px;
font-weight: 600;
color: #4A3728;
background: transparent;
border: 1.5px solid #C4A882;
border-radius: 99px;
padding: 7px 16px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.iq-chip:hover {
background: rgba(196, 168, 130, 0.15);
}
.iq-chip-selected {
background: rgba(122, 92, 58, 0.12);
border-color: #7A5C3A;
color: #4A3728;
}
.iq-chip-correct {
background: rgba(90, 122, 58, 0.12);
border-color: #5a7a3a;
color: #3a5a1e;
}
.iq-chip-wrong {
background: rgba(160, 90, 58, 0.12);
border-color: #a05a3a;
color: #a05a3a;
}
/* Confirm button */
.iq-confirm-btn {
width: 100%;
padding: 14px;
border: none;
border-radius: 14px;
background: #7A5C3A;
color: #F5EFE6;
font-family: 'Nunito', sans-serif;
font-size: 15px;
font-weight: 700;
cursor: pointer;
transition: background 0.15s;
}
.iq-confirm-btn:hover:not(.iq-confirm-disabled) {
background: #4A3728;
}
.iq-confirm-btn.iq-confirm-disabled {
background: #D4B896;
color: #8C7A65;
cursor: default;
}
/* Feedback text */
.iq-feedback {
font-size: 12px;
font-family: 'Nunito', sans-serif;
margin-bottom: 10px;
}
.iq-wrong-text { color: #a05a3a; }
/* Success bar */
.iq-success-bar {
display: flex;
justify-content: space-between;
align-items: center;
background: #EDE0CE;
border: 0.5px solid #D4B896;
border-radius: 12px;
padding: 12px 16px;
}
.iq-success-left {
font-size: 13px;
font-weight: 700;
color: #4A3728;
font-family: 'Nunito', sans-serif;
}
.iq-success-right {
font-size: 12px;
color: #8C7A65;
font-family: 'Nunito', sans-serif;
}

View File

@@ -0,0 +1,110 @@
import { useState } from 'react'
import './CardShared.css'
import './ImageQuizCard.css'
import { triggerConfetti } from '../utils/confetti'
function LivingRoomIllustration() {
return (
<svg viewBox="0 0 200 180" width="200" height="180" aria-hidden="true">
{/* Rug */}
<rect x="30" y="138" width="140" height="28" rx="6" fill="rgba(74,55,40,0.13)"/>
<rect x="38" y="143" width="124" height="4" rx="2" fill="rgba(74,55,40,0.08)"/>
{/* Coffee table */}
<rect x="58" y="112" width="84" height="14" rx="4" fill="rgba(74,55,40,0.22)"/>
<rect x="68" y="126" width="8" height="16" rx="2" fill="rgba(74,55,40,0.15)"/>
<rect x="124" y="126" width="8" height="16" rx="2" fill="rgba(74,55,40,0.15)"/>
{/* Sofa body */}
<rect x="22" y="74" width="156" height="40" rx="10" fill="rgba(74,55,40,0.18)"/>
{/* Sofa back */}
<rect x="22" y="58" width="156" height="22" rx="8" fill="rgba(74,55,40,0.14)"/>
{/* Left cushion */}
<rect x="32" y="76" width="64" height="34" rx="8" fill="rgba(74,55,40,0.12)"/>
{/* Right cushion */}
<rect x="104" y="76" width="64" height="34" rx="8" fill="rgba(74,55,40,0.12)"/>
{/* Sofa armrests */}
<rect x="16" y="68" width="14" height="46" rx="6" fill="rgba(74,55,40,0.16)"/>
<rect x="170" y="68" width="14" height="46" rx="6" fill="rgba(74,55,40,0.16)"/>
</svg>
)
}
export default function ImageQuizCard({ card }) {
const [selected, setSelected] = useState([])
const [status, setStatus] = useState('idle') // idle | correct | wrong
const toggle = (word) => {
if (status !== 'idle') return
setSelected(prev =>
prev.includes(word) ? prev.filter(w => w !== word) : [...prev, word]
)
}
const confirm = () => {
if (selected.length === 0) return
const isCorrect =
selected.length === card.correct.length &&
selected.every(w => card.correct.includes(w))
setStatus(isCorrect ? 'correct' : 'wrong')
if (isCorrect) triggerConfetti()
}
const reset = () => { setSelected([]); setStatus('idle') }
return (
<div className="nw-card">
<div className="nw-card-header">
<span className="nw-lang-pill">{card.language}</span>
<span className="nw-points-pill"> +{card.points} Punkt</span>
</div>
<div className="nw-image iq-image">
<span className="iq-scene-label">{card.scene}</span>
<LivingRoomIllustration />
</div>
<div className="nw-content">
<div className="iq-question-row">
<span className="iq-question">{card.prompt}</span>
<span className="iq-hint">Mehrere möglich</span>
</div>
<div className="iq-chips">
{card.choices.map(word => {
const isSelected = selected.includes(word)
const isCorrectWord = card.correct.includes(word)
let chipClass = 'iq-chip'
if (status === 'correct' && isCorrectWord) chipClass += ' iq-chip-correct'
else if (status === 'wrong' && isSelected && !isCorrectWord) chipClass += ' iq-chip-wrong'
else if (isSelected) chipClass += ' iq-chip-selected'
return (
<button key={word} className={chipClass} onClick={() => toggle(word)}>
{word}
</button>
)
})}
</div>
<div className="nw-divider" />
{status === 'correct' ? (
<div className="iq-success-bar">
<span className="iq-success-left"> +{card.points} Punkt erhalten</span>
<span className="iq-success-right">Gesamt: {card.totalPoints} Punkte</span>
</div>
) : (
<>
{status === 'wrong' && (
<p className="iq-feedback iq-wrong-text">Nicht alle richtig versuch es nochmal.</p>
)}
<button
className={`iq-confirm-btn${selected.length === 0 ? ' iq-confirm-disabled' : ''}`}
onClick={status === 'wrong' ? reset : confirm}
>
{status === 'wrong' ? 'Nochmal versuchen' : 'Bestätigen'}
</button>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,145 @@
.lp-card { background: #F5EFE6; }
/* 1:1 Bild */
.lp-image {
width: 100%;
aspect-ratio: 1 / 1;
background: #C4A882;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.lp-image img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Polygon-Overlay: liegt deckungsgleich über dem Bild */
.lp-overlay {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.lp-polygon {
fill: rgba(255, 236, 170, 0.45);
stroke: #FFD86B;
stroke-width: 5;
stroke-linejoin: round;
stroke-dasharray: 18 7;
vector-effect: non-scaling-stroke;
opacity: 0;
}
.lp-overlay-on .lp-polygon {
animation:
lp-flash 1.9s ease-out forwards,
lp-shimmer 0.45s linear infinite;
}
@keyframes lp-flash {
0% { opacity: 0; filter: none; }
7% { opacity: 1; filter: drop-shadow(0 0 8px rgba(255, 216, 107, 1))
drop-shadow(0 0 22px rgba(255, 216, 107, 0.9))
drop-shadow(0 0 44px rgba(255, 195, 30, 0.75)); }
32% { opacity: 1; filter: drop-shadow(0 0 6px rgba(255, 216, 107, 0.85))
drop-shadow(0 0 16px rgba(255, 216, 107, 0.55)); }
78% { opacity: 0.4; filter: drop-shadow(0 0 4px rgba(255, 216, 107, 0.4)); }
100% { opacity: 0; filter: none; }
}
@keyframes lp-shimmer {
to { stroke-dashoffset: -25; }
}
.lp-image-fallback {
font-size: 56px;
color: rgba(74, 55, 40, 0.4);
}
.lp-statement {
font-family: 'Lora', Georgia, serif;
font-size: 20px;
font-weight: 700;
color: #4A3728;
line-height: 1.35;
margin: 4px 0 14px;
}
/* Buttons-Reihe: Lautsprecher + Mikro */
.lp-actions {
display: flex;
gap: 12px;
align-items: stretch;
}
.lp-btn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 52px;
border-radius: 12px;
background: #F5EFE6;
border: 1px solid #D4B896;
color: #7A5C3A;
font-family: 'Nunito', sans-serif;
font-size: 14px;
font-weight: 700;
cursor: pointer;
position: relative;
overflow: visible;
transition: background 0.15s, border-color 0.15s, transform 0.15s;
}
.lp-btn:hover { background: #EDE0CE; }
.lp-btn:active { transform: scale(0.98); }
.lp-btn-stt.lp-listening {
border-color: #7A5C3A;
background: #EDE0CE;
}
.lp-btn-stt.lp-wrong {
border-color: #c0826a;
}
.lp-pulse {
position: absolute;
inset: -6px;
border-radius: 16px;
border: 2px solid #7A5C3A;
animation: lp-pulse 1.2s ease-out infinite;
pointer-events: none;
}
@keyframes lp-pulse {
0% { opacity: 0.7; transform: scale(1); }
100% { opacity: 0; transform: scale(1.2); }
}
.lp-hint {
margin-top: 10px;
font-size: 13px;
color: #7A5C3A;
font-family: 'Nunito', sans-serif;
font-weight: 600;
}
.lp-feedback {
margin-top: 10px;
font-size: 13px;
font-family: 'Nunito', sans-serif;
font-weight: 700;
}
.lp-wrong-text { color: #a05a3a; }
.lp-correct-text { color: #5a7a3a; }

View File

@@ -0,0 +1,222 @@
import { useState, useRef } from 'react'
import './CardShared.css'
import './NewWordVoiceCard.css'
import './LanguageParentCard.css'
import { triggerConfetti } from '../utils/confetti'
function normalize(s) {
return (s || '')
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function similarity(a, b) {
const A = normalize(a)
const B = normalize(b)
if (!A || !B) return 0
if (A === B) return 1
const wa = A.split(' ')
const wb = new Set(B.split(' '))
let hits = 0
for (const w of wa) if (wb.has(w)) hits++
return hits / Math.max(wa.length, wb.size)
}
export default function LanguageParentCard({ card, onComplete }) {
const [status, setStatus] = useState('idle') // idle | listening | correct | wrong
const [heard, setHeard] = useState('')
const [imgSize, setImgSize] = useState(null) // { w, h } natural image dims
const [highlight, setHighlight] = useState(false)
const recognitionRef = useRef(null)
const reportedRef = useRef(false)
const speakGenRef = useRef(0)
const polygon = (() => {
const sel = card.selections?.[0]
if (!sel) return null
if (sel.mode === 'polygon' && Array.isArray(sel.polygon)) return sel.polygon
if (Array.isArray(sel.points)) return sel.points
return null
})()
const report = (result) => {
if (reportedRef.current) return
reportedRef.current = true
onComplete?.(result)
}
const speak = () => {
// Eigener Generationszähler: nur der jeweils letzte Aufruf darf den Glow am Ende abschalten
const gen = ++speakGenRef.current
const start = performance.now()
const minMs = 1400
setHighlight(true)
const finish = () => {
if (speakGenRef.current !== gen) return // ein neuerer Klick läuft schon
const elapsed = performance.now() - start
const wait = Math.max(0, minMs - elapsed)
setTimeout(() => {
if (speakGenRef.current === gen) setHighlight(false)
}, wait)
}
if (!('speechSynthesis' in window)) {
finish()
return
}
window.speechSynthesis.cancel()
const u = new SpeechSynthesisUtterance(card.statement)
u.lang = card.speechLang || 'de-DE'
u.rate = 0.70
u.onend = finish
u.onerror = finish
window.speechSynthesis.speak(u)
// Fallback, falls onend nicht feuert (manche Engines)
setTimeout(finish, 6000)
}
const startListening = () => {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition
if (!SR) {
setStatus('listening')
setTimeout(() => { setStatus('correct'); triggerConfetti(); report('correct') }, 1500)
return
}
const rec = new SR()
rec.lang = card.speechLang || 'de-DE'
rec.interimResults = false
rec.maxAlternatives = 3
recognitionRef.current = rec
setStatus('listening')
setHeard('')
rec.onresult = (e) => {
const alts = Array.from(e.results[0]).map(r => r.transcript.trim())
let best = 0
let bestText = alts[0] || ''
for (const a of alts) {
const s = similarity(a, card.statement)
if (s > best) { best = s; bestText = a }
}
setHeard(bestText)
if (best >= 0.7) {
setStatus('correct')
triggerConfetti()
report('correct')
} else {
setStatus('wrong')
}
}
rec.onerror = () => setStatus('wrong')
rec.onend = () => { if (status === 'listening') setStatus(prev => prev === 'listening' ? 'wrong' : prev) }
try { rec.start() } catch { setStatus('idle') }
}
const reset = () => {
recognitionRef.current?.abort()
setStatus('idle')
setHeard('')
}
return (
<div className="nw-card lp-card">
<div className="nw-card-header">
<span className="nw-lang-pill">{card.language}</span>
<span className="nw-points-pill"> +{card.points} Punkt</span>
</div>
<div className="lp-image">
{card.imageUrl ? (
<>
<img
src={card.imageUrl}
alt={card.primaryWord || ''}
loading="lazy"
onLoad={(e) => setImgSize({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })}
/>
{polygon && imgSize && (
<svg
className={`lp-overlay ${highlight ? 'lp-overlay-on' : ''}`}
viewBox={`0 0 ${imgSize.w} ${imgSize.h}`}
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
>
<polygon
className="lp-polygon"
points={polygon.map(p => `${p.x},${p.y}`).join(' ')}
/>
</svg>
)}
</>
) : (
<div className="lp-image-fallback" aria-hidden="true">🖼</div>
)}
</div>
<div className="nw-content">
<p className="lp-statement">{card.statement}</p>
<div className="nw-divider" />
{status !== 'correct' ? (
<>
<div className="lp-actions">
<button
className="lp-btn lp-btn-tts"
onClick={speak}
aria-label="Satz vorlesen"
type="button"
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none"
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 5 6 9H3v6h3l5 4V5Z"/>
<path d="M15.5 8.5a5 5 0 0 1 0 7"/>
<path d="M19 5a9 9 0 0 1 0 14"/>
</svg>
<span>Hören</span>
</button>
<button
className={`lp-btn lp-btn-stt ${status === 'listening' ? 'lp-listening' : ''} ${status === 'wrong' ? 'lp-wrong' : ''}`}
onClick={status === 'listening' ? reset : startListening}
aria-label="Satz nachsprechen"
type="button"
>
{status === 'listening' && <span className="lp-pulse" />}
<svg viewBox="0 0 24 24" width="20" height="20" fill="none"
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="2" width="6" height="11" rx="3"/>
<path d="M5 10a7 7 0 0 0 14 0"/>
<line x1="12" y1="19" x2="12" y2="22"/>
<line x1="8" y1="22" x2="16" y2="22"/>
</svg>
<span>Sprechen</span>
</button>
</div>
{status === 'listening' && <p className="lp-hint">Ich höre dir zu </p>}
{status === 'wrong' && (
<p className="lp-feedback lp-wrong-text">
{heard ? <>Verstanden: {heard}" — versuch's nochmal.</> : <>Nicht erkannt — versuch's nochmal.</>}
</p>
)}
</>
) : (
<>
<p className="lp-feedback lp-correct-text">Perfekt! 🎉</p>
<div className="nwv-success-bar">
<span className="nwv-success-left">★ +{card.points} Punkt erhalten</span>
<span className="nwv-success-right">Gesamt: {card.totalPoints} Punkte</span>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,129 @@
.lo-prompt-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.lo-prompt {
font-family: 'Nunito', sans-serif;
font-size: 13px;
color: #8C7A65;
line-height: 1.5;
flex: 1;
}
/* Answer slot */
.lo-answer-area {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 52px;
border: 1.5px solid #D4B896;
border-radius: 14px;
padding: 10px 12px;
margin-bottom: 14px;
transition: border-color 0.2s;
background: rgba(255,255,255,0.4);
}
.lo-answer-correct { border-color: #5a7a3a; background: rgba(90,122,58,0.06); }
.lo-answer-wrong { border-color: #c0826a; background: rgba(160,90,58,0.06); }
/* Chips */
.lo-chip {
width: 38px;
height: 38px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Lora', Georgia, serif;
font-size: 17px;
font-weight: 700;
}
.lo-chip-placed {
background: #7A5C3A;
color: #F5EFE6;
}
.lo-chip-available {
background: #7A5C3A;
color: #F5EFE6;
border: none;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
}
.lo-chip-available:hover { background: #4A3728; }
.lo-chip-available:active { transform: scale(0.93); }
/* Available letters row */
.lo-available {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
min-height: 38px;
}
/* Back / Reset button */
.lo-back-btn {
font-family: 'Nunito', sans-serif;
font-size: 13px;
font-weight: 700;
color: #7A5C3A;
background: transparent;
border: none;
cursor: pointer;
padding: 2px 0;
opacity: 1;
transition: opacity 0.15s;
}
.lo-back-btn.lo-back-disabled {
opacity: 0.35;
cursor: default;
}
/* Feedback */
.lo-wrong-text {
font-family: 'Nunito', sans-serif;
font-size: 12px;
color: #a05a3a;
margin-bottom: 8px;
}
/* Success */
.lo-success-text {
font-family: 'Nunito', sans-serif;
font-size: 14px;
font-weight: 700;
color: #5a7a3a;
margin-bottom: 12px;
}
.lo-success-bar {
display: flex;
justify-content: space-between;
align-items: center;
background: #EDE0CE;
border: 0.5px solid #D4B896;
border-radius: 12px;
padding: 12px 16px;
}
.lo-success-left {
font-family: 'Nunito', sans-serif;
font-size: 13px;
font-weight: 700;
color: #4A3728;
}
.lo-success-right {
font-family: 'Nunito', sans-serif;
font-size: 12px;
color: #8C7A65;
}

View File

@@ -0,0 +1,131 @@
import { useState, useMemo, useRef } from 'react'
import './CardShared.css'
import './LetterOrderCard.css'
import { triggerConfetti } from '../utils/confetti'
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
}
function SofaIllustration() {
return (
<svg viewBox="0 0 200 140" width="190" height="133" aria-hidden="true">
<rect x="16" y="46" width="14" height="50" rx="6" fill="rgba(74,55,40,0.16)"/>
<rect x="170" y="46" width="14" height="50" rx="6" fill="rgba(74,55,40,0.16)"/>
<rect x="22" y="36" width="156" height="22" rx="8" fill="rgba(74,55,40,0.14)"/>
<rect x="22" y="54" width="156" height="38" rx="10" fill="rgba(74,55,40,0.18)"/>
<rect x="32" y="56" width="64" height="32" rx="8" fill="rgba(74,55,40,0.11)"/>
<rect x="104" y="56" width="64" height="32" rx="8" fill="rgba(74,55,40,0.11)"/>
<rect x="30" y="92" width="10" height="22" rx="3" fill="rgba(74,55,40,0.13)"/>
<rect x="160" y="92" width="10" height="22" rx="3" fill="rgba(74,55,40,0.13)"/>
</svg>
)
}
export default function LetterOrderCard({ card, onComplete }) {
const scrambled = useMemo(() => shuffle(card.word.split('')), [card.word])
const [placed, setPlaced] = useState([])
const [available, setAvailable] = useState(scrambled)
const [status, setStatus] = useState('idle') // idle | correct | wrong
const reportedRef = useRef(false)
const report = (result) => {
if (reportedRef.current) return
reportedRef.current = true
onComplete?.(result)
}
const placeLetter = (idx) => {
if (status === 'correct') return
const letter = available[idx]
const newPlaced = [...placed, letter]
const newAvailable = available.filter((_, i) => i !== idx)
if (newPlaced.length === card.word.length) {
const correct = newPlaced.join('') === card.word
setPlaced(newPlaced)
setAvailable(newAvailable)
setStatus(correct ? 'correct' : 'wrong')
if (correct) { triggerConfetti(); report('correct') }
} else {
setPlaced(newPlaced)
setAvailable(newAvailable)
}
}
const removeLast = () => {
if (placed.length === 0) return
const letter = placed[placed.length - 1]
setPlaced(placed.slice(0, -1))
setAvailable([...available, letter])
setStatus('idle')
}
const reset = () => {
setPlaced([])
setAvailable(scrambled)
setStatus('idle')
}
return (
<div className="nw-card">
<div className="nw-card-header">
<span className="nw-lang-pill">{card.language}</span>
<span className="nw-points-pill"> +{card.points} Punkt</span>
</div>
<div className="nw-image">
<SofaIllustration />
</div>
<div className="nw-content">
<div className="lo-prompt-row">
<span className="lo-prompt">{card.prompt}</span>
<span className="nw-translation">{card.translation}</span>
</div>
<div className={`lo-answer-area${status === 'correct' ? ' lo-answer-correct' : status === 'wrong' ? ' lo-answer-wrong' : ''}`}>
{placed.map((letter, i) => (
<span key={i} className="lo-chip lo-chip-placed">{letter}</span>
))}
</div>
<div className="nw-divider" />
{status === 'correct' ? (
<>
<p className="lo-success-text">Perfekt! Bra jobbat!</p>
<div className="lo-success-bar">
<span className="lo-success-left"> +{card.points} Punkt erhalten</span>
<span className="lo-success-right">Gesamt: {card.totalPoints} Punkte</span>
</div>
</>
) : (
<>
<div className="lo-available">
{available.map((letter, i) => (
<button key={i} className="lo-chip lo-chip-available" onClick={() => placeLetter(i)}>
{letter}
</button>
))}
</div>
{status === 'wrong' && (
<p className="lo-wrong-text">Nicht ganz versuch es nochmal.</p>
)}
<button
className={`lo-back-btn${placed.length === 0 ? ' lo-back-disabled' : ''}`}
onClick={status === 'wrong' ? reset : removeLast}
disabled={placed.length === 0 && status === 'idle'}
>
{status === 'wrong' ? 'Nochmal' : '← Zurück'}
</button>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,77 @@
.nwt-input-row {
display: flex;
align-items: center;
gap: 8px;
background: #fff;
border: 1px solid #D4B896;
border-radius: 12px;
padding: 4px 6px 4px 12px;
transition: border-color 0.2s;
}
.nwt-input-row.nwt-wrong {
border-color: #c0826a;
}
.nwt-input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-family: 'Lora', Georgia, serif;
font-size: 16px;
color: #4A3728;
padding: 6px 0;
}
.nwt-input::placeholder {
color: rgba(74, 55, 40, 0.3);
}
.nwt-submit-btn {
width: 36px;
height: 36px;
border-radius: 8px;
background: #7A5C3A;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #F5EFE6;
flex-shrink: 0;
transition: background 0.15s;
}
.nwt-submit-btn:hover { background: #4A3728; }
.nwt-feedback {
font-size: 12px;
font-family: 'Nunito', sans-serif;
margin-top: 8px;
}
.nwt-wrong-text { color: #a05a3a; }
.nwt-success-bar {
display: flex;
justify-content: space-between;
align-items: center;
background: #EDE0CE;
border: 0.5px solid #D4B896;
border-radius: 12px;
padding: 12px 16px;
}
.nwt-success-left {
font-size: 13px;
font-weight: 700;
color: #4A3728;
font-family: 'Nunito', sans-serif;
}
.nwt-success-right {
font-size: 12px;
color: #8C7A65;
font-family: 'Nunito', sans-serif;
}

View File

@@ -0,0 +1,80 @@
import { useState, useRef } from 'react'
import './CardShared.css'
import './NewWordTextCard.css'
import TableIllustration from './TableIllustration'
import { triggerConfetti } from '../utils/confetti'
export default function NewWordTextCard({ card, onComplete }) {
const [input, setInput] = useState('')
const [status, setStatus] = useState('idle') // idle | correct | wrong
const reportedRef = useRef(false)
const report = (result) => {
if (reportedRef.current) return
reportedRef.current = true
onComplete?.(result)
}
const check = () => {
if (!input.trim()) return
const correct = input.trim().toLowerCase() === card.word.toLowerCase()
setStatus(correct ? 'correct' : 'wrong')
if (correct) {
triggerConfetti()
report('correct')
}
}
const reset = () => { setInput(''); setStatus('idle') }
return (
<div className="nw-card">
<div className="nw-card-header">
<span className="nw-lang-pill">{card.language}</span>
<span className="nw-points-pill"> +{card.points} Punkt</span>
</div>
<div className="nw-image">
<div className="nw-bubble">{card.baseForm}</div>
<TableIllustration />
</div>
<div className="nw-content">
<div className="nw-word-row">
<span className="nw-word">{card.word}</span>
<span className="nw-translation">{card.translation}</span>
</div>
<div className="nw-divider" />
{status !== 'correct' ? (
<>
<label className="nw-label">{card.prompt}</label>
<div className={`nwt-input-row ${status === 'wrong' ? 'nwt-wrong' : ''}`}>
<input
className="nwt-input"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && check()}
placeholder={`${card.word}`}
autoCorrect="off" autoCapitalize="none" spellCheck={false}
/>
<button className="nwt-submit-btn" onClick={check} aria-label="Prüfen">
<svg viewBox="0 0 20 20" width="16" height="16" fill="none"
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4,10 9,15 16,6" />
</svg>
</button>
</div>
{status === 'wrong' && (
<p className="nwt-feedback nwt-wrong-text">Nicht ganz versuch es nochmal.</p>
)}
</>
) : (
<div className="nwt-success-bar">
<span className="nwt-success-left"> +{card.points} Punkt erhalten</span>
<span className="nwt-success-right">Gesamt: {card.totalPoints} Punkte</span>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,95 @@
.nwv-mic-row {
display: flex;
align-items: center;
gap: 14px;
min-height: 52px;
}
.nwv-mic-btn {
width: 52px;
height: 52px;
border-radius: 12px;
background: #F5EFE6;
border: 1px solid #D4B896;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #7A5C3A;
flex-shrink: 0;
position: relative;
transition: background 0.15s, border-color 0.15s;
overflow: visible;
}
.nwv-mic-btn:hover { background: #EDE0CE; }
.nwv-mic-btn.nwv-listening {
border-color: #7A5C3A;
background: #EDE0CE;
}
.nwv-mic-btn.nwv-wrong {
border-color: #c0826a;
}
.nwv-mic-btn.nwv-done {
border-color: #5a7a3a;
color: #5a7a3a;
cursor: default;
}
/* Pulse animation when listening */
.nwv-pulse-ring {
position: absolute;
inset: -6px;
border-radius: 16px;
border: 2px solid #7A5C3A;
animation: mic-pulse 1.2s ease-out infinite;
pointer-events: none;
}
@keyframes mic-pulse {
0% { opacity: 0.7; transform: scale(1); }
100% { opacity: 0; transform: scale(1.25); }
}
.nwv-hint {
font-size: 13px;
color: #7A5C3A;
font-family: 'Nunito', sans-serif;
font-weight: 600;
}
.nwv-feedback {
font-size: 13px;
font-family: 'Nunito', sans-serif;
font-weight: 700;
}
.nwv-wrong-text { color: #a05a3a; }
.nwv-correct-text { color: #5a7a3a; }
.nwv-success-bar {
display: flex;
justify-content: space-between;
align-items: center;
background: #EDE0CE;
border: 0.5px solid #D4B896;
border-radius: 12px;
padding: 12px 16px;
margin-top: 14px;
}
.nwv-success-left {
font-size: 13px;
font-weight: 700;
color: #4A3728;
font-family: 'Nunito', sans-serif;
}
.nwv-success-right {
font-size: 12px;
color: #8C7A65;
font-family: 'Nunito', sans-serif;
}

View File

@@ -0,0 +1,128 @@
import { useState, useRef } from 'react'
import './CardShared.css'
import './NewWordVoiceCard.css'
import TableIllustration from './TableIllustration'
import { triggerConfetti } from '../utils/confetti'
export default function NewWordVoiceCard({ card, onComplete }) {
const [status, setStatus] = useState('idle') // idle | listening | correct | wrong
const recognitionRef = useRef(null)
const reportedRef = useRef(false)
const report = (result) => {
if (reportedRef.current) return
reportedRef.current = true
onComplete?.(result)
}
const startListening = () => {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition
if (!SR) {
// Simulate for browsers without speech API
setStatus('listening')
setTimeout(() => { setStatus('correct'); triggerConfetti(); report('correct') }, 2000)
return
}
const rec = new SR()
rec.lang = card.speechLang || 'sv-SE'
rec.interimResults = false
rec.maxAlternatives = 3
recognitionRef.current = rec
setStatus('listening')
rec.onresult = (e) => {
const heard = Array.from(e.results[0])
.map((r) => r.transcript.trim().toLowerCase())
const target = card.word.toLowerCase()
const correct = heard.some((h) => h === target || h.includes(target))
setStatus(correct ? 'correct' : 'wrong')
if (correct) { triggerConfetti(); report('correct') }
}
rec.onerror = () => setStatus('wrong')
rec.onend = () => { if (status === 'listening') setStatus('wrong') }
rec.start()
}
const reset = () => {
recognitionRef.current?.abort()
setStatus('idle')
}
return (
<div className="nw-card">
<div className="nw-card-header">
<span className="nw-lang-pill">{card.language}</span>
<span className="nw-points-pill"> +{card.points} Punkt</span>
</div>
<div className="nw-image">
<div className="nw-bubble">{card.baseForm}</div>
<TableIllustration />
</div>
<div className="nw-content">
<div className="nw-word-row">
<span className="nw-word">{card.word}</span>
<span className="nw-translation">{card.translation}</span>
</div>
<div className="nw-divider" />
{status !== 'correct' ? (
<>
<label className="nw-label">{card.prompt}</label>
<div className="nwv-mic-row">
<button
className={`nwv-mic-btn ${status === 'listening' ? 'nwv-listening' : ''} ${status === 'wrong' ? 'nwv-wrong' : ''}`}
onClick={status === 'listening' ? reset : startListening}
aria-label="Sprechen"
>
{status === 'listening' ? (
<span className="nwv-pulse-ring" />
) : null}
<svg viewBox="0 0 24 24" width="22" height="22" fill="none"
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="2" width="6" height="11" rx="3" />
<path d="M5 10a7 7 0 0 0 14 0" />
<line x1="12" y1="19" x2="12" y2="22" />
<line x1="8" y1="22" x2="16" y2="22" />
</svg>
</button>
{status === 'listening' && (
<p className="nwv-hint">Hören </p>
)}
{status === 'wrong' && (
<p className="nwv-feedback nwv-wrong-text">Nicht erkannt versuch es nochmal.</p>
)}
{status === 'correct' && (
<p className="nwv-feedback nwv-correct-text">Bra! Aussprache erkannt.</p>
)}
</div>
</>
) : (
<>
<label className="nw-label">{card.prompt}</label>
<div className="nwv-mic-row">
<button className="nwv-mic-btn nwv-done" aria-label="Fertig">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none"
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="2" width="6" height="11" rx="3" />
<path d="M5 10a7 7 0 0 0 14 0" />
<line x1="12" y1="19" x2="12" y2="22" />
<line x1="8" y1="22" x2="16" y2="22" />
</svg>
</button>
<p className="nwv-feedback nwv-correct-text">Bra! Aussprache erkannt.</p>
</div>
<div className="nwv-success-bar">
<span className="nwv-success-left"> +{card.points} Punkt erhalten</span>
<span className="nwv-success-right">Gesamt: {card.totalPoints} Punkte</span>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,97 @@
import { useState, useRef } from 'react'
import './CardShared.css'
import './NewWordTextCard.css'
import { triggerConfetti } from '../utils/confetti'
export default function SentenceFillCard({ card, onComplete }) {
const [input, setInput] = useState('')
const [status, setStatus] = useState('idle') // idle | correct | wrong
const [revealed, setRevealed] = useState(false)
const reportedRef = useRef(false)
const report = (result) => {
if (reportedRef.current) return
reportedRef.current = true
onComplete?.(result)
}
const check = () => {
if (!input.trim()) return
const correct = input.trim().toLowerCase() === card.word.toLowerCase()
setStatus(correct ? 'correct' : 'wrong')
if (correct) {
triggerConfetti()
report('correct')
}
}
return (
<div className="nw-card">
<div className="nw-card-header">
<span className="nw-lang-pill">{card.language}</span>
<span className="nw-points-pill"> +{card.points} Punkt</span>
</div>
<div className="nw-content" style={{ paddingTop: 24 }}>
<label className="nw-label" style={{ marginBottom: 6 }}>Frage</label>
<div style={{
fontFamily: 'Lora, Georgia, serif',
fontSize: 22, fontWeight: 700, color: '#4A3728',
lineHeight: 1.3, marginBottom: 16,
}}>
{card.translation}
</div>
<div className="nw-divider" />
{status !== 'correct' ? (
<>
<label className="nw-label">{card.prompt}</label>
<div className={`nwt-input-row ${status === 'wrong' ? 'nwt-wrong' : ''}`}>
<input
className="nwt-input"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && check()}
placeholder="Deine Antwort …"
autoCorrect="off" autoCapitalize="none" spellCheck={false}
/>
<button className="nwt-submit-btn" onClick={check} aria-label="Prüfen">
<svg viewBox="0 0 20 20" width="16" height="16" fill="none"
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4,10 9,15 16,6" />
</svg>
</button>
</div>
{status === 'wrong' && (
<>
<p className="nwt-feedback nwt-wrong-text">Nicht ganz versuch es nochmal.</p>
{!revealed ? (
<button
onClick={() => setRevealed(true)}
style={{
marginTop: 6, background: 'transparent', border: 'none',
color: '#8C7A65', fontSize: 12, fontFamily: 'Nunito, sans-serif',
textDecoration: 'underline', cursor: 'pointer', padding: 0,
}}
>
Lösung anzeigen
</button>
) : (
<p style={{ marginTop: 6, fontSize: 13, color: '#7A5C3A', fontFamily: 'Nunito, sans-serif' }}>
Lösung: <strong>{card.word}</strong>
</p>
)}
</>
)}
</>
) : (
<div className="nwt-success-bar">
<span className="nwt-success-left"> +{card.points} Punkt erhalten</span>
<span className="nwt-success-right">Gesamt: {card.totalPoints} Punkte</span>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
export default function TableIllustration() {
return (
<svg viewBox="0 0 200 160" width="200" height="160" aria-hidden="true">
<rect x="20" y="72" width="160" height="22" rx="4" fill="rgba(74,55,40,0.18)" />
<rect x="34" y="94" width="16" height="44" rx="3" fill="rgba(74,55,40,0.15)" />
<rect x="150" y="94" width="16" height="44" rx="3" fill="rgba(74,55,40,0.15)" />
<rect x="28" y="134" width="28" height="8" rx="3" fill="rgba(74,55,40,0.12)" />
<rect x="144" y="134" width="28" height="8" rx="3" fill="rgba(74,55,40,0.12)" />
</svg>
)
}

View File

@@ -0,0 +1,90 @@
import { useState } from 'react'
import LoginForm from './LoginForm'
import RegisterStep1 from './RegisterStep1'
import RegisterStep2 from './RegisterStep2'
const css = `
:root {
--bg: #F5F0E8; --surface: #FFFCF7; --border: #E2DAD0;
--text: #2C2520; --muted: #9A8F85; --accent: #5C7A5E;
--accent-lt: #EAF0EA; --danger: #C0544A; --danger-lt: #FBF0EF;
--radius: 14px;
}
@import url('https://fonts.googleapis.com/css2?family=Lora:wght@400;500&family=DM+Sans:wght@300;400;500&display=swap');
@keyframes fadeUp { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:none; } }
`
export default function AuthScreen() {
const [mode, setMode] = useState(() => localStorage.getItem('hejyou_last_mode') || 'login')
const [step, setStep] = useState('main')
const [pendingUserId, setPendingUserId] = useState(null)
const [pendingToken, setPendingToken] = useState(null)
const [successName, setSuccessName] = useState('')
const handleModeChange = (m) => {
setMode(m); localStorage.setItem('hejyou_last_mode', m); setStep('main')
}
const handleNeedsProfile = (userId, token) => {
setPendingUserId(userId); setPendingToken(token); setStep('profile')
}
return (
<>
<style>{css}</style>
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '24px', background: 'var(--bg)' }}>
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: '24px', padding: '48px 44px', width: '100%', maxWidth: '420px', boxShadow: '0 2px 40px rgba(44,37,32,0.06)', animation: 'fadeUp 0.3s ease' }}>
{/* Brand */}
<div style={{ textAlign: 'center', marginBottom: '36px' }}>
<div style={{ width: '48px', height: '48px', background: 'var(--accent)', borderRadius: '50%', margin: '0 auto 14px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round">
<circle cx="12" cy="12" r="10"/><path d="M8 12q2-5 4-4t4 4-4 4-4-4"/>
</svg>
</div>
<h1 style={{ fontFamily: 'Lora, serif', fontSize: '22px', fontWeight: 500, letterSpacing: '-0.3px', color: 'var(--text)' }}>HejYou</h1>
<p style={{ fontSize: '13px', color: 'var(--muted)', marginTop: '4px' }}>Sprachen lernen wie ein Kind</p>
</div>
{/* Toggle */}
{step === 'main' && (
<div style={{ display: 'flex', background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: '10px', padding: '3px', marginBottom: '32px', gap: '3px' }}>
{['login', 'register'].map(m => (
<button key={m} onClick={() => handleModeChange(m)} style={{
flex: 1, padding: '8px', border: 'none', borderRadius: '8px',
background: mode === m ? 'var(--surface)' : 'transparent',
fontFamily: 'DM Sans, sans-serif', fontSize: '13px', fontWeight: 500,
color: mode === m ? 'var(--text)' : 'var(--muted)', cursor: 'pointer',
boxShadow: mode === m ? '0 1px 4px rgba(44,37,32,0.08)' : 'none',
transition: 'all 0.2s',
}}>
{m === 'login' ? 'Anmelden' : 'Registrieren'}
</button>
))}
</div>
)}
{/* Screens */}
{step === 'main' && mode === 'login' && <LoginForm onNeedsProfile={handleNeedsProfile} />}
{step === 'main' && mode === 'register' && <RegisterStep1 onSuccess={(id, t) => handleNeedsProfile(id, t)} />}
{step === 'profile' && <RegisterStep2 userId={pendingUserId} userToken={pendingToken} onSuccess={(name) => { setSuccessName(name); setStep('success') }} />}
{/* Erfolg */}
{step === 'success' && (
<div style={{ textAlign: 'center', padding: '24px 0' }}>
<div style={{ width: '52px', height: '52px', background: 'var(--accent-lt)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px' }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
</div>
<strong style={{ fontFamily: 'Lora, serif', fontSize: '18px', display: 'block', marginBottom: '8px' }}>
Willkommen, {successName}!
</strong>
<p style={{ fontSize: '14px', color: 'var(--muted)' }}>Dein Abenteuer beginnt jetzt.</p>
</div>
)}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,48 @@
import { useState } from 'react'
import { login, getMe } from '../../api/directus'
import { useAuth } from '../../context/AuthContext'
import { FormGroup, Input, Button, Alert } from './ui'
export default function LoginForm({ onNeedsProfile }) {
const { saveToken, setUser } = useAuth()
const [email, setEmail] = useState('')
const [pw, setPw] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e) => {
e?.preventDefault()
if (!email || !pw) { setError('Bitte E-Mail und Passwort eingeben.'); return }
setError(''); setLoading(true)
try {
const { access_token } = await login(email, pw)
saveToken(access_token)
const me = await getMe(access_token)
setUser(me)
if (!me.username || !me.language_native || !me.language_target) {
onNeedsProfile(me.id, access_token)
}
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit}>
<Alert message={error} />
<FormGroup label="E-Mail">
<Input type="email" placeholder="deine@email.de" value={email}
onChange={e => setEmail(e.target.value)} autoComplete="email" autoFocus />
</FormGroup>
<FormGroup label="Passwort">
<Input type="password" placeholder="••••••••" value={pw}
onChange={e => setPw(e.target.value)} autoComplete="current-password" />
</FormGroup>
<Button type="submit" loading={loading} disabled={loading}>
{loading ? 'Anmelden…' : 'Anmelden'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,48 @@
import { useState } from 'react'
import { registerUser, login, getMe } from '../../api/directus'
import { useAuth } from '../../context/AuthContext'
import { FormGroup, Input, Button, Alert, StepDots } from './ui'
export default function RegisterStep1({ onSuccess }) {
const { saveToken } = useAuth()
const [email, setEmail] = useState('')
const [pw, setPw] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e) => {
e?.preventDefault()
if (!email || !pw) { setError('Bitte alle Felder ausfüllen.'); return }
if (pw.length < 8) { setError('Passwort muss mindestens 8 Zeichen haben.'); return }
setError(''); setLoading(true)
try {
await registerUser(email, pw)
const { access_token } = await login(email, pw)
saveToken(access_token)
const me = await getMe(access_token)
onSuccess(me.id, access_token)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit}>
<StepDots current={0} total={2} />
<Alert message={error} />
<FormGroup label="E-Mail">
<Input type="email" placeholder="deine@email.de" value={email}
onChange={e => setEmail(e.target.value)} autoComplete="email" autoFocus />
</FormGroup>
<FormGroup label="Passwort">
<Input type="password" placeholder="Mindestens 8 Zeichen" value={pw}
onChange={e => setPw(e.target.value)} autoComplete="new-password" />
</FormGroup>
<Button type="submit" loading={loading} disabled={loading}>
{loading ? 'Weiter…' : 'Weiter'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,85 @@
import { useState, useEffect } from 'react'
import { checkUsername, createProfile, getLanguageOptions } from '../../api/directus'
import { useAuth } from '../../context/AuthContext'
import { FormGroup, Input, Select, Button, Alert, StepDots } from './ui'
export default function RegisterStep2({ userId, userToken, onSuccess }) {
const { setUser } = useAuth()
const [username, setUsername] = useState('')
const [nativeLang, setNativeLang] = useState('')
const [targetLang, setTargetLang] = useState('')
const [languages, setLanguages] = useState([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => {
getLanguageOptions()
.then(setLanguages)
.catch(() => setError('Sprachen konnten nicht geladen werden.'))
}, [])
const handleSubmit = async (e) => {
e?.preventDefault()
if (!username || !nativeLang || !targetLang) {
setError('Bitte alle Felder ausfüllen.'); return
}
if (nativeLang === targetLang) {
setError('Muttersprache und Zielsprache dürfen nicht gleich sein.'); return
}
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
setError('Username: 320 Zeichen, nur Buchstaben, Zahlen und _'); return
}
setError(''); setLoading(true)
try {
const available = await checkUsername(username, userToken)
if (!available) {
setError('Dieser Username ist bereits vergeben.'); setLoading(false); return
}
await createProfile({ userId, username, nativeLang, targetLang, userToken })
setUser({ id: userId, username: userId, language_native: nativeLang, language_target: targetLang })
onSuccess(username)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit}>
<StepDots current={1} total={2} />
<Alert message={error} />
<FormGroup label="Username">
<Input
type="text"
placeholder="z. B. tim_lernt"
value={username}
onChange={e => setUsername(e.target.value)}
autoFocus
autoComplete="username"
/>
</FormGroup>
<FormGroup label="Deine Muttersprache">
<Select value={nativeLang} onChange={e => setNativeLang(e.target.value)}>
<option value="">Bitte wählen</option>
{languages.map(l => (
<option key={l.id} value={l.id}>{l.flag} {l.label}</option>
))}
</Select>
</FormGroup>
<FormGroup label="Ich lerne…">
<Select value={targetLang} onChange={e => setTargetLang(e.target.value)}>
<option value="">Bitte wählen</option>
{languages
.filter(l => l.id !== nativeLang)
.map(l => (
<option key={l.id} value={l.id}>{l.flag} {l.label}</option>
))}
</Select>
</FormGroup>
<Button type="submit" loading={loading} disabled={loading || languages.length === 0}>
{loading ? 'Erstelle Profil…' : 'Loslegen'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,79 @@
.formGroup { margin-bottom: 16px; }
.label {
display: block;
font-size: 11px;
font-weight: 500;
color: var(--muted);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 6px;
}
.input {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
font-family: 'DM Sans', sans-serif;
font-size: 15px;
color: var(--text);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
appearance: none;
-webkit-appearance: none;
}
.input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(92,122,94,0.12);
background: var(--surface);
}
.selectWrap { position: relative; }
.selectArrow {
position: absolute; right: 14px; top: 50%;
transform: translateY(-50%);
width: 0; height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid var(--muted);
pointer-events: none;
}
.selectWrap .input { padding-right: 36px; cursor: pointer; }
.btn {
width: 100%; padding: 13px; margin-top: 8px;
background: var(--accent); color: #fff;
border: none; border-radius: var(--radius);
font-family: 'DM Sans', sans-serif;
font-size: 15px; font-weight: 500; cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 8px;
transition: background 0.2s, transform 0.1s;
}
.btn:hover:not(:disabled) { background: #4a6650; }
.btn:active:not(:disabled) { transform: scale(0.98); }
.btn:disabled { background: var(--border); color: var(--muted); cursor: not-allowed; }
.spinner {
width: 14px; height: 14px;
border: 2px solid rgba(255,255,255,0.35);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.alert {
background: var(--danger-lt);
border: 1px solid #EBCBC8;
border-radius: var(--radius);
padding: 10px 14px;
font-size: 13px; color: var(--danger);
margin-bottom: 16px;
}
.stepDots { display: flex; align-items: center; gap: 6px; margin-bottom: 24px; }
.stepDot { height: 6px; border-radius: 3px; transition: all 0.25s ease; }
.stepLabel { font-size: 11px; color: var(--muted); margin-left: 4px; }
@keyframes spin { to { transform: rotate(360deg); } }

View File

@@ -0,0 +1,51 @@
import styles from './auth.module.css'
export function FormGroup({ label, children }) {
return (
<div className={styles.formGroup}>
{label && <label className={styles.label}>{label}</label>}
{children}
</div>
)
}
export function Input(props) {
return <input className={styles.input} {...props} />
}
export function Select({ children, ...props }) {
return (
<div className={styles.selectWrap}>
<select className={styles.input} {...props}>{children}</select>
<div className={styles.selectArrow} />
</div>
)
}
export function Button({ loading, children, ...props }) {
return (
<button className={styles.btn} {...props}>
{children}
{loading && <span className={styles.spinner} />}
</button>
)
}
export function Alert({ message }) {
if (!message) return null
return <div className={styles.alert}>{message}</div>
}
export function StepDots({ current, total }) {
return (
<div className={styles.stepDots}>
{Array.from({ length: total }).map((_, i) => (
<div key={i} className={styles.stepDot} style={{
background: i === current ? 'var(--accent)' : 'var(--border)',
width: i === current ? '18px' : '6px',
}} />
))}
<span className={styles.stepLabel}>Schritt {current + 1} von {total}</span>
</div>
)
}