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:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
.claude/
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG VITE_DIRECTUS_URL
|
||||
ENV VITE_DIRECTUS_URL=$VITE_DIRECTUS_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Language App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
20
nginx.conf
Normal file
20
nginx.conf
Normal file
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
}
|
||||
1816
package-lock.json
generated
Normal file
1816
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
package.json
Normal file
1
package.json
Normal file
@@ -0,0 +1 @@
|
||||
{"name":"language-app","version":"1.0.0","type":"module","scripts":{"dev":"vite","build":"vite build","preview":"vite preview"},"dependencies":{"canvas-confetti":"^1.9.4","react":"^19.0.0","react-dom":"^19.0.0"},"devDependencies":{"@types/react":"^19.0.0","@types/react-dom":"^19.0.0","@vitejs/plugin-react":"^4.3.4","vite":"^6.3.1"}}
|
||||
685
public/admin/kachel/index.html
Normal file
685
public/admin/kachel/index.html
Normal file
@@ -0,0 +1,685 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kachel-Übersicht — Admin</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Lora:wght@700&family=Nunito:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
background: #EDE0CE;
|
||||
color: #4A3728;
|
||||
padding: 40px 24px 80px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: 'Lora', Georgia, serif;
|
||||
font-size: 24px;
|
||||
color: #4A3728;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: #8C7A65;
|
||||
margin-bottom: 40px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
color: #8C7A65;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: 'Lora', Georgia, serif;
|
||||
font-size: 18px;
|
||||
color: #4A3728;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 13px;
|
||||
color: #8C7A65;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: #7A5C3A;
|
||||
color: #EDE0CE;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 3px 8px;
|
||||
border-radius: 99px;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ── Card shell ── */
|
||||
.nw-card {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
border: 0.5px solid #D4B896;
|
||||
box-shadow: 0 4px 24px rgba(74,55,40,0.1);
|
||||
background: #F5EFE6;
|
||||
}
|
||||
|
||||
.nw-image {
|
||||
background: #C4A882;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 20px 16px 24px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.nw-lang-pill {
|
||||
position: absolute; top: 14px; left: 14px;
|
||||
background: #7A5C3A; color: #EDE0CE;
|
||||
font-size: 12px; font-weight: 700;
|
||||
padding: 5px 12px; border-radius: 99px;
|
||||
}
|
||||
|
||||
.nw-points-pill {
|
||||
position: absolute; top: 14px; right: 14px;
|
||||
background: #F5EFE6; color: #7A5C3A;
|
||||
font-size: 12px; font-weight: 700;
|
||||
padding: 5px 12px; border-radius: 99px;
|
||||
border: 0.5px solid #D4B896;
|
||||
}
|
||||
|
||||
.nw-bubble {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -68%);
|
||||
background: rgba(237,224,206,0.55);
|
||||
color: rgba(74,55,40,0.7);
|
||||
font-family: 'Lora', Georgia, serif;
|
||||
font-size: 24px; font-weight: 700;
|
||||
width: 96px; height: 96px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.nw-divider { height: 0.5px; background: #D4B896; margin-bottom: 14px; }
|
||||
|
||||
.nw-label { font-size: 12px; color: #8C7A65; margin-bottom: 10px; display: block; }
|
||||
|
||||
/* Text input */
|
||||
.nwt-input-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: #fff; border: 1px solid #D4B896;
|
||||
border-radius: 12px; padding: 4px 6px 4px 12px;
|
||||
}
|
||||
|
||||
.nwt-input {
|
||||
flex: 1; border: none; outline: none; background: transparent;
|
||||
font-family: 'Lora', Georgia, serif;
|
||||
font-size: 16px; color: rgba(74,55,40,0.35); padding: 6px 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Voice mic */
|
||||
.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;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #7A5C3A;
|
||||
}
|
||||
|
||||
.nwv-correct-text { font-size: 13px; font-weight: 700; color: #5a7a3a; }
|
||||
|
||||
/* Success bar */
|
||||
.nwt-success-bar, .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;
|
||||
}
|
||||
|
||||
.nwv-success-bar { margin-top: 14px; }
|
||||
|
||||
.success-left { font-size: 13px; font-weight: 700; color: #4A3728; }
|
||||
.success-right { font-size: 12px; color: #8C7A65; }
|
||||
|
||||
hr.page-divider {
|
||||
border: none;
|
||||
border-top: 1px solid #D4B896;
|
||||
margin: 48px 0;
|
||||
}
|
||||
|
||||
/* ── image_quiz tile ── */
|
||||
.iq-image {
|
||||
position: relative;
|
||||
background: #C4A882;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
.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);
|
||||
z-index: 1; pointer-events: none;
|
||||
}
|
||||
.iq-question-row {
|
||||
display: flex; align-items: baseline; gap: 8px; margin-bottom: 14px;
|
||||
}
|
||||
.iq-question { font-size: 15px; font-weight: 700; color: #4A3728; }
|
||||
.iq-hint { font-size: 12px; color: #8C7A65; }
|
||||
.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;
|
||||
}
|
||||
.iq-chip-selected {
|
||||
background: rgba(122,92,58,0.12); border-color: #7A5C3A;
|
||||
}
|
||||
.iq-confirm-btn {
|
||||
width: 100%; padding: 14px; border: none; border-radius: 14px;
|
||||
background: #D4B896; color: #8C7A65;
|
||||
font-family: 'Nunito', sans-serif; font-size: 15px; font-weight: 700;
|
||||
}
|
||||
.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; }
|
||||
.iq-success-right { font-size: 12px; color: #8C7A65; }
|
||||
|
||||
/* ── letter_order tile ── */
|
||||
.lo-prompt-row { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:14px; }
|
||||
.lo-prompt { font-size:13px; color:#8C7A65; line-height:1.5; flex:1; }
|
||||
.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; background:rgba(255,255,255,0.4);
|
||||
}
|
||||
.lo-answer-correct { border-color:#5a7a3a; background:rgba(90,122,58,0.06); }
|
||||
.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;
|
||||
background:#7A5C3A; color:#F5EFE6; border:none;
|
||||
}
|
||||
.lo-available { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:12px; min-height:38px; }
|
||||
.lo-back-btn { font-size:13px; font-weight:700; color:#7A5C3A; background:transparent; border:none; }
|
||||
.lo-success-text { 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-size:13px; font-weight:700; color:#4A3728; }
|
||||
.lo-success-right { font-size:12px; color:#8C7A65; }
|
||||
|
||||
/* ── image_pick tile ── */
|
||||
.ip-image { background:#C4A882; display:flex; flex-direction:column; align-items:center; justify-content:flex-end; padding:20px 16px 12px; min-height:200px; }
|
||||
.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); }
|
||||
.ip-dot-active { background:rgba(74,55,40,0.7); }
|
||||
.ip-btn-row { display:flex; gap:10px; }
|
||||
.ip-btn { flex:1; padding:13px 8px; border-radius:14px; border:1px solid #D4B896; background:#F5EFE6; font-size:14px; font-weight:700; color:#8C7A65; display:flex; align-items:center; justify-content:center; gap:6px; }
|
||||
.ip-btn-ja { background:#7A5C3A; color:#F5EFE6; border-color:#7A5C3A; }
|
||||
.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-size:13px; font-weight:700; color:#4A3728; }
|
||||
.ip-success-right { font-size:12px; color:#8C7A65; }
|
||||
|
||||
/* ── audio_quiz tile ── */
|
||||
.aq-card { background:#F5EFE6; }
|
||||
.aq-header { background:#2C1A0E; }
|
||||
.aq-points-pill-dark { background:#2C1A0E; color:#C4A882; font-size:12px; font-weight:700; padding:5px 12px; border-radius:99px; border:0.5px solid #4A3020; }
|
||||
.aq-image { background:#2C1A0E; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px; padding:32px 20px 20px; min-height:200px; }
|
||||
.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; }
|
||||
.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; }
|
||||
.aq-dots-dark { display:flex; gap:5px; }
|
||||
.aq-dot-dark { width:5px; height:5px; border-radius:50%; background:rgba(196,168,130,0.3); }
|
||||
.aq-tap-hint { font-size:12px; color:rgba(196,168,130,0.5); }
|
||||
.aq-question { font-size:15px; font-weight:700; color:#4A3728; margin-bottom:14px; }
|
||||
.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; }
|
||||
.aq-option-label { font-size:12px; font-weight:700; color:#8C7A65; width:16px; }
|
||||
.aq-option-text { font-size:14px; font-weight:600; color:#4A3728; }
|
||||
.aq-option-selected-ex { background:rgba(90,122,58,0.12); border-color:#5a7a3a; }
|
||||
.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-size:13px; font-weight:700; color:#4A3728; }
|
||||
.aq-success-right { font-size:12px; color:#8C7A65; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Kachel-Übersicht</h1>
|
||||
<p class="subtitle">Admin · alle verfügbaren Kacheltypen mit Beispiel</p>
|
||||
|
||||
<!-- ── new_word_text ── -->
|
||||
<div class="section">
|
||||
<div class="tag">new_word_text</div>
|
||||
<div class="section-title">Neues Wort — Text</div>
|
||||
<p class="section-desc">
|
||||
Der Nutzer sieht das Wort mit Bild und tippt die Übersetzung in der Zielsprache ein.
|
||||
Sofortiges Feedback: richtig / falsch. Bei Erfolg Punkte-Anzeige.
|
||||
</p>
|
||||
|
||||
<div class="nw-card">
|
||||
<div class="nw-image">
|
||||
<span class="nw-lang-pill">Svenska</span>
|
||||
<span class="nw-points-pill">★ +1 Punkt</span>
|
||||
<div class="nw-bubble">bord</div>
|
||||
<svg viewBox="0 0 200 160" width="180" height="144" 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>
|
||||
</div>
|
||||
<div class="nw-content">
|
||||
<div class="nw-word-row">
|
||||
<span class="nw-word">bordet</span>
|
||||
<span class="nw-translation">der Tisch</span>
|
||||
</div>
|
||||
<div class="nw-divider"></div>
|
||||
<label class="nw-label">Schreib das schwedische Wort</label>
|
||||
<div class="nwt-input-row">
|
||||
<input class="nwt-input" placeholder="bordet …" readonly />
|
||||
<button class="nwt-submit-btn">
|
||||
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="4,10 9,15 16,6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="page-divider" />
|
||||
|
||||
<!-- ── new_word_voice ── -->
|
||||
<div class="section">
|
||||
<div class="tag">new_word_voice</div>
|
||||
<div class="section-title">Neues Wort — Sprache</div>
|
||||
<p class="section-desc">
|
||||
Der Nutzer sieht das Wort und spricht es laut aus. Die Web Speech API erkennt die Aussprache
|
||||
und gibt Feedback. Bei Erfolg erscheint die Punkte-Leiste.
|
||||
</p>
|
||||
|
||||
<div class="nw-card">
|
||||
<div class="nw-image">
|
||||
<span class="nw-lang-pill">Svenska</span>
|
||||
<span class="nw-points-pill">★ +1 Punkt</span>
|
||||
<div class="nw-bubble">bord</div>
|
||||
<svg viewBox="0 0 200 160" width="180" height="144" 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>
|
||||
</div>
|
||||
<div class="nw-content">
|
||||
<div class="nw-word-row">
|
||||
<span class="nw-word">bordet</span>
|
||||
<span class="nw-translation">der Tisch</span>
|
||||
</div>
|
||||
<div class="nw-divider"></div>
|
||||
<label class="nw-label">Spreche das schwedische Wort</label>
|
||||
<div class="nwv-mic-row">
|
||||
<div class="nwv-mic-btn">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="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>
|
||||
</div>
|
||||
<span class="nwv-correct-text">Bra! Aussprache erkannt.</span>
|
||||
</div>
|
||||
<div class="nwv-success-bar">
|
||||
<span class="success-left">★ +1 Punkt erhalten</span>
|
||||
<span class="success-right">Gesamt: 47 Punkte</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<hr class="page-divider" />
|
||||
|
||||
<!-- ── letter_order ── -->
|
||||
<div class="section">
|
||||
<div class="tag">letter_order</div>
|
||||
<div class="section-title">Buchstaben-Reihenfolge</div>
|
||||
<p class="section-desc">
|
||||
Der Nutzer sieht ein Bild und tippt die durcheinander gewürfelten Buchstaben in der richtigen Reihenfolge.
|
||||
Grüner Rahmen + Erfolgsmeldung bei vollständig richtiger Eingabe.
|
||||
</p>
|
||||
|
||||
<div class="nw-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px 10px;background:#F5EFE6;">
|
||||
<span class="nw-lang-pill">Svenska</span>
|
||||
<span class="nw-points-pill">★ +1 Punkt</span>
|
||||
</div>
|
||||
<div class="nw-image">
|
||||
<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>
|
||||
</div>
|
||||
<div class="nw-content">
|
||||
<div class="lo-prompt-row">
|
||||
<span class="lo-prompt">Tippe die Buchstaben in der richtigen Reihenfolge</span>
|
||||
<span class="nw-translation">das Sofa</span>
|
||||
</div>
|
||||
<!-- Correct state: green border, all letters placed -->
|
||||
<div class="lo-answer-area lo-answer-correct">
|
||||
<span class="lo-chip">s</span>
|
||||
<span class="lo-chip">o</span>
|
||||
<span class="lo-chip">f</span>
|
||||
<span class="lo-chip">f</span>
|
||||
<span class="lo-chip">a</span>
|
||||
</div>
|
||||
<div class="nw-divider"></div>
|
||||
<p class="lo-success-text">Perfekt! Bra jobbat!</p>
|
||||
<div class="lo-success-bar">
|
||||
<span class="lo-success-left">★ +1 Punkt erhalten</span>
|
||||
<span class="lo-success-right">Gesamt: 47 Punkte</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Idle state -->
|
||||
<p style="font-size:12px;color:#8C7A65;margin:20px 0 12px;font-weight:700;letter-spacing:0.04em;">ZUSTAND: Wartend (gemischt)</p>
|
||||
<div class="nw-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px 10px;background:#F5EFE6;">
|
||||
<span class="nw-lang-pill">Svenska</span>
|
||||
<span class="nw-points-pill">★ +1 Punkt</span>
|
||||
</div>
|
||||
<div class="nw-image">
|
||||
<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>
|
||||
</div>
|
||||
<div class="nw-content">
|
||||
<div class="lo-prompt-row">
|
||||
<span class="lo-prompt">Tippe die Buchstaben in der richtigen Reihenfolge</span>
|
||||
<span class="nw-translation">das Sofa</span>
|
||||
</div>
|
||||
<div class="lo-answer-area">
|
||||
<span class="lo-chip">s</span>
|
||||
<span class="lo-chip">o</span>
|
||||
</div>
|
||||
<div class="nw-divider"></div>
|
||||
<div class="lo-available">
|
||||
<button class="lo-chip">f</button>
|
||||
<button class="lo-chip">f</button>
|
||||
<button class="lo-chip">a</button>
|
||||
</div>
|
||||
<button class="lo-back-btn">← Zurück</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="page-divider" />
|
||||
|
||||
<!-- ── image_pick ── -->
|
||||
<div class="section">
|
||||
<div class="tag">image_pick</div>
|
||||
<div class="section-title">Bild auswählen — Ja / Nein</div>
|
||||
<p class="section-desc">
|
||||
Der Nutzer sieht ein Wort und blättert durch Bilder. Mit „Ja, das ist es" wählt er das passende Bild aus.
|
||||
Punkte gibt es nur beim richtigen Bild.
|
||||
</p>
|
||||
|
||||
<div class="nw-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px 10px;background:#F5EFE6;">
|
||||
<span class="nw-lang-pill">Svenska</span>
|
||||
<span class="nw-points-pill">★ +1 Punkt</span>
|
||||
</div>
|
||||
<div class="ip-image">
|
||||
<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>
|
||||
<div class="ip-dots">
|
||||
<span class="ip-dot ip-dot-active"></span>
|
||||
<span class="ip-dot"></span>
|
||||
<span class="ip-dot"></span>
|
||||
<span class="ip-dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nw-content">
|
||||
<div class="nw-word-row">
|
||||
<span class="nw-word">soffa</span>
|
||||
<span class="nw-translation">das Sofa</span>
|
||||
</div>
|
||||
<div class="nw-divider"></div>
|
||||
<label class="nw-label">Welches Bild zeigt das Wort?</label>
|
||||
<div class="ip-btn-row">
|
||||
<button class="ip-btn">✕ Nein</button>
|
||||
<button class="ip-btn ip-btn-ja">✓ Ja, das ist es</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="page-divider" />
|
||||
|
||||
<!-- ── audio_quiz ── -->
|
||||
<div class="section">
|
||||
<div class="tag">audio_quiz</div>
|
||||
<div class="section-title">Audio-Quiz — Mehrfachwahl</div>
|
||||
<p class="section-desc">
|
||||
Der Nutzer hört einen gesprochenen Begriff und wählt die richtige deutsche Übersetzung aus vier Optionen.
|
||||
Dunkles Karten-Design mit Lautsprecher-Animation.
|
||||
</p>
|
||||
|
||||
<div class="nw-card aq-card" style="overflow:hidden;">
|
||||
<div class="aq-header" style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px 10px;">
|
||||
<span class="nw-lang-pill">Svenska</span>
|
||||
<span class="aq-points-pill-dark">★ +1 Punkt</span>
|
||||
</div>
|
||||
<div class="aq-image">
|
||||
<div class="aq-speaker-ring">
|
||||
<div class="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)"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aq-dots-dark">
|
||||
<span class="aq-dot-dark"></span>
|
||||
<span class="aq-dot-dark"></span>
|
||||
<span class="aq-dot-dark"></span>
|
||||
<span class="aq-dot-dark"></span>
|
||||
<span class="aq-dot-dark"></span>
|
||||
<span class="aq-dot-dark"></span>
|
||||
</div>
|
||||
<p class="aq-tap-hint">Tippe zum Abspielen</p>
|
||||
</div>
|
||||
<div class="nw-content">
|
||||
<p class="aq-question">Was hörst du?</p>
|
||||
<div class="aq-options">
|
||||
<div class="aq-option"><span class="aq-option-label">A</span><span class="aq-option-text">die Küche</span></div>
|
||||
<div class="aq-option aq-option-selected-ex"><span class="aq-option-label" style="color:#3a5a1e">B</span><span class="aq-option-text" style="color:#3a5a1e">das Wohnzimmer</span></div>
|
||||
<div class="aq-option"><span class="aq-option-label">C</span><span class="aq-option-text">das Schlafzimmer</span></div>
|
||||
<div class="aq-option"><span class="aq-option-label">D</span><span class="aq-option-text">das Badezimmer</span></div>
|
||||
</div>
|
||||
<div class="nw-divider"></div>
|
||||
<div class="aq-success-bar">
|
||||
<span class="aq-success-left">★ +1 Punkt erhalten</span>
|
||||
<span class="aq-success-right">Gesamt: 47 Punkte</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="page-divider" />
|
||||
|
||||
<!-- ── image_quiz ── -->
|
||||
<div class="section">
|
||||
<div class="tag">image_quiz</div>
|
||||
<div class="section-title">Bild-Quiz — Mehrfachauswahl</div>
|
||||
<p class="section-desc">
|
||||
Der Nutzer sieht ein Bild einer Szene und wählt alle passenden schwedischen Wörter aus.
|
||||
Mehrere Antworten sind möglich. Bei vollständig richtiger Auswahl gibt es Punkte.
|
||||
</p>
|
||||
|
||||
<div class="nw-card">
|
||||
<!-- State: some chips selected, confirm button active -->
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px 10px;background:#F5EFE6;">
|
||||
<span class="nw-lang-pill">Svenska</span>
|
||||
<span class="nw-points-pill">★ +1 Punkt</span>
|
||||
</div>
|
||||
<div class="iq-image">
|
||||
<span class="iq-scene-label">vardagsrum</span>
|
||||
<svg viewBox="0 0 200 180" width="200" height="180" aria-hidden="true">
|
||||
<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)"/>
|
||||
<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)"/>
|
||||
<rect x="22" y="74" width="156" height="40" rx="10" fill="rgba(74,55,40,0.18)"/>
|
||||
<rect x="22" y="58" width="156" height="22" rx="8" fill="rgba(74,55,40,0.14)"/>
|
||||
<rect x="32" y="76" width="64" height="34" rx="8" fill="rgba(74,55,40,0.12)"/>
|
||||
<rect x="104" y="76" width="64" height="34" rx="8" fill="rgba(74,55,40,0.12)"/>
|
||||
<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>
|
||||
</div>
|
||||
<div class="nw-content">
|
||||
<div class="iq-question-row">
|
||||
<span class="iq-question">Was siehst du im Bild?</span>
|
||||
<span class="iq-hint">Mehrere möglich</span>
|
||||
</div>
|
||||
<div class="iq-chips">
|
||||
<button class="iq-chip iq-chip-selected">soffa</button>
|
||||
<button class="iq-chip">bord</button>
|
||||
<button class="iq-chip">lampa</button>
|
||||
<button class="iq-chip">stol</button>
|
||||
<button class="iq-chip">fönster</button>
|
||||
<button class="iq-chip iq-chip-selected">matta</button>
|
||||
<button class="iq-chip">bil</button>
|
||||
<button class="iq-chip">dörr</button>
|
||||
</div>
|
||||
<div class="nw-divider"></div>
|
||||
<button class="iq-confirm-btn" style="background:#7A5C3A;color:#F5EFE6;">Bestätigen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- State: correct -->
|
||||
<p style="font-size:12px;color:#8C7A65;margin:20px 0 12px;font-weight:700;letter-spacing:0.04em;">ZUSTAND: Richtig</p>
|
||||
<div class="nw-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px 10px;background:#F5EFE6;">
|
||||
<span class="nw-lang-pill">Svenska</span>
|
||||
<span class="nw-points-pill">★ +1 Punkt</span>
|
||||
</div>
|
||||
<div class="iq-image">
|
||||
<span class="iq-scene-label">vardagsrum</span>
|
||||
<svg viewBox="0 0 200 180" width="200" height="180" aria-hidden="true">
|
||||
<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)"/>
|
||||
<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)"/>
|
||||
<rect x="22" y="74" width="156" height="40" rx="10" fill="rgba(74,55,40,0.18)"/>
|
||||
<rect x="22" y="58" width="156" height="22" rx="8" fill="rgba(74,55,40,0.14)"/>
|
||||
<rect x="32" y="76" width="64" height="34" rx="8" fill="rgba(74,55,40,0.12)"/>
|
||||
<rect x="104" y="76" width="64" height="34" rx="8" fill="rgba(74,55,40,0.12)"/>
|
||||
<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>
|
||||
</div>
|
||||
<div class="nw-content">
|
||||
<div class="iq-question-row">
|
||||
<span class="iq-question">Was siehst du im Bild?</span>
|
||||
<span class="iq-hint">Mehrere möglich</span>
|
||||
</div>
|
||||
<div class="iq-chips">
|
||||
<button class="iq-chip" style="background:rgba(90,122,58,0.12);border-color:#5a7a3a;color:#3a5a1e;">soffa</button>
|
||||
<button class="iq-chip">bord</button>
|
||||
<button class="iq-chip">lampa</button>
|
||||
<button class="iq-chip">stol</button>
|
||||
<button class="iq-chip">fönster</button>
|
||||
<button class="iq-chip" style="background:rgba(90,122,58,0.12);border-color:#5a7a3a;color:#3a5a1e;">matta</button>
|
||||
<button class="iq-chip">bil</button>
|
||||
<button class="iq-chip">dörr</button>
|
||||
</div>
|
||||
<div class="nw-divider"></div>
|
||||
<div class="iq-success-bar">
|
||||
<span class="iq-success-left">★ +1 Punkt erhalten</span>
|
||||
<span class="iq-success-right">Gesamt: 47 Punkte</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
45
src/App.jsx
Normal file
45
src/App.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useState } from 'react'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import AuthScreen from './components/auth/AuthScreen'
|
||||
import BottomNav from './BottomNav'
|
||||
import Feed from './pages/Feed'
|
||||
import Game from './pages/Game'
|
||||
import Pro from './pages/Pro'
|
||||
import Profil from './pages/Profil'
|
||||
|
||||
const PAGES = { feed: Feed, game: Game, pro: Pro, profil: Profil }
|
||||
|
||||
function AppContent() {
|
||||
const { user, loading } = useAuth()
|
||||
const [page, setPage] = useState('feed')
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#F5F0E8' }}>
|
||||
<div style={{ width: '32px', height: '32px', border: '2px solid #E2DAD0', borderTopColor: '#5C7A5E', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user || !user.username || !user.language_native || !user.language_target) {
|
||||
return <AuthScreen />
|
||||
}
|
||||
|
||||
const PageComponent = PAGES[page] || Feed
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#F5F0E8', paddingBottom: '72px' }}>
|
||||
<PageComponent />
|
||||
<BottomNav active={page} onNavigate={setPage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
44
src/BottomNav.css
Normal file
44
src/BottomNav.css
Normal file
@@ -0,0 +1,44 @@
|
||||
.bottom-nav {
|
||||
display: flex;
|
||||
background: #C4A882;
|
||||
border-top: 1px solid #D4B896;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: rgba(74, 55, 40, 0.45);
|
||||
transition: color 0.2s;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: #7A5C3A;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
53
src/BottomNav.jsx
Normal file
53
src/BottomNav.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import './BottomNav.css'
|
||||
|
||||
const IconHouse = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 10.5L12 3l9 7.5V21a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V10.5z" />
|
||||
<path d="M9 22V12h6v10" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const IconPlay = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<polygon points="10,8.5 17,12 10,15.5" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const IconMountain = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 20L9 7l4 6 2.5-3L22 20H2z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const IconGoal = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const tabs = [
|
||||
{ id: 'feed', label: 'Feed', Icon: IconHouse },
|
||||
{ id: 'game', label: 'Game', Icon: IconPlay },
|
||||
{ id: 'pro', label: 'Pro', Icon: IconMountain },
|
||||
{ id: 'profil', label: 'Profil', Icon: IconGoal },
|
||||
]
|
||||
|
||||
export default function BottomNav({ active, onNavigate }) {
|
||||
return (
|
||||
<nav className="bottom-nav">
|
||||
{tabs.map(({ id, label, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
className={`nav-item ${active === id ? 'active' : ''}`}
|
||||
onClick={() => onNavigate(id)}
|
||||
>
|
||||
<span className="nav-icon"><Icon /></span>
|
||||
<span className="nav-label">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
270
src/api/directus.js
Normal file
270
src/api/directus.js
Normal file
@@ -0,0 +1,270 @@
|
||||
const BASE = import.meta.env.VITE_DIRECTUS_URL
|
||||
|
||||
const json = { 'Content-Type': 'application/json' }
|
||||
const auth = (token) => ({ 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` })
|
||||
|
||||
export async function login(email, password) {
|
||||
const res = await fetch(`${BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: json,
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Login fehlgeschlagen.')
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function getMe(userToken) {
|
||||
const res = await fetch(`${BASE}/users/me?fields=id,username,language_native,language_target`, {
|
||||
headers: auth(userToken),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error('Profil konnte nicht geladen werden.')
|
||||
return data.data
|
||||
}
|
||||
|
||||
// Nutzt den öffentlichen Registrierungsendpunkt — kein Admin-Token nötig
|
||||
export async function registerUser(email, password) {
|
||||
const res = await fetch(`${BASE}/users/register`, {
|
||||
method: 'POST',
|
||||
headers: json,
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
if (res.status === 204) return null
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Registrierung fehlgeschlagen.')
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function checkUsername(username, userToken) {
|
||||
const clean = username.toLowerCase().replace(/[^a-z0-9_]/g, '')
|
||||
const res = await fetch(
|
||||
`${BASE}/items/users_language?filter[username_lowercases][_eq]=${encodeURIComponent(clean)}&fields=id&limit=1`,
|
||||
{ headers: auth(userToken) }
|
||||
)
|
||||
const data = await res.json()
|
||||
return Array.isArray(data.data) && data.data.length === 0
|
||||
}
|
||||
|
||||
export async function createProfile({ userId, username, nativeLang, targetLang, userToken }) {
|
||||
const clean = username.toLowerCase().replace(/[^a-z0-9_]/g, '')
|
||||
const h = auth(userToken)
|
||||
|
||||
const profileRes = await fetch(`${BASE}/items/users_language`, {
|
||||
method: 'POST',
|
||||
headers: h,
|
||||
body: JSON.stringify({ username_public: username, username_lowercases: clean }),
|
||||
})
|
||||
const profileData = await profileRes.json()
|
||||
if (!profileRes.ok) throw new Error(profileData.errors?.[0]?.message || 'Profil konnte nicht erstellt werden.')
|
||||
const profileId = profileData.data.id
|
||||
|
||||
await fetch(`${BASE}/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
headers: h,
|
||||
body: JSON.stringify({ username: profileId, language_native: nativeLang, language_target: targetLang }),
|
||||
})
|
||||
|
||||
await fetch(`${BASE}/items/users_language/${profileId}`, {
|
||||
method: 'PATCH',
|
||||
headers: h,
|
||||
body: JSON.stringify({ user: userId }),
|
||||
})
|
||||
|
||||
await fetch(`${BASE}/items/learning_pairs`, {
|
||||
method: 'POST',
|
||||
headers: h,
|
||||
body: JSON.stringify({ user: profileId, language_from: nativeLang, language_to: targetLang, active: true, current_level: 1, points: 0 }),
|
||||
})
|
||||
|
||||
return profileId
|
||||
}
|
||||
|
||||
const LANG_META = {
|
||||
de: { flag: '🇩🇪', speech: 'de-DE' },
|
||||
en: { flag: '🇬🇧', speech: 'en-US' },
|
||||
se: { flag: '🇸🇪', speech: 'sv-SE' },
|
||||
}
|
||||
|
||||
// Lädt Sprachen aus Directus (public, kein Token nötig)
|
||||
export async function getLanguageOptions() {
|
||||
const res = await fetch(
|
||||
`${BASE}/items/language_options?filter[status][_eq]=published&fields=id,title_de,short&sort=title_en`
|
||||
)
|
||||
const data = await res.json()
|
||||
return (data.data || []).map(l => ({
|
||||
id: l.id,
|
||||
label: l.title_de,
|
||||
suffix: l.short,
|
||||
...(LANG_META[l.short] || { flag: '🌐', speech: l.short }),
|
||||
}))
|
||||
}
|
||||
|
||||
export function langById(id, options) {
|
||||
return (options || []).find(l => l.id === id) || null
|
||||
}
|
||||
|
||||
export async function getActiveLearningPair(profileId, userToken) {
|
||||
const res = await fetch(
|
||||
`${BASE}/items/learning_pairs?filter[user][_eq]=${profileId}&filter[active][_eq]=true` +
|
||||
`&fields=id,language_from,language_to,current_level,points&limit=1`,
|
||||
{ headers: auth(userToken) }
|
||||
)
|
||||
const data = await res.json()
|
||||
return data.data?.[0] || null
|
||||
}
|
||||
|
||||
export async function getWords(userToken, limit = 100) {
|
||||
const res = await fetch(
|
||||
`${BASE}/items/words?filter[status][_eq]=published` +
|
||||
`&fields=id,title_de,title_en,title_se,level&limit=${limit}`,
|
||||
{ headers: auth(userToken) }
|
||||
)
|
||||
const data = await res.json()
|
||||
return data.data || []
|
||||
}
|
||||
|
||||
export async function getQuestions(userToken, limit = 100) {
|
||||
const res = await fetch(
|
||||
`${BASE}/items/questions?filter[status][_eq]=published` +
|
||||
`&fields=id,question_de,question_en,question_se,answer_de,answer_en,answer_se,level,related_words.words_id` +
|
||||
`&limit=${limit}`,
|
||||
{ headers: auth(userToken) }
|
||||
)
|
||||
const data = await res.json()
|
||||
return data.data || []
|
||||
}
|
||||
|
||||
// Holt Progress für einen Nutzer, optional gefiltert auf eine Zielsprache
|
||||
export async function getUserProgress(profileId, userToken, toLangId = null) {
|
||||
let url = `${BASE}/items/user_progress?filter[user][_eq]=${profileId}` +
|
||||
`&fields=id,word,question,card_type,result,language_from,language_to&limit=-1`
|
||||
if (toLangId) url += `&filter[language_to][_eq]=${toLangId}`
|
||||
const res = await fetch(url, { headers: auth(userToken) })
|
||||
const data = await res.json()
|
||||
return data.data || []
|
||||
}
|
||||
|
||||
export async function saveProgress(payload, userToken) {
|
||||
const res = await fetch(`${BASE}/items/user_progress`, {
|
||||
method: 'POST',
|
||||
headers: auth(userToken),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) return null
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function addPointsToPair(pairId, newPoints, userToken) {
|
||||
const res = await fetch(`${BASE}/items/learning_pairs/${pairId}`, {
|
||||
method: 'PATCH',
|
||||
headers: auth(userToken),
|
||||
body: JSON.stringify({ points: newPoints }),
|
||||
})
|
||||
return res.ok
|
||||
}
|
||||
|
||||
export async function addPointsToUser(userId, newTotal, userToken) {
|
||||
const res = await fetch(`${BASE}/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
headers: auth(userToken),
|
||||
body: JSON.stringify({ points_total: newTotal }),
|
||||
})
|
||||
return res.ok
|
||||
}
|
||||
|
||||
// Asset-URL inkl. User-Token, da Directus Hetzner-Storage standardmäßig nicht öffentlich ist
|
||||
export function assetUrl(fileId, userToken) {
|
||||
if (!fileId) return null
|
||||
const base = `${BASE}/assets/${fileId}`
|
||||
return userToken ? `${base}?access_token=${encodeURIComponent(userToken)}` : base
|
||||
}
|
||||
|
||||
// Lädt qa_pairs (db_pairs) auf einem bestimmten Level und liefert pro Pair
|
||||
// das zugehörige Bild + den fertig aufgelösten Statement-Satz zurück.
|
||||
export async function getQAPairsAtLevel(level, userToken, langSuffix = 'de') {
|
||||
const fields = [
|
||||
'id',
|
||||
'picture.picture',
|
||||
'selections',
|
||||
'word_main.db_words_id.id',
|
||||
'word_main.db_words_id.titel_de',
|
||||
'word_main.db_words_id.titel_en',
|
||||
'word_main.db_words_id.titel_se',
|
||||
'pairs.db_pairs_id.id',
|
||||
'pairs.db_pairs_id.level',
|
||||
'pairs.db_pairs_id.status',
|
||||
'pairs.db_pairs_id.statement.db_statement_id.statement_de',
|
||||
'pairs.db_pairs_id.statement.db_statement_id.statement_en',
|
||||
'pairs.db_pairs_id.statement.db_statement_id.statement_se',
|
||||
].join(',')
|
||||
|
||||
const filter = encodeURIComponent(
|
||||
JSON.stringify({ pairs: { db_pairs_id: { level: { _eq: level } } } })
|
||||
)
|
||||
|
||||
const res = await fetch(
|
||||
`${BASE}/items/db_objects?fields=${fields}&filter=${filter}&limit=-1`,
|
||||
{ headers: auth(userToken) }
|
||||
)
|
||||
const data = await res.json()
|
||||
if (!res.ok) return []
|
||||
|
||||
const statementKey = `statement_${langSuffix}`
|
||||
const titelKey = `titel_${langSuffix}`
|
||||
const result = []
|
||||
|
||||
for (const obj of data.data || []) {
|
||||
const wordsById = {}
|
||||
let primaryWord = ''
|
||||
for (const w of obj.word_main || []) {
|
||||
const word = w.db_words_id
|
||||
if (!word) continue
|
||||
const text = word[titelKey] || word.titel_de || ''
|
||||
if (text) wordsById[word.id] = text
|
||||
if (!primaryWord && text) primaryWord = text
|
||||
}
|
||||
|
||||
for (const p of obj.pairs || []) {
|
||||
const pair = p.db_pairs_id
|
||||
if (!pair) continue
|
||||
if (pair.level !== level) continue
|
||||
if (pair.status === 'archived') continue
|
||||
|
||||
const stmtRow = pair.statement?.[0]?.db_statement_id
|
||||
if (!stmtRow) continue
|
||||
const raw = stmtRow[statementKey] || stmtRow.statement_de || ''
|
||||
if (!raw) continue
|
||||
|
||||
const statement = raw.replace(/\{([^}]+)\}/g, (_m, ref) => {
|
||||
const parts = ref.split('.')
|
||||
const wid = parts[parts.length - 1]
|
||||
return wordsById[wid] || primaryWord
|
||||
})
|
||||
|
||||
result.push({
|
||||
pairId: pair.id,
|
||||
level: pair.level,
|
||||
statement,
|
||||
pictureFileId: obj.picture?.picture || null,
|
||||
objectId: obj.id,
|
||||
primaryWord,
|
||||
selections: obj.selections || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function getProfilData(userToken) {
|
||||
const res = await fetch(
|
||||
`${BASE}/users/me?fields=id,username.id,username.username_public,` +
|
||||
`language_native,language_target,points_total,streak_days`,
|
||||
{ headers: auth(userToken) }
|
||||
)
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error('Profil konnte nicht geladen werden.')
|
||||
return data.data
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
29
src/context/AuthContext.jsx
Normal file
29
src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { getMe } from '../api/directus'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [token, setToken] = useState(() => localStorage.getItem('hejyou_token'))
|
||||
const [user, setUser] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) { setLoading(false); return }
|
||||
getMe(token)
|
||||
.then(setUser)
|
||||
.catch(() => { localStorage.removeItem('hejyou_token'); setToken(null) })
|
||||
.finally(() => setLoading(false))
|
||||
}, [token])
|
||||
|
||||
const saveToken = (t) => { localStorage.setItem('hejyou_token', t); setToken(t) }
|
||||
const logout = () => { localStorage.removeItem('hejyou_token'); setToken(null); setUser(null) }
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ token, user, setUser, saveToken, logout, loading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext)
|
||||
22
src/index.css
Normal file
22
src/index.css
Normal file
@@ -0,0 +1,22 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Lora:wght@700&family=Nunito:wght@400;500;600;700;800&display=swap');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Nunito', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #EDE0CE;
|
||||
color: #4A3728;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
10
src/main.jsx
Normal file
10
src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
17
src/pages/Feed.css
Normal file
17
src/pages/Feed.css
Normal file
@@ -0,0 +1,17 @@
|
||||
.feed {
|
||||
height: 100%;
|
||||
background: #EDE0CE;
|
||||
overflow-y: auto;
|
||||
scroll-snap-type: y mandatory;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.feed-slot {
|
||||
scroll-snap-align: start;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
324
src/pages/Feed.jsx
Normal file
324
src/pages/Feed.jsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import './Feed.css'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import {
|
||||
getActiveLearningPair, getWords, getQuestions, getUserProgress,
|
||||
getLanguageOptions, langById,
|
||||
saveProgress, addPointsToPair,
|
||||
getQAPairsAtLevel, assetUrl,
|
||||
} from '../api/directus'
|
||||
import NewWordTextCard from '../components/NewWordTextCard'
|
||||
import NewWordVoiceCard from '../components/NewWordVoiceCard'
|
||||
import LetterOrderCard from '../components/LetterOrderCard'
|
||||
import SentenceFillCard from '../components/SentenceFillCard'
|
||||
import LanguageParentCard from '../components/LanguageParentCard'
|
||||
|
||||
// Ein Wort gilt als gemeistert, wenn es in der aktiven Sprachrichtung
|
||||
// mindestens MASTERY_THRESHOLD korrekt beantwortete Kacheln gesammelt hat.
|
||||
const MASTERY_THRESHOLD = 3
|
||||
// Wie viele verschiedene Wörter gleichzeitig im Feed erscheinen
|
||||
const FEED_WORD_BUDGET = 6
|
||||
|
||||
// Punkteformel: selbes/niedrigeres Level = 1 Punkt, jeder Level höher = +1 Punkt
|
||||
function computePoints(cardLevel, userLevel) {
|
||||
return Math.max(1, 1 + ((cardLevel || 1) - (userLevel || 1)))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const pickN = (arr, n) => shuffle(arr).slice(0, Math.max(0, n))
|
||||
|
||||
function pickWordsToLearn(unmastered, userLevel, budget) {
|
||||
if (unmastered.length === 0) return []
|
||||
const same = unmastered.filter(w => (w.level || 1) === userLevel)
|
||||
const higher = unmastered.filter(w => (w.level || 1) > userLevel && (w.level || 1) <= userLevel + 2)
|
||||
const farHigher = unmastered.filter(w => (w.level || 1) > userLevel + 2)
|
||||
const lower = unmastered.filter(w => (w.level || 1) < userLevel)
|
||||
|
||||
if (same.length > 0) {
|
||||
const sameN = Math.ceil(budget * 0.8)
|
||||
const higherN = budget - sameN
|
||||
let result = [
|
||||
...pickN(same, Math.min(sameN, same.length)),
|
||||
...pickN(higher, Math.min(higherN, higher.length)),
|
||||
]
|
||||
if (result.length < budget) {
|
||||
const used = new Set(result.map(w => w.id))
|
||||
const rest = unmastered.filter(w => !used.has(w.id))
|
||||
result = [...result, ...pickN(rest, budget - result.length)]
|
||||
}
|
||||
return shuffle(result)
|
||||
}
|
||||
if (higher.length > 0) return pickN(higher, budget)
|
||||
if (farHigher.length > 0) return pickN(farHigher, budget)
|
||||
return pickN(lower, budget)
|
||||
}
|
||||
|
||||
function buildWordCards(words, userLevel, fromLang, toLang) {
|
||||
const cards = []
|
||||
words.forEach(w => {
|
||||
const word = w[`title_${toLang.suffix}`]
|
||||
const translation = w[`title_${fromLang.suffix}`]
|
||||
if (!word || !translation) return
|
||||
const level = w.level || 1
|
||||
const points = computePoints(level, userLevel)
|
||||
|
||||
const base = { language: toLang.label, points, word, translation, level }
|
||||
|
||||
cards.push({
|
||||
type: 'text',
|
||||
meta: { wordId: w.id, cardType: 'write', points, level },
|
||||
card: { ...base, baseForm: word, prompt: `Schreib das Wort auf ${toLang.label}` },
|
||||
})
|
||||
cards.push({
|
||||
type: 'voice',
|
||||
meta: { wordId: w.id, cardType: 'speak', points, level },
|
||||
card: { ...base, baseForm: word, prompt: `Sprich das Wort auf ${toLang.label}`, speechLang: toLang.speech },
|
||||
})
|
||||
if (word.length >= 4) {
|
||||
cards.push({
|
||||
type: 'letter',
|
||||
meta: { wordId: w.id, cardType: 'write', points, level },
|
||||
card: { ...base, prompt: 'Tippe die Buchstaben in der richtigen Reihenfolge' },
|
||||
})
|
||||
}
|
||||
})
|
||||
return cards
|
||||
}
|
||||
|
||||
function buildLanguageParentCards(qaPairs, userLevel, toLang, token) {
|
||||
return qaPairs.map(qp => {
|
||||
const points = computePoints(qp.level, userLevel)
|
||||
return {
|
||||
type: 'languparent',
|
||||
meta: { pairId: qp.pairId, cardType: 'speak', points, level: qp.level },
|
||||
card: {
|
||||
language: toLang.label,
|
||||
points,
|
||||
level: qp.level,
|
||||
statement: qp.statement,
|
||||
imageUrl: assetUrl(qp.pictureFileId, token),
|
||||
primaryWord: qp.primaryWord,
|
||||
speechLang: toLang.speech,
|
||||
selections: qp.selections,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildQuestionCardsFor(questions, masteredWordIds, userLevel, toLang) {
|
||||
const cards = []
|
||||
questions.forEach(q => {
|
||||
const wordIds = (q.related_words || []).map(rw => rw.words_id).filter(Boolean)
|
||||
if (wordIds.length === 0) return
|
||||
if (!wordIds.every(id => masteredWordIds.has(id))) return
|
||||
|
||||
const qText = q[`question_${toLang.suffix}`]
|
||||
const answer = q[`answer_${toLang.suffix}`]
|
||||
if (!qText || !answer) return
|
||||
|
||||
const level = q.level || 1
|
||||
const points = computePoints(level, userLevel)
|
||||
|
||||
cards.push({
|
||||
type: 'sentence',
|
||||
meta: { questionId: q.id, wordIds, cardType: 'sentence_fill', points, level },
|
||||
card: {
|
||||
language: toLang.label, points, level,
|
||||
word: answer,
|
||||
translation: qText,
|
||||
prompt: 'Antworte auf die Frage',
|
||||
},
|
||||
})
|
||||
})
|
||||
return cards
|
||||
}
|
||||
|
||||
export default function Feed() {
|
||||
const { user, token } = useAuth()
|
||||
const [cards, setCards] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [ctx, setCtx] = useState(null)
|
||||
const [runningPoints, setRunningPoints] = useState(0)
|
||||
const [empty, setEmpty] = useState(false)
|
||||
|
||||
// Laufende Mastery-Verwaltung für diese Session
|
||||
const correctsRef = useRef({}) // wordId -> Anzahl korrekter Antworten (persistiert + session)
|
||||
const masteredRef = useRef(new Set())
|
||||
const questionsRef = useRef([])
|
||||
const appendedQRef = useRef(new Set()) // bereits angehängte questionIds
|
||||
const userLevelRef = useRef(1)
|
||||
const toLangRef = useRef(null)
|
||||
const pointsQueueRef = useRef(Promise.resolve()) // serialisiert Punkte-Updates
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [pair, langs] = await Promise.all([
|
||||
getActiveLearningPair(user.username, token),
|
||||
getLanguageOptions(),
|
||||
])
|
||||
if (!pair) { setEmpty(true); setLoading(false); return }
|
||||
|
||||
const fromLang = langById(pair.language_from, langs)
|
||||
const toLang = langById(pair.language_to, langs)
|
||||
if (!fromLang || !toLang) { setEmpty(true); setLoading(false); return }
|
||||
|
||||
const userLevel = pair.current_level || 1
|
||||
|
||||
const [words, questions, progress, qaPairs] = await Promise.all([
|
||||
getWords(token),
|
||||
getQuestions(token),
|
||||
getUserProgress(user.username, token, pair.language_to),
|
||||
getQAPairsAtLevel(userLevel, token, toLang.suffix),
|
||||
])
|
||||
|
||||
const correctsByWord = {}
|
||||
progress.forEach(p => {
|
||||
if (p.result === 'correct' && p.word) {
|
||||
correctsByWord[p.word] = (correctsByWord[p.word] || 0) + 1
|
||||
}
|
||||
})
|
||||
const mastered = new Set(
|
||||
Object.entries(correctsByWord)
|
||||
.filter(([, c]) => c >= MASTERY_THRESHOLD)
|
||||
.map(([id]) => id)
|
||||
)
|
||||
|
||||
correctsRef.current = correctsByWord
|
||||
masteredRef.current = mastered
|
||||
questionsRef.current = questions
|
||||
userLevelRef.current = userLevel
|
||||
toLangRef.current = toLang
|
||||
|
||||
const unmastered = words.filter(w => !mastered.has(w.id))
|
||||
const chosen = pickWordsToLearn(unmastered, userLevel, FEED_WORD_BUDGET)
|
||||
|
||||
const wordCards = buildWordCards(chosen, userLevel, fromLang, toLang)
|
||||
const questionCards = buildQuestionCardsFor(questions, mastered, userLevel, toLang)
|
||||
const lpCards = buildLanguageParentCards(qaPairs, userLevel, toLang, token)
|
||||
|
||||
// bereits angehängte Frage-IDs merken, damit wir sie nicht doppelt einstreuen
|
||||
questionCards.forEach(c => appendedQRef.current.add(c.meta.questionId))
|
||||
|
||||
const allCards = [...lpCards, ...wordCards, ...questionCards]
|
||||
setCards(allCards)
|
||||
setEmpty(allCards.length === 0)
|
||||
setCtx({
|
||||
pair,
|
||||
fromLangId: pair.language_from,
|
||||
toLangId: pair.language_to,
|
||||
profileId: user.username,
|
||||
})
|
||||
setRunningPoints(pair.points || 0)
|
||||
} catch (err) {
|
||||
console.error('Feed load error', err)
|
||||
setEmpty(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [user.username, token])
|
||||
|
||||
async function handleComplete(item, result) {
|
||||
if (!ctx) return
|
||||
const earned = result === 'correct' ? (item.meta.points || 1) : 0
|
||||
|
||||
saveProgress({
|
||||
user: ctx.profileId,
|
||||
word: item.meta.wordId || null,
|
||||
question: item.meta.questionId || null,
|
||||
card_type: item.meta.cardType,
|
||||
result,
|
||||
points_earned: earned,
|
||||
language_from: ctx.fromLangId,
|
||||
language_to: ctx.toLangId,
|
||||
}, token).catch(() => {})
|
||||
|
||||
// Punkte serialisiert patchen, damit parallele Karten nicht denselben Basiswert überschreiben
|
||||
if (earned > 0) {
|
||||
setRunningPoints(p => p + earned)
|
||||
pointsQueueRef.current = pointsQueueRef.current.then(async () => {
|
||||
ctx.pair.points = (ctx.pair.points || 0) + earned
|
||||
try { await addPointsToPair(ctx.pair.id, ctx.pair.points, token) } catch {}
|
||||
})
|
||||
}
|
||||
|
||||
// In-Session-Mastery: korrekte Wort-Antwort erhöht Zähler; neu gemasterte
|
||||
// Wörter können Frage-Kacheln freischalten.
|
||||
if (result === 'correct' && item.meta.wordId) {
|
||||
const wid = item.meta.wordId
|
||||
const newCount = (correctsRef.current[wid] || 0) + 1
|
||||
correctsRef.current[wid] = newCount
|
||||
|
||||
if (!masteredRef.current.has(wid) && newCount >= MASTERY_THRESHOLD) {
|
||||
masteredRef.current.add(wid)
|
||||
|
||||
const newQuestions = questionsRef.current.filter(q => {
|
||||
if (appendedQRef.current.has(q.id)) return false
|
||||
const wordIds = (q.related_words || []).map(rw => rw.words_id).filter(Boolean)
|
||||
if (wordIds.length === 0) return false
|
||||
return wordIds.every(id => masteredRef.current.has(id))
|
||||
})
|
||||
|
||||
if (newQuestions.length > 0) {
|
||||
const extra = buildQuestionCardsFor(
|
||||
newQuestions,
|
||||
masteredRef.current,
|
||||
userLevelRef.current,
|
||||
toLangRef.current,
|
||||
)
|
||||
extra.forEach(c => appendedQRef.current.add(c.meta.questionId))
|
||||
setCards(prev => [...prev, ...extra])
|
||||
setEmpty(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="feed">
|
||||
<div style={{ padding: '60px 24px', textAlign: 'center', color: '#9A8F85', fontFamily: 'DM Sans, sans-serif' }}>
|
||||
Lade Karten…
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
return (
|
||||
<div className="feed">
|
||||
<div style={{ padding: '60px 24px', textAlign: 'center', color: '#9A8F85', fontFamily: 'DM Sans, sans-serif' }}>
|
||||
Super! Du hast alle Wörter deines Levels gemeistert. Neue Wörter kommen bald.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="feed">
|
||||
{cards.map((item, i) => {
|
||||
const enrichedCard = { ...item.card, totalPoints: runningPoints }
|
||||
const handler = (r) => handleComplete(item, r)
|
||||
return (
|
||||
<div key={i} className="feed-slot">
|
||||
{item.type === 'text' && <NewWordTextCard card={enrichedCard} onComplete={handler} />}
|
||||
{item.type === 'voice' && <NewWordVoiceCard card={enrichedCard} onComplete={handler} />}
|
||||
{item.type === 'letter' && <LetterOrderCard card={enrichedCard} onComplete={handler} />}
|
||||
{item.type === 'sentence' && <SentenceFillCard card={enrichedCard} onComplete={handler} />}
|
||||
{item.type === 'languparent' && <LanguageParentCard card={enrichedCard} onComplete={handler} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
src/pages/Game.jsx
Normal file
7
src/pages/Game.jsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function Game() {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#EDE0CE' }}>
|
||||
<p style={{ color: '#8C7A65', fontSize: '15px', fontFamily: 'Nunito, sans-serif' }}>Dieser Bereich wird später kommen.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
src/pages/Pro.jsx
Normal file
7
src/pages/Pro.jsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function Pro() {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#EDE0CE' }}>
|
||||
<p style={{ color: '#8C7A65', fontSize: '15px', fontFamily: 'Nunito, sans-serif' }}>Dieser Bereich wird später kommen.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
194
src/pages/Profil.css
Normal file
194
src/pages/Profil.css
Normal file
@@ -0,0 +1,194 @@
|
||||
/* ── Layout ────────────────────────────────────────────────── */
|
||||
.profil {
|
||||
min-height: 100%;
|
||||
background: #EDE0CE;
|
||||
padding: 0 16px 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Header ────────────────────────────────────────────────── */
|
||||
.profil-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 20px 4px 16px;
|
||||
}
|
||||
|
||||
.avatar-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Animated ring */
|
||||
.avatar-ring {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(from 0deg, #EDE0CE, #C4A882, #7A5C3A, #D4B896, #EDE0CE);
|
||||
animation: spin-ring 4s linear infinite;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
@keyframes spin-ring {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.avatar-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: #EDE0CE;
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #7A5C3A, #4A3728);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #F5EFE6;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Online dot */
|
||||
.online-dot {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
background: #7A5C3A;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #EDE0CE;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.online-dot::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #7A5C3A;
|
||||
animation: pulse-ring 1.8s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% { opacity: 0.7; transform: scale(0.8); }
|
||||
100% { opacity: 0; transform: scale(1.8); }
|
||||
}
|
||||
|
||||
/* Level badge on avatar */
|
||||
.avatar-level-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -6px;
|
||||
z-index: 3;
|
||||
filter: drop-shadow(0 1px 3px rgba(74, 55, 40, 0.3));
|
||||
}
|
||||
|
||||
.profil-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.profil-name {
|
||||
font-family: 'Lora', Georgia, serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #4A3728;
|
||||
}
|
||||
|
||||
.profil-handle {
|
||||
font-size: 12px;
|
||||
color: #7A5C3A;
|
||||
}
|
||||
|
||||
/* ── Cards ──────────────────────────────────────────────────── */
|
||||
.progress-card,
|
||||
.skills-card {
|
||||
background: #F5EFE6;
|
||||
border: 0.5px solid #D4B896;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #8C7A65;
|
||||
letter-spacing: 0.07em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ── XP Section ─────────────────────────────────────────────── */
|
||||
.xp-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.lang-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #4A3728;
|
||||
}
|
||||
|
||||
.xp-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #7A5C3A;
|
||||
}
|
||||
|
||||
.xp-bar {
|
||||
background: #D4B896;
|
||||
border-radius: 99px;
|
||||
height: 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xp-fill {
|
||||
height: 100%;
|
||||
border-radius: 99px;
|
||||
background: #7A5C3A;
|
||||
}
|
||||
|
||||
/* Level row */
|
||||
.level-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.level-pill {
|
||||
background: #7A5C3A;
|
||||
color: #EDE0CE;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 3px 10px;
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.level-hint {
|
||||
font-size: 11px;
|
||||
color: #8C7A65;
|
||||
}
|
||||
|
||||
/* ── Radar ───────────────────────────────────────────────────── */
|
||||
.radar-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 0 4px;
|
||||
}
|
||||
195
src/pages/Profil.jsx
Normal file
195
src/pages/Profil.jsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import './Profil.css'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { getProfilData, getActiveLearningPair, getLanguageOptions, langById } from '../api/directus'
|
||||
|
||||
const SKILLS = [
|
||||
{ label: 'Vokabular', value: 0.78 },
|
||||
{ label: 'Grammatik', value: 0.65 },
|
||||
{ label: 'Sprechen', value: 0.60 },
|
||||
{ label: 'Hören', value: 0.52 },
|
||||
{ label: 'Lesen', value: 0.62 },
|
||||
]
|
||||
|
||||
/* ── Radar Chart ─────────────────────────────────────────────── */
|
||||
function RadarChart({ skills, animate }) {
|
||||
const size = 220
|
||||
const cx = 110
|
||||
const cy = 105
|
||||
const r = 70
|
||||
const n = skills.length
|
||||
|
||||
const angle = (i) => (Math.PI * 2 * i) / n - Math.PI / 2
|
||||
|
||||
const point = (i, ratio) => ({
|
||||
x: cx + r * ratio * Math.cos(angle(i)),
|
||||
y: cy + r * ratio * Math.sin(angle(i)),
|
||||
})
|
||||
|
||||
const gridPoly = (ratio) =>
|
||||
skills.map((_, i) => `${point(i, ratio).x},${point(i, ratio).y}`).join(' ')
|
||||
|
||||
const dataPoly = skills
|
||||
.map((s, i) => `${point(i, animate ? s.value : 0).x},${point(i, animate ? s.value : 0).y}`)
|
||||
.join(' ')
|
||||
|
||||
const labelAnchor = (i) => {
|
||||
const x = Math.cos(angle(i))
|
||||
if (x > 0.1) return 'start'
|
||||
if (x < -0.1) return 'end'
|
||||
return 'middle'
|
||||
}
|
||||
|
||||
const labelOffset = (i) => {
|
||||
const y = Math.sin(angle(i))
|
||||
return y > 0.1 ? 10 : y < -0.1 ? -4 : 4
|
||||
}
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} overflow="visible">
|
||||
{[1, 0.8, 0.6, 0.4].map((lvl, idx) => (
|
||||
<polygon key={lvl} points={gridPoly(lvl)}
|
||||
fill="none" stroke="#D4B896" strokeWidth={idx === 0 ? 1 : 0.7} />
|
||||
))}
|
||||
{skills.map((_, i) => {
|
||||
const p = point(i, 1)
|
||||
return <line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y}
|
||||
stroke="#D4B896" strokeWidth="0.7" />
|
||||
})}
|
||||
<polygon points={dataPoly}
|
||||
fill="#C4A882" fillOpacity="0.45"
|
||||
stroke="#7A5C3A" strokeWidth="1.5" strokeLinejoin="round"
|
||||
style={{ transition: animate ? 'all 0.7s ease-out' : 'none' }}
|
||||
/>
|
||||
{skills.map((s, i) => {
|
||||
const p = point(i, animate ? s.value : 0)
|
||||
return <circle key={i} cx={p.x} cy={p.y} r="4" fill="#7A5C3A"
|
||||
style={{ transition: animate ? `all 0.7s ease-out ${i * 0.06}s` : 'none' }} />
|
||||
})}
|
||||
{skills.map((s, i) => {
|
||||
const p = point(i, 1.28)
|
||||
return (
|
||||
<text key={i}
|
||||
x={p.x} y={p.y + labelOffset(i)}
|
||||
textAnchor={labelAnchor(i)}
|
||||
dominantBaseline="middle"
|
||||
fontSize="11" fill="#4A3728" fontFamily="Nunito, sans-serif">
|
||||
{s.label}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Main Component ──────────────────────────────────────────── */
|
||||
export default function Profil() {
|
||||
const { user, token } = useAuth()
|
||||
const [radarReady, setRadarReady] = useState(false)
|
||||
const [profil, setProfil] = useState(null)
|
||||
const [pair, setPair] = useState(null)
|
||||
const [langs, setLangs] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setRadarReady(true), 120)
|
||||
return () => clearTimeout(t)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [p, lp, langs] = await Promise.all([
|
||||
getProfilData(token),
|
||||
getActiveLearningPair(user.username, token),
|
||||
getLanguageOptions(),
|
||||
])
|
||||
setProfil(p)
|
||||
setPair(lp)
|
||||
setLangs(langs)
|
||||
} catch {
|
||||
// Profildaten nicht ladbar – zeige Fallback
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [token, user.username])
|
||||
|
||||
const displayName = profil?.username?.username_public || user?.username || '…'
|
||||
const initials = displayName.slice(0, 2).toUpperCase()
|
||||
const points = pair?.points ?? profil?.points_total ?? 0
|
||||
const level = pair?.current_level ?? 1
|
||||
const xpMax = level * 500
|
||||
const xpPct = Math.min((points / xpMax) * 100, 100)
|
||||
const toLang = pair ? langById(pair.language_to, langs) : null
|
||||
const langLabel = toLang ? `${toLang.flag} ${toLang.label}` : 'Zielsprache'
|
||||
const streak = profil?.streak_days ?? 0
|
||||
|
||||
return (
|
||||
<div className="profil">
|
||||
{/* ── Header ── */}
|
||||
<div className="profil-header">
|
||||
<div className="avatar-wrap">
|
||||
<div className="avatar-ring">
|
||||
<div className="avatar-inner">
|
||||
<div className="avatar">{initials}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="online-dot" />
|
||||
<div className="avatar-level-badge">
|
||||
<svg viewBox="0 0 48 54" width="28" height="32">
|
||||
<defs>
|
||||
<linearGradient id="hexGold2" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#C4A882" />
|
||||
<stop offset="50%" stopColor="#7A5C3A" />
|
||||
<stop offset="100%" stopColor="#4A3728" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon points="24,2 46,14 46,40 24,52 2,40 2,14"
|
||||
fill="url(#hexGold2)" stroke="rgba(255,220,100,0.2)" strokeWidth="1" />
|
||||
<text x="24" y="30" textAnchor="middle" dominantBaseline="middle"
|
||||
fontSize="16" fontWeight="800" fill="#F5EFE6" fontFamily="inherit">
|
||||
{level}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profil-info">
|
||||
<h2 className="profil-name">{displayName}</h2>
|
||||
<p className="profil-handle">@{displayName.toLowerCase()}</p>
|
||||
{streak > 0 && (
|
||||
<p style={{ fontSize: '12px', color: '#C4853A', marginTop: '4px' }}>
|
||||
🔥 {streak} Tag{streak !== 1 ? 'e' : ''} Streak
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Progress Card ── */}
|
||||
<div className="progress-card">
|
||||
<p className="card-title">DEIN FORTSCHRITT</p>
|
||||
|
||||
<div className="xp-row">
|
||||
<span className="lang-label">{langLabel}</span>
|
||||
<span className="xp-value">{points.toLocaleString('de')} / {xpMax.toLocaleString('de')} XP</span>
|
||||
</div>
|
||||
|
||||
<div className="xp-bar">
|
||||
<div className="xp-fill" style={{ width: `${xpPct}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="level-row">
|
||||
<span className="level-pill">Level {level}</span>
|
||||
<span className="level-hint">{(xpMax - points).toLocaleString('de')} XP bis Level {level + 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Skills Card ── */}
|
||||
<div className="skills-card">
|
||||
<p className="card-title">FÄHIGKEITEN</p>
|
||||
<div className="radar-wrap">
|
||||
<RadarChart skills={SKILLS} animate={radarReady} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
src/utils/confetti.js
Normal file
10
src/utils/confetti.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import confetti from 'canvas-confetti'
|
||||
|
||||
export function triggerConfetti() {
|
||||
confetti({
|
||||
particleCount: 120,
|
||||
spread: 70,
|
||||
origin: { y: 0.55 },
|
||||
colors: ['#7A5C3A', '#C4A882', '#EDE0CE', '#D4B896', '#F5EFE6'],
|
||||
})
|
||||
}
|
||||
6
vite.config.js
Normal file
6
vite.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user