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:
191
src/components/AudioQuizCard.css
Normal file
191
src/components/AudioQuizCard.css
Normal 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; }
|
||||
106
src/components/AudioQuizCard.jsx
Normal file
106
src/components/AudioQuizCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
src/components/CardShared.css
Normal file
114
src/components/CardShared.css
Normal 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;
|
||||
}
|
||||
95
src/components/ImagePickCard.css
Normal file
95
src/components/ImagePickCard.css
Normal 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;
|
||||
}
|
||||
125
src/components/ImagePickCard.jsx
Normal file
125
src/components/ImagePickCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
140
src/components/ImageQuizCard.css
Normal file
140
src/components/ImageQuizCard.css
Normal 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;
|
||||
}
|
||||
110
src/components/ImageQuizCard.jsx
Normal file
110
src/components/ImageQuizCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
145
src/components/LanguageParentCard.css
Normal file
145
src/components/LanguageParentCard.css
Normal 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; }
|
||||
222
src/components/LanguageParentCard.jsx
Normal file
222
src/components/LanguageParentCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
129
src/components/LetterOrderCard.css
Normal file
129
src/components/LetterOrderCard.css
Normal 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;
|
||||
}
|
||||
131
src/components/LetterOrderCard.jsx
Normal file
131
src/components/LetterOrderCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
src/components/NewWordTextCard.css
Normal file
77
src/components/NewWordTextCard.css
Normal 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;
|
||||
}
|
||||
80
src/components/NewWordTextCard.jsx
Normal file
80
src/components/NewWordTextCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
src/components/NewWordVoiceCard.css
Normal file
95
src/components/NewWordVoiceCard.css
Normal 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;
|
||||
}
|
||||
128
src/components/NewWordVoiceCard.jsx
Normal file
128
src/components/NewWordVoiceCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
src/components/SentenceFillCard.jsx
Normal file
97
src/components/SentenceFillCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
src/components/TableIllustration.jsx
Normal file
11
src/components/TableIllustration.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
src/components/auth/AuthScreen.jsx
Normal file
90
src/components/auth/AuthScreen.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
48
src/components/auth/LoginForm.jsx
Normal file
48
src/components/auth/LoginForm.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
src/components/auth/RegisterStep1.jsx
Normal file
48
src/components/auth/RegisterStep1.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
src/components/auth/RegisterStep2.jsx
Normal file
85
src/components/auth/RegisterStep2.jsx
Normal 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: 3–20 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>
|
||||
)
|
||||
}
|
||||
79
src/components/auth/auth.module.css
Normal file
79
src/components/auth/auth.module.css
Normal 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); } }
|
||||
51
src/components/auth/ui.jsx
Normal file
51
src/components/auth/ui.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user