feat: Premium-Redesign + Fortschritts-UI
- Zentrales Design-Token-System in index.css (Farben/Spacing/Radien/Schatten/Fonts) - Alle Live-Screens auf Tokens: Karten, BottomNav (aktiver Pill), Feed, Auth - Auth: DM Sans entfernt, Akzent vereinheitlicht (Braun), Tokens gescoped - Profil neu: Tagesziel-Ring, Streak-Heatmap, Wochen-Graph, echter Skills-Radar, Eckdaten - Feed-EP-Badge mit Tagesziel-Ring (ProgressRing-Komponente) - Game/Pro als gestaltetes 'Bald verfügbar' (ComingSoon) - Konsumiert neue API: getStats/setDailyGoal, degradiert sauber bei 404 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,9 +15,8 @@ function AppContent() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#F5F0E8' }}>
|
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)' }}>
|
||||||
<div style={{ width: '32px', height: '32px', border: '2px solid #E2DAD0', borderTopColor: '#5C7A5E', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />
|
<div className="app-spinner" />
|
||||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -29,8 +28,8 @@ function AppContent() {
|
|||||||
const PageComponent = PAGES[page] || Feed
|
const PageComponent = PAGES[page] || Feed
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', background: '#F5F0E8', overflow: 'hidden' }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', background: 'var(--bg)', overflow: 'hidden' }}>
|
||||||
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
<div key={page} className="page-enter" style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||||
<PageComponent />
|
<PageComponent />
|
||||||
</div>
|
</div>
|
||||||
<BottomNav active={page} onNavigate={setPage} />
|
<BottomNav active={page} onNavigate={setPage} />
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: #C4A882;
|
background: var(--surface);
|
||||||
border-top: 1px solid #D4B896;
|
border-top: 1px solid var(--border-soft);
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding: 6px 8px;
|
||||||
|
padding-bottom: calc(6px + env(safe-area-inset-bottom));
|
||||||
|
box-shadow: 0 -2px 16px rgba(60, 40, 20, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
@@ -11,34 +13,45 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 10px 0;
|
padding: 8px 0 6px;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: rgba(74, 55, 40, 0.45);
|
color: var(--text-soft);
|
||||||
transition: color 0.2s;
|
transition: color var(--dur-fast) var(--ease);
|
||||||
gap: 3px;
|
gap: 4px;
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active {
|
|
||||||
color: #7A5C3A;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
width: 24px;
|
width: 44px;
|
||||||
height: 24px;
|
height: 30px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
transition: background var(--dur) var(--ease), transform var(--dur) var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon svg {
|
.nav-icon svg {
|
||||||
width: 24px;
|
width: 23px;
|
||||||
height: 24px;
|
height: 23px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active .nav-icon {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:active .nav-icon {
|
||||||
|
transform: scale(0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-label {
|
.nav-label {
|
||||||
font-family: 'Nunito', sans-serif;
|
font-family: var(--font-ui);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,25 @@ export async function getUserProgress(userToken) {
|
|||||||
return { total_ep: me.total_ep || 0, streak_days: me.streak_days || 0, level: me.level || 0 }
|
return { total_ep: me.total_ep || 0, streak_days: me.streak_days || 0, level: me.level || 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detaillierte Statistik fürs Profil: { daily[], today, totals, skills }.
|
||||||
|
export async function getStats(userToken) {
|
||||||
|
const res = await fetch(`${BASE}/auth/stats`, { headers: auth(userToken) })
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Statistik konnte nicht geladen werden.')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tagesziel (EP/Tag) setzen. Gibt { daily_goal_ep } zurück.
|
||||||
|
export async function setDailyGoal(dailyGoalEp, userToken) {
|
||||||
|
const res = await fetch(`${BASE}/auth/goal`, {
|
||||||
|
method: 'PUT', headers: auth(userToken),
|
||||||
|
body: JSON.stringify({ daily_goal_ep: dailyGoalEp }),
|
||||||
|
})
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Tagesziel konnte nicht gespeichert werden.')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
// ── Stubs (content-Endpunkte kommen später) ───────────────────────────────────
|
// ── Stubs (content-Endpunkte kommen später) ───────────────────────────────────
|
||||||
|
|
||||||
export async function getActiveLearningPair() { return null }
|
export async function getActiveLearningPair() { return null }
|
||||||
|
|||||||
82
src/components/ComingSoon.css
Normal file
82
src/components/ComingSoon.css
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
.coming-soon {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: var(--sp-5);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 340px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: var(--sp-6) var(--sp-5);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-icon {
|
||||||
|
width: 68px;
|
||||||
|
height: 68px;
|
||||||
|
margin: 0 auto var(--sp-4);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.cs-icon svg { width: 32px; height: 32px; }
|
||||||
|
|
||||||
|
.cs-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--gold);
|
||||||
|
background: var(--gold-soft);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
padding: 4px 12px;
|
||||||
|
margin-bottom: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-strong);
|
||||||
|
margin-bottom: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-teaser {
|
||||||
|
list-style: none;
|
||||||
|
text-align: left;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.cs-teaser li {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
padding-left: 24px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.cs-teaser li::before {
|
||||||
|
content: '✦';
|
||||||
|
position: absolute;
|
||||||
|
left: 4px;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
20
src/components/ComingSoon.jsx
Normal file
20
src/components/ComingSoon.jsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import './ComingSoon.css'
|
||||||
|
|
||||||
|
// Gestaltetes "Bald verfügbar" für noch nicht gebaute Bereiche.
|
||||||
|
export default function ComingSoon({ icon, title, subtitle, teaser }) {
|
||||||
|
return (
|
||||||
|
<div className="coming-soon page-enter">
|
||||||
|
<div className="cs-card">
|
||||||
|
<div className="cs-icon">{icon}</div>
|
||||||
|
<span className="cs-badge">Bald verfügbar</span>
|
||||||
|
<h2 className="cs-title">{title}</h2>
|
||||||
|
<p className="cs-subtitle">{subtitle}</p>
|
||||||
|
{teaser && (
|
||||||
|
<ul className="cs-teaser">
|
||||||
|
{teaser.map((t, i) => <li key={i}>{t}</li>)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,13 +6,10 @@
|
|||||||
.pair-card {
|
.pair-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 380px;
|
max-width: 380px;
|
||||||
border-radius: 22px;
|
border-radius: var(--r-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #FDFAF6;
|
background: var(--surface);
|
||||||
box-shadow:
|
box-shadow: var(--shadow-card);
|
||||||
0 1px 2px rgba(60, 40, 20, 0.06),
|
|
||||||
0 4px 16px rgba(60, 40, 20, 0.09),
|
|
||||||
0 12px 40px rgba(60, 40, 20, 0.06);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header (sits below image) ── */
|
/* ── Header (sits below image) ── */
|
||||||
@@ -27,14 +24,14 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.09em;
|
letter-spacing: 0.09em;
|
||||||
color: #6B6556;
|
color: #6B6556;
|
||||||
font-family: 'DM Sans', 'Nunito', sans-serif;
|
font-family: var(--font-ui);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.pair-points-pill {
|
.pair-points-pill {
|
||||||
color: #C4A85A;
|
color: var(--gold);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-family: 'DM Sans', 'Nunito', sans-serif;
|
font-family: var(--font-ui);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -54,7 +51,7 @@
|
|||||||
letter-spacing: 0.10em;
|
letter-spacing: 0.10em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #A89F8C;
|
color: #A89F8C;
|
||||||
font-family: 'DM Sans', 'Nunito', sans-serif;
|
font-family: var(--font-ui);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +151,7 @@
|
|||||||
}
|
}
|
||||||
.pair-chip-highlight-target {
|
.pair-chip-highlight-target {
|
||||||
display: block;
|
display: block;
|
||||||
font-family: 'Lora', Georgia, serif;
|
font-family: var(--font-display);
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #3A2515;
|
color: #3A2515;
|
||||||
@@ -163,7 +160,7 @@
|
|||||||
}
|
}
|
||||||
.pair-chip-highlight-native {
|
.pair-chip-highlight-native {
|
||||||
display: block;
|
display: block;
|
||||||
font-family: 'Nunito', sans-serif;
|
font-family: var(--font-ui);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #9A7D60;
|
color: #9A7D60;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -198,7 +195,7 @@
|
|||||||
background: #F0EDE3;
|
background: #F0EDE3;
|
||||||
border-radius: 30px;
|
border-radius: 30px;
|
||||||
border: 0.5px solid #D8D3C5;
|
border: 0.5px solid #D8D3C5;
|
||||||
font-family: 'Lora', Georgia, serif;
|
font-family: var(--font-display);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: #7A5C2E;
|
color: #7A5C2E;
|
||||||
@@ -252,7 +249,7 @@
|
|||||||
|
|
||||||
/* Sentence text (text-type cards) */
|
/* Sentence text (text-type cards) */
|
||||||
.pair-sentence {
|
.pair-sentence {
|
||||||
font-family: 'Lora', Georgia, serif;
|
font-family: var(--font-display);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
color: #3A2515;
|
color: #3A2515;
|
||||||
@@ -261,7 +258,7 @@
|
|||||||
|
|
||||||
/* Question text */
|
/* Question text */
|
||||||
.pair-question {
|
.pair-question {
|
||||||
font-family: 'Lora', Georgia, serif;
|
font-family: var(--font-display);
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
@@ -271,7 +268,7 @@
|
|||||||
|
|
||||||
/* Hint — native language translation */
|
/* Hint — native language translation */
|
||||||
.pair-hint {
|
.pair-hint {
|
||||||
font-family: 'Nunito', sans-serif;
|
font-family: var(--font-ui);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #A08868;
|
color: #A08868;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -288,7 +285,7 @@
|
|||||||
padding: 14px 10px;
|
padding: 14px 10px;
|
||||||
border-radius: 13px;
|
border-radius: 13px;
|
||||||
border: none;
|
border: none;
|
||||||
font-family: 'Nunito', sans-serif;
|
font-family: var(--font-ui);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -300,7 +297,7 @@
|
|||||||
.pair-btn:disabled { opacity: 0.75; cursor: default; }
|
.pair-btn:disabled { opacity: 0.75; cursor: default; }
|
||||||
|
|
||||||
.pair-btn-primary {
|
.pair-btn-primary {
|
||||||
background: #5C3D22;
|
background: var(--accent-strong);
|
||||||
color: #F5EDE0;
|
color: #F5EDE0;
|
||||||
}
|
}
|
||||||
.pair-btn-locked {
|
.pair-btn-locked {
|
||||||
@@ -309,15 +306,15 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.pair-btn-yes {
|
.pair-btn-yes {
|
||||||
background: #3D7055;
|
background: var(--success);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.pair-btn-no {
|
.pair-btn-no {
|
||||||
background: #A84040;
|
background: var(--danger);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.pair-btn-correct { background: #3D7055; color: #fff; }
|
.pair-btn-correct { background: var(--success); color: #fff; }
|
||||||
.pair-btn-wrong { background: #A84040; color: #fff; }
|
.pair-btn-wrong { background: var(--danger); color: #fff; }
|
||||||
|
|
||||||
/* ── Word option buttons ── */
|
/* ── Word option buttons ── */
|
||||||
.pair-options {
|
.pair-options {
|
||||||
@@ -332,7 +329,7 @@
|
|||||||
border: 1.5px solid #DDD0BF;
|
border: 1.5px solid #DDD0BF;
|
||||||
background: #FDFAF6;
|
background: #FDFAF6;
|
||||||
color: #3A2515;
|
color: #3A2515;
|
||||||
font-family: 'Nunito', sans-serif;
|
font-family: var(--font-ui);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -344,17 +341,17 @@
|
|||||||
}
|
}
|
||||||
.pair-option-btn:active:not(:disabled) { transform: scale(0.95); }
|
.pair-option-btn:active:not(:disabled) { transform: scale(0.95); }
|
||||||
.pair-option-btn.selected { background: #FFF0DC; border-color: #C4A85A; color: #5C3D22; }
|
.pair-option-btn.selected { background: #FFF0DC; border-color: #C4A85A; color: #5C3D22; }
|
||||||
.pair-option-btn.correct { background: #3D7055; color: #fff; border-color: #3D7055; }
|
.pair-option-btn.correct { background: var(--success); color: #fff; border-color: var(--success); }
|
||||||
.pair-option-btn.wrong { background: #A84040; color: #fff; border-color: #A84040; }
|
.pair-option-btn.wrong { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||||
.pair-option-btn:disabled { cursor: default; }
|
.pair-option-btn:disabled { cursor: default; }
|
||||||
|
|
||||||
/* ── Feedback ── */
|
/* ── Feedback ── */
|
||||||
.pair-feedback {
|
.pair-feedback {
|
||||||
font-family: 'Nunito', sans-serif;
|
font-family: var(--font-ui);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 12px 0 2px;
|
padding: 12px 0 2px;
|
||||||
color: #3A2515;
|
color: #3A2515;
|
||||||
}
|
}
|
||||||
.pair-feedback.correct { color: #3D7055; }
|
.pair-feedback.correct { color: var(--success); }
|
||||||
.pair-feedback.wrong { color: #A84040; }
|
.pair-feedback.wrong { color: var(--danger); }
|
||||||
|
|||||||
35
src/components/ProgressRing.jsx
Normal file
35
src/components/ProgressRing.jsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Ruhiger SVG-Donut für Tagesziel-Fortschritt. Wiederverwendet in Feed (klein) und Profil (groß).
|
||||||
|
export default function ProgressRing({
|
||||||
|
value = 0, // 0..1
|
||||||
|
size = 26,
|
||||||
|
stroke = 4,
|
||||||
|
track = 'var(--surface-2)',
|
||||||
|
color = 'var(--gold)',
|
||||||
|
children,
|
||||||
|
}) {
|
||||||
|
const r = (size - stroke) / 2
|
||||||
|
const c = 2 * Math.PI * r
|
||||||
|
const pct = Math.max(0, Math.min(1, value))
|
||||||
|
const dash = c * pct
|
||||||
|
return (
|
||||||
|
<span style={{ position: 'relative', display: 'inline-flex', width: size, height: size }}>
|
||||||
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ transform: 'rotate(-90deg)' }}>
|
||||||
|
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={track} strokeWidth={stroke} />
|
||||||
|
<circle
|
||||||
|
cx={size / 2} cy={size / 2} r={r} fill="none"
|
||||||
|
stroke={color} strokeWidth={stroke} strokeLinecap="round"
|
||||||
|
strokeDasharray={`${dash} ${c}`}
|
||||||
|
style={{ transition: 'stroke-dasharray 0.6s var(--ease)' }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{children != null && (
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', inset: 0, display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,14 +3,15 @@ import LoginForm from './LoginForm'
|
|||||||
import RegisterStep1 from './RegisterStep1'
|
import RegisterStep1 from './RegisterStep1'
|
||||||
import RegisterStep2 from './RegisterStep2'
|
import RegisterStep2 from './RegisterStep2'
|
||||||
|
|
||||||
|
// Legacy-Variablennamen der Auth-Formulare auf die globalen Design-Tokens mappen.
|
||||||
|
// Auf .auth-root gescoped, damit nichts ins :root der App leakt.
|
||||||
const css = `
|
const css = `
|
||||||
:root {
|
.auth-root {
|
||||||
--bg: #F5F0E8; --surface: #FFFCF7; --border: #E2DAD0;
|
--muted: var(--text-muted);
|
||||||
--text: #2C2520; --muted: #9A8F85; --accent: #5C7A5E;
|
--accent-lt: var(--accent-soft);
|
||||||
--accent-lt: #EAF0EA; --danger: #C0544A; --danger-lt: #FBF0EF;
|
--danger-lt: var(--danger-soft);
|
||||||
--radius: 14px;
|
--radius: var(--r-sm);
|
||||||
}
|
}
|
||||||
@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; } }
|
@keyframes fadeUp { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:none; } }
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ export default function AuthScreen() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{css}</style>
|
<style>{css}</style>
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '24px', background: 'var(--bg)' }}>
|
<div className="auth-root" 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' }}>
|
<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 */}
|
{/* Brand */}
|
||||||
@@ -42,7 +43,7 @@ export default function AuthScreen() {
|
|||||||
<circle cx="12" cy="12" r="10"/><path d="M8 12q2-5 4-4t4 4-4 4-4-4"/>
|
<circle cx="12" cy="12" r="10"/><path d="M8 12q2-5 4-4t4 4-4 4-4-4"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 style={{ fontFamily: 'Lora, serif', fontSize: '22px', fontWeight: 500, letterSpacing: '-0.3px', color: 'var(--text)' }}>HejYou</h1>
|
<h1 style={{ fontFamily: 'var(--font-display)', 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>
|
<p style={{ fontSize: '13px', color: 'var(--muted)', marginTop: '4px' }}>Sprachen lernen wie ein Kind</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ export default function AuthScreen() {
|
|||||||
<button key={m} onClick={() => handleModeChange(m)} style={{
|
<button key={m} onClick={() => handleModeChange(m)} style={{
|
||||||
flex: 1, padding: '8px', border: 'none', borderRadius: '8px',
|
flex: 1, padding: '8px', border: 'none', borderRadius: '8px',
|
||||||
background: mode === m ? 'var(--surface)' : 'transparent',
|
background: mode === m ? 'var(--surface)' : 'transparent',
|
||||||
fontFamily: 'DM Sans, sans-serif', fontSize: '13px', fontWeight: 500,
|
fontFamily: 'var(--font-ui)', fontSize: '13px', fontWeight: 700,
|
||||||
color: mode === m ? 'var(--text)' : 'var(--muted)', cursor: 'pointer',
|
color: mode === m ? 'var(--text)' : 'var(--muted)', cursor: 'pointer',
|
||||||
boxShadow: mode === m ? '0 1px 4px rgba(44,37,32,0.08)' : 'none',
|
boxShadow: mode === m ? '0 1px 4px rgba(44,37,32,0.08)' : 'none',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
@@ -77,7 +78,7 @@ export default function AuthScreen() {
|
|||||||
<polyline points="20 6 9 17 4 12"/>
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<strong style={{ fontFamily: 'Lora, serif', fontSize: '18px', display: 'block', marginBottom: '8px' }}>
|
<strong style={{ fontFamily: 'var(--font-display)', fontSize: '18px', display: 'block', marginBottom: '8px' }}>
|
||||||
Willkommen, {successName}!
|
Willkommen, {successName}!
|
||||||
</strong>
|
</strong>
|
||||||
<p style={{ fontSize: '14px', color: 'var(--muted)' }}>Dein Abenteuer beginnt jetzt.</p>
|
<p style={{ fontSize: '14px', color: 'var(--muted)' }}>Dein Abenteuer beginnt jetzt.</p>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
font-family: 'DM Sans', sans-serif;
|
font-family: var(--font-ui);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
}
|
}
|
||||||
.input:focus {
|
.input:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px rgba(92,122,94,0.12);
|
box-shadow: 0 0 0 3px rgba(122, 92, 58, 0.14);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,12 +46,12 @@
|
|||||||
width: 100%; padding: 13px; margin-top: 8px;
|
width: 100%; padding: 13px; margin-top: 8px;
|
||||||
background: var(--accent); color: #fff;
|
background: var(--accent); color: #fff;
|
||||||
border: none; border-radius: var(--radius);
|
border: none; border-radius: var(--radius);
|
||||||
font-family: 'DM Sans', sans-serif;
|
font-family: var(--font-ui);
|
||||||
font-size: 15px; font-weight: 500; cursor: pointer;
|
font-size: 15px; font-weight: 500; cursor: pointer;
|
||||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
transition: background 0.2s, transform 0.1s;
|
transition: background 0.2s, transform 0.1s;
|
||||||
}
|
}
|
||||||
.btn:hover:not(:disabled) { background: #4a6650; }
|
.btn:hover:not(:disabled) { background: var(--accent-strong); }
|
||||||
.btn:active:not(:disabled) { transform: scale(0.98); }
|
.btn:active:not(:disabled) { transform: scale(0.98); }
|
||||||
.btn:disabled { background: var(--border); color: var(--muted); cursor: not-allowed; }
|
.btn:disabled { background: var(--border); color: var(--muted); cursor: not-allowed; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,65 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Lora:wght@700&family=Nunito:wght@400;500;600;700;800&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,500;0,600;0,700;1,500&family=Nunito:wght@400;500;600;700;800&display=swap');
|
||||||
|
|
||||||
|
/* ── Design Tokens ──────────────────────────────────────────────
|
||||||
|
Single source of truth. Warmes, ruhiges Konzept – nur diszipliniert.
|
||||||
|
Bestehende Palette normalisiert (ein Grün, ein Rot, klare Skalen). */
|
||||||
|
:root {
|
||||||
|
/* Flächen & Hintergrund */
|
||||||
|
--bg: #EDE0CE; /* warmes Creme – App-Hintergrund */
|
||||||
|
--bg-2: #F2E8DA; /* leicht heller, für Sektionen */
|
||||||
|
--surface: #FBF7F0; /* Karten / Panels */
|
||||||
|
--surface-2: #F4EEE3; /* eingelassene Flächen, Tracks */
|
||||||
|
--surface-sunk: #ECE3D4; /* Vertiefungen / Heatmap-Leerzelle */
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text: #4A3728; /* primärer warmer Braunton */
|
||||||
|
--text-strong: #3A2515; /* Überschriften / Betonung */
|
||||||
|
--text-muted: #8C7A65; /* sekundär */
|
||||||
|
--text-soft: #A89F8C; /* tertiär / Hints */
|
||||||
|
|
||||||
|
/* Akzente */
|
||||||
|
--accent: #7A5C3A; /* Aktion / aktiv */
|
||||||
|
--accent-strong: #5C3D22;
|
||||||
|
--accent-soft: #EFE6D6; /* heller Akzent-Hintergrund (Pills) */
|
||||||
|
--gold: #C4A85A; /* EP / Belohnung */
|
||||||
|
--gold-soft: #F0E4C4;
|
||||||
|
--success: #3D7055; /* genau ein Grün */
|
||||||
|
--success-soft: #E4EDE5;
|
||||||
|
--danger: #C0544A; /* genau ein warmes Rot */
|
||||||
|
--danger-soft: #FBF0EF;
|
||||||
|
|
||||||
|
/* Linien */
|
||||||
|
--border: #DDD0BF;
|
||||||
|
--border-soft: #E7DDCD;
|
||||||
|
|
||||||
|
/* Spacing-Skala */
|
||||||
|
--sp-1: 4px;
|
||||||
|
--sp-2: 8px;
|
||||||
|
--sp-3: 12px;
|
||||||
|
--sp-4: 16px;
|
||||||
|
--sp-5: 24px;
|
||||||
|
--sp-6: 32px;
|
||||||
|
|
||||||
|
/* Radien */
|
||||||
|
--r-sm: 12px;
|
||||||
|
--r-md: 16px;
|
||||||
|
--r-lg: 22px;
|
||||||
|
--r-pill: 999px;
|
||||||
|
|
||||||
|
/* Schatten – warm getönt, nie hartes Schwarz */
|
||||||
|
--shadow-soft: 0 1px 2px rgba(60, 40, 20, 0.05), 0 2px 8px rgba(60, 40, 20, 0.06);
|
||||||
|
--shadow-card: 0 1px 2px rgba(60, 40, 20, 0.06), 0 4px 16px rgba(60, 40, 20, 0.09), 0 12px 40px rgba(60, 40, 20, 0.06);
|
||||||
|
--shadow-pop: 0 6px 24px rgba(60, 40, 20, 0.16);
|
||||||
|
|
||||||
|
/* Typografie */
|
||||||
|
--font-ui: 'Nunito', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
--font-display: 'Lora', Georgia, serif;
|
||||||
|
|
||||||
|
/* Bewegung */
|
||||||
|
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||||
|
--dur-fast: 0.15s;
|
||||||
|
--dur: 0.28s;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -7,12 +68,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Nunito', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: var(--font-ui);
|
||||||
background: #EDE0CE;
|
background: var(--bg);
|
||||||
color: #4A3728;
|
color: var(--text);
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
@@ -20,3 +83,30 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Gemeinsamer, ruhiger Loader/Spinner */
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.app-spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 2px solid var(--border-soft);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dezenter Seitenwechsel */
|
||||||
|
@keyframes pageEnter {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.page-enter { animation: pageEnter var(--dur) var(--ease); }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.001ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.001ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.feed {
|
.feed {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #EDE0CE;
|
background: var(--bg);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scroll-snap-type: y mandatory;
|
scroll-snap-type: y mandatory;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
@@ -12,8 +12,55 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 16px 20px;
|
padding: var(--sp-4) var(--sp-5);
|
||||||
padding-bottom: 24px;
|
padding-bottom: var(--sp-5);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feed-empty {
|
||||||
|
padding: 60px var(--sp-5);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* EP-Badge + Tagesziel-Ring */
|
||||||
|
.ep-badge {
|
||||||
|
position: sticky;
|
||||||
|
top: 10px;
|
||||||
|
z-index: 5;
|
||||||
|
align-self: center;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
padding: 5px 14px 5px 7px;
|
||||||
|
margin: 8px auto 10px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-strong);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
animation: epPop var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes epPop {
|
||||||
|
from { opacity: 0; transform: translateY(-6px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ep-badge .ep-ring { display: block; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.ep-badge .ep-value {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.ep-badge .ep-value small {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import './Feed.css'
|
import './Feed.css'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { getFeedPairs, saveProgress, getUserProgress } from '../api/directus'
|
import { getFeedPairs, saveProgress, getUserProgress, getStats } from '../api/directus'
|
||||||
|
import ProgressRing from '../components/ProgressRing'
|
||||||
import PairSentenceCard from '../components/PairSentenceCard'
|
import PairSentenceCard from '../components/PairSentenceCard'
|
||||||
import PairYesNoCard from '../components/PairYesNoCard'
|
import PairYesNoCard from '../components/PairYesNoCard'
|
||||||
import PairWordCard from '../components/PairWordCard'
|
import PairWordCard from '../components/PairWordCard'
|
||||||
@@ -24,6 +25,7 @@ export default function Feed() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [empty, setEmpty] = useState(false)
|
const [empty, setEmpty] = useState(false)
|
||||||
const [totalEp, setTotalEp] = useState(null)
|
const [totalEp, setTotalEp] = useState(null)
|
||||||
|
const [daily, setDaily] = useState(null) // { ep, daily_goal_ep } – wenn /auth/stats verfügbar
|
||||||
|
|
||||||
// Target language from user profile, fall back to 'de'
|
// Target language from user profile, fall back to 'de'
|
||||||
const lang = user?.language_target_short || 'de'
|
const lang = user?.language_target_short || 'de'
|
||||||
@@ -43,37 +45,42 @@ export default function Feed() {
|
|||||||
getUserProgress(token)
|
getUserProgress(token)
|
||||||
.then(p => setTotalEp(p.total_ep))
|
.then(p => setTotalEp(p.total_ep))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
// Tagesziel-Fortschritt – degradiert lautlos, falls /auth/stats noch nicht deployed ist
|
||||||
|
getStats(token)
|
||||||
|
.then(s => { if (s?.today) setDaily(s.today) })
|
||||||
|
.catch(() => {})
|
||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
function handleComplete(item, result) {
|
function handleComplete(item, result) {
|
||||||
setDone(prev => new Set([...prev, item.meta.pairId]))
|
setDone(prev => new Set([...prev, item.meta.pairId]))
|
||||||
const correct = result === 'correct'
|
const correct = result === 'correct'
|
||||||
|
const earned = correct ? item.meta.points : 0
|
||||||
saveProgress({
|
saveProgress({
|
||||||
pairId: item.meta.pairId,
|
pairId: item.meta.pairId,
|
||||||
correct,
|
correct,
|
||||||
points: correct ? item.meta.points : 0,
|
points: earned,
|
||||||
userToken: token,
|
userToken: token,
|
||||||
})
|
})
|
||||||
.then(res => { if (res?.total_ep != null) setTotalEp(res.total_ep) })
|
.then(res => { if (res?.total_ep != null) setTotalEp(res.total_ep) })
|
||||||
.catch(err => console.error('saveProgress error', err))
|
.catch(err => console.error('saveProgress error', err))
|
||||||
|
// Tagesziel optimistisch hochzählen
|
||||||
|
if (earned > 0) setDaily(d => d ? { ...d, ep: (d.ep || 0) + earned } : d)
|
||||||
}
|
}
|
||||||
|
|
||||||
const visible = cards.filter(c => !done.has(c.meta.pairId))
|
const visible = cards.filter(c => !done.has(c.meta.pairId))
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="feed">
|
<div className="feed page-enter">
|
||||||
<div style={{ padding: '60px 24px', textAlign: 'center', color: '#9A8F85', fontFamily: 'DM Sans, sans-serif' }}>
|
<div className="feed-empty">Lade Karten…</div>
|
||||||
Lade Karten…
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty || visible.length === 0) {
|
if (empty || visible.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="feed">
|
<div className="feed page-enter">
|
||||||
<div style={{ padding: '60px 24px', textAlign: 'center', color: '#9A8F85', fontFamily: 'DM Sans, sans-serif' }}>
|
<div className="feed-empty">
|
||||||
{cards.length === 0
|
{cards.length === 0
|
||||||
? 'Noch keine Inhalte verfügbar.'
|
? 'Noch keine Inhalte verfügbar.'
|
||||||
: 'Super! Alle Karten abgeschlossen. 🎉'}
|
: 'Super! Alle Karten abgeschlossen. 🎉'}
|
||||||
@@ -82,16 +89,22 @@ export default function Feed() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goalPct = daily && daily.daily_goal_ep ? (daily.ep || 0) / daily.daily_goal_ep : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="feed">
|
<div className="feed page-enter">
|
||||||
{totalEp != null && (
|
{totalEp != null && (
|
||||||
<div style={{
|
<div className="ep-badge">
|
||||||
position: 'sticky', top: 8, zIndex: 5, alignSelf: 'center',
|
<ProgressRing
|
||||||
background: '#fff', border: '1px solid #EFE7DE', borderRadius: 999,
|
value={daily ? goalPct : 1}
|
||||||
padding: '6px 14px', margin: '4px auto 8px', fontFamily: 'DM Sans, sans-serif',
|
size={26} stroke={4}
|
||||||
fontWeight: 600, color: '#7A6A58', boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
|
color={goalPct >= 1 ? 'var(--success)' : 'var(--gold)'}
|
||||||
}}>
|
>
|
||||||
⭐ {totalEp} EP
|
<span style={{ fontSize: 11 }}>{goalPct >= 1 ? '✓' : '⭐'}</span>
|
||||||
|
</ProgressRing>
|
||||||
|
<span className="ep-value">
|
||||||
|
{totalEp}<small>EP{daily ? ` · ${daily.ep || 0}/${daily.daily_goal_ep} heute` : ''}</small>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{visible.map((item) => {
|
{visible.map((item) => {
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
|
import ComingSoon from '../components/ComingSoon'
|
||||||
|
|
||||||
|
const IconGame = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="2" y="6" width="20" height="12" rx="4" />
|
||||||
|
<line x1="7" y1="12" x2="11" y2="12" /><line x1="9" y1="10" x2="9" y2="14" />
|
||||||
|
<circle cx="16" cy="11" r="0.6" fill="currentColor" /><circle cx="18" cy="13" r="0.6" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
export default function Game() {
|
export default function Game() {
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#EDE0CE' }}>
|
<ComingSoon
|
||||||
<p style={{ color: '#8C7A65', fontSize: '15px', fontFamily: 'Nunito, sans-serif' }}>Dieser Bereich wird später kommen.</p>
|
icon={<IconGame />}
|
||||||
</div>
|
title="Spielend lernen"
|
||||||
|
subtitle="Kurze, verspielte Übungsrunden, die dein Wissen auf die Probe stellen – entspannt und mit Belohnung."
|
||||||
|
teaser={['Tägliche Mini-Challenges', 'Wiederholung schwacher Wörter', 'Extra-EP für Bestzeiten']}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
|
import ComingSoon from '../components/ComingSoon'
|
||||||
|
|
||||||
|
const IconPro = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 17l4-9 5 6 5-9 4 12z" /><path d="M3 20h18" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
export default function Pro() {
|
export default function Pro() {
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#EDE0CE' }}>
|
<ComingSoon
|
||||||
<p style={{ color: '#8C7A65', fontSize: '15px', fontFamily: 'Nunito, sans-serif' }}>Dieser Bereich wird später kommen.</p>
|
icon={<IconPro />}
|
||||||
</div>
|
title="Snakkimo Pro"
|
||||||
|
subtitle="Lerne ohne Grenzen – mit unbegrenzten Karten, Offline-Modus und persönlichen Lernpfaden."
|
||||||
|
teaser={['Unbegrenzte tägliche Karten', 'Persönliche Lernpfade', 'Detaillierte Statistiken']}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,194 +1,195 @@
|
|||||||
/* ── Layout ────────────────────────────────────────────────── */
|
/* ── Layout ────────────────────────────────────────────────── */
|
||||||
.profil {
|
.profil {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
background: #EDE0CE;
|
background: var(--bg);
|
||||||
padding: 0 16px 32px;
|
padding: 0 var(--sp-4) var(--sp-6);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profil-logout {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 4px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--text-soft);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color var(--dur-fast), background var(--dur-fast);
|
||||||
|
}
|
||||||
|
.profil-logout:hover { color: var(--danger); background: var(--danger-soft); }
|
||||||
|
|
||||||
/* ── Header ────────────────────────────────────────────────── */
|
/* ── Header ────────────────────────────────────────────────── */
|
||||||
.profil-header {
|
.profil-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: var(--sp-3);
|
||||||
padding: 20px 4px 16px;
|
padding: 20px 4px var(--sp-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-wrap {
|
.avatar-wrap { position: relative; flex-shrink: 0; }
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animated ring */
|
|
||||||
.avatar-ring {
|
.avatar-ring {
|
||||||
width: 64px;
|
width: 64px; height: 64px;
|
||||||
height: 64px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: conic-gradient(from 0deg, #EDE0CE, #C4A882, #7A5C3A, #D4B896, #EDE0CE);
|
background: conic-gradient(from 0deg, var(--bg), var(--gold), var(--accent), var(--border), var(--bg));
|
||||||
animation: spin-ring 4s linear infinite;
|
animation: spin-ring 6s linear infinite;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
}
|
}
|
||||||
|
@keyframes spin-ring { to { transform: rotate(360deg); } }
|
||||||
@keyframes spin-ring {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-inner {
|
.avatar-inner {
|
||||||
width: 100%;
|
width: 100%; height: 100%;
|
||||||
height: 100%;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #EDE0CE;
|
background: var(--bg);
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
display: flex;
|
display: flex; align-items: center; justify-content: center;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 100%;
|
width: 100%; height: 100%;
|
||||||
height: 100%;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, #7A5C3A, #4A3728);
|
background: linear-gradient(135deg, var(--accent), var(--text));
|
||||||
display: flex;
|
display: flex; align-items: center; justify-content: center;
|
||||||
align-items: center;
|
font-size: 18px; font-weight: 800; color: #F5EFE6; letter-spacing: 1px;
|
||||||
justify-content: center;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #F5EFE6;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Online dot */
|
|
||||||
.online-dot {
|
.online-dot {
|
||||||
position: absolute;
|
position: absolute; bottom: 2px; right: 2px;
|
||||||
bottom: 2px;
|
width: 11px; height: 11px;
|
||||||
right: 2px;
|
background: var(--success);
|
||||||
width: 11px;
|
|
||||||
height: 11px;
|
|
||||||
background: #7A5C3A;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid #EDE0CE;
|
border: 2px solid var(--bg);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.online-dot::after {
|
.online-dot::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute; inset: -4px;
|
||||||
inset: -4px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid #7A5C3A;
|
border: 2px solid var(--success);
|
||||||
animation: pulse-ring 1.8s ease-out infinite;
|
animation: pulse-ring 2s ease-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-ring {
|
@keyframes pulse-ring {
|
||||||
0% { opacity: 0.7; transform: scale(0.8); }
|
0% { opacity: 0.6; transform: scale(0.8); }
|
||||||
100% { opacity: 0; transform: scale(1.8); }
|
100% { opacity: 0; transform: scale(1.8); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Level badge on avatar */
|
|
||||||
.avatar-level-badge {
|
.avatar-level-badge {
|
||||||
position: absolute;
|
position: absolute; top: -4px; right: -6px; z-index: 3;
|
||||||
top: -4px;
|
|
||||||
right: -6px;
|
|
||||||
z-index: 3;
|
|
||||||
filter: drop-shadow(0 1px 3px rgba(74, 55, 40, 0.3));
|
filter: drop-shadow(0 1px 3px rgba(74, 55, 40, 0.3));
|
||||||
}
|
}
|
||||||
|
|
||||||
.profil-info {
|
.profil-info { display: flex; flex-direction: column; gap: 2px; }
|
||||||
display: flex;
|
.profil-name { font-family: var(--font-display); font-size: 19px; font-weight: 700; color: var(--text); }
|
||||||
flex-direction: column;
|
.profil-handle { font-size: 12px; color: var(--accent); }
|
||||||
gap: 2px;
|
.profil-streak { font-size: 12px; color: #C4853A; margin-top: 4px; font-weight: 700; }
|
||||||
}
|
|
||||||
|
|
||||||
.profil-name {
|
|
||||||
font-family: 'Lora', Georgia, serif;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #4A3728;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profil-handle {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #7A5C3A;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Cards ──────────────────────────────────────────────────── */
|
/* ── Cards ──────────────────────────────────────────────────── */
|
||||||
.progress-card,
|
.card {
|
||||||
.skills-card {
|
background: var(--surface);
|
||||||
background: #F5EFE6;
|
border: 1px solid var(--border-soft);
|
||||||
border: 0.5px solid #D4B896;
|
border-radius: var(--r-md);
|
||||||
border-radius: 16px;
|
padding: var(--sp-4);
|
||||||
padding: 16px;
|
margin-bottom: var(--sp-3);
|
||||||
margin-bottom: 12px;
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 800;
|
||||||
color: #8C7A65;
|
color: var(--text-muted);
|
||||||
letter-spacing: 0.07em;
|
letter-spacing: 0.09em;
|
||||||
margin-bottom: 12px;
|
margin-bottom: var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Tagesziel ── */
|
||||||
|
.goal-card { display: flex; align-items: center; gap: var(--sp-4); }
|
||||||
|
.goal-ring-label { font-size: 14px; font-weight: 800; color: var(--text-strong); }
|
||||||
|
.goal-text { flex: 1; }
|
||||||
|
.goal-text .card-title { margin-bottom: 4px; }
|
||||||
|
.goal-value { font-family: var(--font-display); font-size: 22px; font-weight: 700; color: var(--text-strong); line-height: 1.2; }
|
||||||
|
.goal-value small { font-family: var(--font-ui); font-size: 13px; font-weight: 700; color: var(--text-muted); }
|
||||||
|
.goal-hint { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
||||||
|
|
||||||
/* ── XP Section ─────────────────────────────────────────────── */
|
/* ── XP Section ─────────────────────────────────────────────── */
|
||||||
.xp-row {
|
.xp-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--sp-2); }
|
||||||
display: flex;
|
.lang-label { font-size: 13px; font-weight: 700; color: var(--text); }
|
||||||
justify-content: space-between;
|
.xp-value { font-size: 13px; font-weight: 700; color: var(--accent); }
|
||||||
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-bar { background: var(--surface-2); border-radius: var(--r-pill); height: 9px; width: 100%; margin-bottom: var(--sp-2); overflow: hidden; }
|
||||||
.xp-fill {
|
.xp-fill {
|
||||||
height: 100%;
|
height: 100%; border-radius: var(--r-pill);
|
||||||
border-radius: 99px;
|
background: linear-gradient(90deg, var(--accent), var(--gold));
|
||||||
background: #7A5C3A;
|
transition: width 0.7s var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Level row */
|
.level-row { display: flex; justify-content: space-between; align-items: center; }
|
||||||
.level-row {
|
.level-pill { background: var(--accent); color: var(--bg); font-size: 11px; font-weight: 800; padding: 3px 11px; border-radius: var(--r-pill); }
|
||||||
display: flex;
|
.level-hint { font-size: 11px; color: var(--text-muted); }
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.level-pill {
|
/* ── Wochen-Graph ── */
|
||||||
background: #7A5C3A;
|
.weekbars { display: flex; align-items: flex-end; gap: var(--sp-2); height: 96px; }
|
||||||
color: #EDE0CE;
|
.weekbar-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; }
|
||||||
font-size: 11px;
|
.weekbar-track { flex: 1; width: 100%; display: flex; align-items: flex-end; }
|
||||||
font-weight: 500;
|
.weekbar-fill {
|
||||||
padding: 3px 10px;
|
width: 100%;
|
||||||
border-radius: 99px;
|
min-height: 4px;
|
||||||
|
border-radius: var(--r-sm) var(--r-sm) 4px 4px;
|
||||||
|
background: var(--gold);
|
||||||
|
transition: height 0.6s var(--ease);
|
||||||
}
|
}
|
||||||
|
.weekbar-fill.empty { background: var(--surface-sunk); }
|
||||||
|
.weekbar-fill.today { background: var(--accent); }
|
||||||
|
.weekbar-label { font-size: 10px; font-weight: 700; color: var(--text-soft); }
|
||||||
|
.weekbar-label.today { color: var(--accent); }
|
||||||
|
|
||||||
.level-hint {
|
/* ── Heatmap ── */
|
||||||
font-size: 11px;
|
.heatmap { display: flex; gap: 4px; justify-content: space-between; }
|
||||||
color: #8C7A65;
|
.heatmap-col { display: flex; flex-direction: column; gap: 4px; flex: 1; }
|
||||||
|
.heatmap-cell {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--surface-sunk);
|
||||||
}
|
}
|
||||||
|
.heatmap-cell.lvl-0 { background: var(--surface-sunk); }
|
||||||
|
.heatmap-cell.lvl-1 { background: #E4D3B0; }
|
||||||
|
.heatmap-cell.lvl-2 { background: #D4B36E; }
|
||||||
|
.heatmap-cell.lvl-3 { background: var(--gold); }
|
||||||
|
.heatmap-cell.lvl-4 { background: var(--accent); }
|
||||||
|
|
||||||
/* ── Radar ───────────────────────────────────────────────────── */
|
.heatmap-legend {
|
||||||
.radar-wrap {
|
display: flex; align-items: center; gap: 4px;
|
||||||
display: flex;
|
margin-top: var(--sp-3);
|
||||||
justify-content: center;
|
font-size: 10px; color: var(--text-soft); font-weight: 600;
|
||||||
padding: 8px 0 4px;
|
}
|
||||||
|
.heatmap-legend .heatmap-cell { width: 11px; height: 11px; }
|
||||||
|
|
||||||
|
/* ── Eckdaten ── */
|
||||||
|
.stat-grid { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-3); }
|
||||||
|
.stat-tile {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: var(--sp-3) var(--sp-2);
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 2px;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
.stat-num { font-family: var(--font-display); font-size: 22px; font-weight: 700; color: var(--text-strong); }
|
||||||
|
.stat-cap { font-size: 10px; font-weight: 700; color: var(--text-muted); letter-spacing: 0.04em; text-align: center; }
|
||||||
|
|
||||||
|
/* ── Radar ── */
|
||||||
|
.radar-wrap { display: flex; justify-content: center; padding: var(--sp-2) 0 4px; }
|
||||||
|
.skills-empty { font-size: 13px; color: var(--text-muted); text-align: center; padding: var(--sp-4) 0; }
|
||||||
|
|
||||||
|
.tracking-hint {
|
||||||
|
font-size: 12px; color: var(--text-muted); text-align: center;
|
||||||
|
padding: var(--sp-2) var(--sp-4) var(--sp-4);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import './Profil.css'
|
import './Profil.css'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { getProfilData, getActiveLearningPair, getLanguageOptions, langById } from '../api/directus'
|
import { getProfilData, getStats, getLanguageOptions, langById } from '../api/directus'
|
||||||
|
import ProgressRing from '../components/ProgressRing'
|
||||||
|
|
||||||
function LogoutButton() {
|
function LogoutButton() {
|
||||||
const { logout } = useAuth()
|
const { logout } = useAuth()
|
||||||
return (
|
return (
|
||||||
<button onClick={logout} title="Abmelden" style={{
|
<button onClick={logout} title="Abmelden" className="profil-logout">
|
||||||
position: 'absolute', top: '20px', right: '4px',
|
|
||||||
background: 'none', border: 'none', cursor: 'pointer',
|
|
||||||
padding: '6px', borderRadius: '8px', color: '#9A8878',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
transition: 'color 0.15s, background 0.15s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.color = '#C0544A'; e.currentTarget.style.background = '#FBF0EF' }}
|
|
||||||
onMouseLeave={e => { e.currentTarget.style.color = '#9A8878'; e.currentTarget.style.background = 'none' }}
|
|
||||||
>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||||
<polyline points="16 17 21 12 16 7"/>
|
<polyline points="16 17 21 12 16 7"/>
|
||||||
@@ -25,92 +17,111 @@ function LogoutButton() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const SKILLS = [
|
/* ── Radar Chart (echte Skill-Daten) ─────────────────────────── */
|
||||||
{ 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 }) {
|
function RadarChart({ skills, animate }) {
|
||||||
const size = 220
|
const size = 220, cx = 110, cy = 105, r = 70, n = skills.length
|
||||||
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 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 point = (i, ratio) => ({
|
const gridPoly = (ratio) => skills.map((_, i) => `${point(i, ratio).x},${point(i, ratio).y}`).join(' ')
|
||||||
x: cx + r * ratio * Math.cos(angle(i)),
|
const dataPoly = skills.map((s, i) => `${point(i, animate ? s.value : 0).x},${point(i, animate ? s.value : 0).y}`).join(' ')
|
||||||
y: cy + r * ratio * Math.sin(angle(i)),
|
const labelAnchor = (i) => { const x = Math.cos(angle(i)); return x > 0.1 ? 'start' : x < -0.1 ? 'end' : 'middle' }
|
||||||
})
|
const labelOffset = (i) => { const y = Math.sin(angle(i)); return y > 0.1 ? 10 : y < -0.1 ? -4 : 4 }
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} overflow="visible">
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} overflow="visible">
|
||||||
{[1, 0.8, 0.6, 0.4].map((lvl, idx) => (
|
{[1, 0.8, 0.6, 0.4].map((lvl, idx) => (
|
||||||
<polygon key={lvl} points={gridPoly(lvl)}
|
<polygon key={lvl} points={gridPoly(lvl)} fill="none" stroke="#D4B896" strokeWidth={idx === 0 ? 1 : 0.7} />
|
||||||
fill="none" stroke="#D4B896" strokeWidth={idx === 0 ? 1 : 0.7} />
|
|
||||||
))}
|
))}
|
||||||
{skills.map((_, i) => {
|
{skills.map((_, i) => {
|
||||||
const p = point(i, 1)
|
const p = point(i, 1)
|
||||||
return <line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y}
|
return <line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y} stroke="#D4B896" strokeWidth="0.7" />
|
||||||
stroke="#D4B896" strokeWidth="0.7" />
|
|
||||||
})}
|
})}
|
||||||
<polygon points={dataPoly}
|
<polygon points={dataPoly} fill="#C4A85A" fillOpacity="0.4" stroke="#7A5C3A" strokeWidth="1.5" strokeLinejoin="round"
|
||||||
fill="#C4A882" fillOpacity="0.45"
|
style={{ transition: animate ? 'all 0.7s ease-out' : 'none' }} />
|
||||||
stroke="#7A5C3A" strokeWidth="1.5" strokeLinejoin="round"
|
|
||||||
style={{ transition: animate ? 'all 0.7s ease-out' : 'none' }}
|
|
||||||
/>
|
|
||||||
{skills.map((s, i) => {
|
{skills.map((s, i) => {
|
||||||
const p = point(i, animate ? s.value : 0)
|
const p = point(i, animate ? s.value : 0)
|
||||||
return <circle key={i} cx={p.x} cy={p.y} r="4" fill="#7A5C3A"
|
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' }} />
|
style={{ transition: animate ? `all 0.7s ease-out ${i * 0.06}s` : 'none' }} />
|
||||||
})}
|
})}
|
||||||
{skills.map((s, i) => {
|
{skills.map((s, i) => {
|
||||||
const p = point(i, 1.28)
|
const p = point(i, 1.3)
|
||||||
return (
|
return (
|
||||||
<text key={i}
|
<text key={i} x={p.x} y={p.y + labelOffset(i)} textAnchor={labelAnchor(i)} dominantBaseline="middle"
|
||||||
x={p.x} y={p.y + labelOffset(i)}
|
fontSize="11" fontWeight="700" fill="#4A3728" fontFamily="var(--font-ui)">{s.label}</text>
|
||||||
textAnchor={labelAnchor(i)}
|
|
||||||
dominantBaseline="middle"
|
|
||||||
fontSize="11" fill="#4A3728" fontFamily="Nunito, sans-serif">
|
|
||||||
{s.label}
|
|
||||||
</text>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Main Component ──────────────────────────────────────────── */
|
/* ── Streak-Heatmap (letzte 12 Wochen) ───────────────────────── */
|
||||||
|
function StreakHeatmap({ daily }) {
|
||||||
|
const byDate = new Map(daily.map(d => [d.date, d.ep]))
|
||||||
|
const WEEKS = 12, DAYS = WEEKS * 7
|
||||||
|
const today = new Date()
|
||||||
|
// Start so, dass die letzte Spalte mit heute endet; auf Wochenraster (Mo–So) ausrichten
|
||||||
|
const cells = []
|
||||||
|
for (let i = DAYS - 1; i >= 0; i--) {
|
||||||
|
const d = new Date(today)
|
||||||
|
d.setDate(today.getDate() - i)
|
||||||
|
const key = d.toISOString().slice(0, 10)
|
||||||
|
const ep = byDate.get(key) || 0
|
||||||
|
const level = ep === 0 ? 0 : ep < 10 ? 1 : ep < 25 ? 2 : ep < 50 ? 3 : 4
|
||||||
|
cells.push({ key, ep, level })
|
||||||
|
}
|
||||||
|
// In Spalten zu je 7 Tagen gruppieren
|
||||||
|
const cols = []
|
||||||
|
for (let c = 0; c < WEEKS; c++) cols.push(cells.slice(c * 7, c * 7 + 7))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="heatmap">
|
||||||
|
{cols.map((col, ci) => (
|
||||||
|
<div key={ci} className="heatmap-col">
|
||||||
|
{col.map((cell) => (
|
||||||
|
<span key={cell.key} className={`heatmap-cell lvl-${cell.level}`}
|
||||||
|
title={`${cell.key}: ${cell.ep} EP`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Wochen-Graph (letzte 7 Tage) ────────────────────────────── */
|
||||||
|
function WeekBars({ daily, goal }) {
|
||||||
|
const byDate = new Map(daily.map(d => [d.date, d.ep]))
|
||||||
|
const today = new Date()
|
||||||
|
const days = []
|
||||||
|
const NAMES = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const d = new Date(today)
|
||||||
|
d.setDate(today.getDate() - i)
|
||||||
|
const key = d.toISOString().slice(0, 10)
|
||||||
|
days.push({ key, ep: byDate.get(key) || 0, name: NAMES[d.getDay()], isToday: i === 0 })
|
||||||
|
}
|
||||||
|
const max = Math.max(goal || 0, ...days.map(d => d.ep), 1)
|
||||||
|
return (
|
||||||
|
<div className="weekbars">
|
||||||
|
{days.map((d) => (
|
||||||
|
<div key={d.key} className="weekbar-col">
|
||||||
|
<div className="weekbar-track">
|
||||||
|
<div className={`weekbar-fill ${d.ep > 0 ? '' : 'empty'} ${d.isToday ? 'today' : ''}`}
|
||||||
|
style={{ height: `${Math.round((d.ep / max) * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={`weekbar-label ${d.isToday ? 'today' : ''}`}>{d.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main ────────────────────────────────────────────────────── */
|
||||||
export default function Profil() {
|
export default function Profil() {
|
||||||
const { user, token } = useAuth()
|
const { user, token } = useAuth()
|
||||||
const [radarReady, setRadarReady] = useState(false)
|
const [radarReady, setRadarReady] = useState(false)
|
||||||
const [profil, setProfil] = useState(null)
|
const [profil, setProfil] = useState(null)
|
||||||
const [pair, setPair] = useState(null)
|
const [stats, setStats] = useState(null)
|
||||||
const [langs, setLangs] = useState([])
|
const [langs, setLangs] = useState([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => setRadarReady(true), 120)
|
const t = setTimeout(() => setRadarReady(true), 120)
|
||||||
@@ -120,100 +131,151 @@ export default function Profil() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [p, lp, langs] = await Promise.all([
|
const [p, langs] = await Promise.all([getProfilData(token), getLanguageOptions()])
|
||||||
getProfilData(token),
|
setProfil(p); setLangs(langs)
|
||||||
getActiveLearningPair(user.username, token),
|
} catch { /* Fallback unten */ }
|
||||||
getLanguageOptions(),
|
// Stats getrennt – degradiert lautlos, falls /auth/stats noch nicht deployed ist
|
||||||
])
|
try { setStats(await getStats(token)) } catch { /* kein Tracking verfügbar */ }
|
||||||
setProfil(p)
|
|
||||||
setPair(lp)
|
|
||||||
setLangs(langs)
|
|
||||||
} catch {
|
|
||||||
// Profildaten nicht ladbar – zeige Fallback
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
}, [token, user.username])
|
}, [token])
|
||||||
|
|
||||||
const displayName = profil?.username || user?.username || '…'
|
const displayName = profil?.username || user?.username || '…'
|
||||||
const initials = displayName.slice(0, 2).toUpperCase()
|
const initials = displayName.slice(0, 2).toUpperCase()
|
||||||
const points = profil?.total_ep ?? 0
|
const points = profil?.total_ep ?? user?.total_ep ?? 0
|
||||||
const level = profil?.level ?? Math.floor(points / 500)
|
const level = profil?.level ?? Math.floor(points / 500)
|
||||||
const epIntoLevel = points - level * 500 // EP innerhalb des aktuellen Levels
|
const epIntoLevel = points - level * 500
|
||||||
const epPerLevel = 500
|
const epPerLevel = 500
|
||||||
const xpPct = Math.min((epIntoLevel / epPerLevel) * 100, 100)
|
const xpPct = Math.min((epIntoLevel / epPerLevel) * 100, 100)
|
||||||
const toLang = profil?.language_target_short ? langById(profil.language_target_id, langs) : null
|
const toLang = profil?.language_target_id ? langById(profil.language_target_id, langs) : null
|
||||||
const langLabel = toLang ? `${toLang.flag} ${toLang.label}` : (profil?.language_target_titel || 'Zielsprache')
|
const langLabel = toLang ? `${toLang.flag} ${toLang.label}` : (profil?.language_target_titel || 'Zielsprache')
|
||||||
const streak = profil?.streak_days ?? 0
|
const streak = profil?.streak_days ?? user?.streak_days ?? 0
|
||||||
|
|
||||||
|
const today = stats?.today
|
||||||
|
const goal = today?.daily_goal_ep || 30
|
||||||
|
const todayEp = today?.ep || 0
|
||||||
|
const goalPct = Math.min(todayEp / goal, 1)
|
||||||
|
const daily = stats?.daily || []
|
||||||
|
const totals = stats?.totals
|
||||||
|
const skills = stats?.skills || []
|
||||||
|
const hasSkillData = skills.some(s => s.seen > 0)
|
||||||
|
const accuracyPct = totals ? Math.round((totals.accuracy || 0) * 100) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="profil" style={{ position: 'relative' }}>
|
<div className="profil page-enter">
|
||||||
<LogoutButton />
|
<LogoutButton />
|
||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="profil-header">
|
<div className="profil-header">
|
||||||
<div className="avatar-wrap">
|
<div className="avatar-wrap">
|
||||||
<div className="avatar-ring">
|
<div className="avatar-ring">
|
||||||
<div className="avatar-inner">
|
<div className="avatar-inner"><div className="avatar">{initials}</div></div>
|
||||||
<div className="avatar">{initials}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="online-dot" />
|
<span className="online-dot" />
|
||||||
<div className="avatar-level-badge">
|
<div className="avatar-level-badge">
|
||||||
<svg viewBox="0 0 48 54" width="28" height="32">
|
<svg viewBox="0 0 48 54" width="28" height="32">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="hexGold2" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="hexGold2" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<stop offset="0%" stopColor="#C4A882" />
|
<stop offset="0%" stopColor="#C4A882" /><stop offset="50%" stopColor="#7A5C3A" /><stop offset="100%" stopColor="#4A3728" />
|
||||||
<stop offset="50%" stopColor="#7A5C3A" />
|
|
||||||
<stop offset="100%" stopColor="#4A3728" />
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<polygon points="24,2 46,14 46,40 24,52 2,40 2,14"
|
<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" />
|
||||||
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>
|
||||||
<text x="24" y="30" textAnchor="middle" dominantBaseline="middle"
|
|
||||||
fontSize="16" fontWeight="800" fill="#F5EFE6" fontFamily="inherit">
|
|
||||||
{level}
|
|
||||||
</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="profil-info">
|
<div className="profil-info">
|
||||||
<h2 className="profil-name">{displayName}</h2>
|
<h2 className="profil-name">{displayName}</h2>
|
||||||
<p className="profil-handle">@{displayName.toLowerCase()}</p>
|
<p className="profil-handle">@{displayName.toLowerCase()}</p>
|
||||||
{streak > 0 && (
|
{streak > 0 && (
|
||||||
<p style={{ fontSize: '12px', color: '#C4853A', marginTop: '4px' }}>
|
<p className="profil-streak">🔥 {streak} Tag{streak !== 1 ? 'e' : ''} Streak</p>
|
||||||
🔥 {streak} Tag{streak !== 1 ? 'e' : ''} Streak
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Progress Card ── */}
|
{/* ── Tagesziel ── */}
|
||||||
<div className="progress-card">
|
<div className="card goal-card">
|
||||||
<p className="card-title">DEIN FORTSCHRITT</p>
|
<ProgressRing value={goalPct} size={72} stroke={8}
|
||||||
|
color={goalPct >= 1 ? 'var(--success)' : 'var(--gold)'}>
|
||||||
|
<span className="goal-ring-label">{Math.round(goalPct * 100)}%</span>
|
||||||
|
</ProgressRing>
|
||||||
|
<div className="goal-text">
|
||||||
|
<p className="card-title">TAGESZIEL</p>
|
||||||
|
<p className="goal-value">{todayEp} <small>/ {goal} EP heute</small></p>
|
||||||
|
<p className="goal-hint">
|
||||||
|
{goalPct >= 1 ? 'Geschafft – stark! 🎉' : `Noch ${goal - todayEp} EP bis zum Tagesziel`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Fortschritt (Level/EP) ── */}
|
||||||
|
<div className="card">
|
||||||
|
<p className="card-title">DEIN FORTSCHRITT</p>
|
||||||
<div className="xp-row">
|
<div className="xp-row">
|
||||||
<span className="lang-label">{langLabel}</span>
|
<span className="lang-label">{langLabel}</span>
|
||||||
<span className="xp-value">{points.toLocaleString('de')} EP gesamt</span>
|
<span className="xp-value">{points.toLocaleString('de')} EP gesamt</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="xp-bar"><div className="xp-fill" style={{ width: `${xpPct}%` }} /></div>
|
||||||
<div className="xp-bar">
|
|
||||||
<div className="xp-fill" style={{ width: `${xpPct}%` }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="level-row">
|
<div className="level-row">
|
||||||
<span className="level-pill">Level {level}</span>
|
<span className="level-pill">Level {level}</span>
|
||||||
<span className="level-hint">{(epPerLevel - epIntoLevel).toLocaleString('de')} EP bis Level {level + 1}</span>
|
<span className="level-hint">{(epPerLevel - epIntoLevel).toLocaleString('de')} EP bis Level {level + 1}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Skills Card ── */}
|
{/* ── Wochen-Aktivität ── */}
|
||||||
<div className="skills-card">
|
{stats && (
|
||||||
<p className="card-title">FÄHIGKEITEN</p>
|
<div className="card">
|
||||||
<div className="radar-wrap">
|
<p className="card-title">DIESE WOCHE</p>
|
||||||
<RadarChart skills={SKILLS} animate={radarReady} />
|
<WeekBars daily={daily} goal={goal} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Streak-Kalender ── */}
|
||||||
|
{stats && (
|
||||||
|
<div className="card">
|
||||||
|
<p className="card-title">AKTIVITÄT · 12 WOCHEN</p>
|
||||||
|
<StreakHeatmap daily={daily} />
|
||||||
|
<div className="heatmap-legend">
|
||||||
|
<span>weniger</span>
|
||||||
|
<span className="heatmap-cell lvl-0" /><span className="heatmap-cell lvl-1" />
|
||||||
|
<span className="heatmap-cell lvl-2" /><span className="heatmap-cell lvl-3" />
|
||||||
|
<span className="heatmap-cell lvl-4" />
|
||||||
|
<span>mehr</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Eckdaten ── */}
|
||||||
|
{totals && (
|
||||||
|
<div className="stat-grid">
|
||||||
|
<div className="stat-tile">
|
||||||
|
<span className="stat-num">{totals.pairs_practiced}</span>
|
||||||
|
<span className="stat-cap">Karten geübt</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-tile">
|
||||||
|
<span className="stat-num">{accuracyPct}%</span>
|
||||||
|
<span className="stat-cap">Genauigkeit</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-tile">
|
||||||
|
<span className="stat-num">{streak}</span>
|
||||||
|
<span className="stat-cap">Tage Streak</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Skills ── */}
|
||||||
|
<div className="card">
|
||||||
|
<p className="card-title">FÄHIGKEITEN</p>
|
||||||
|
{hasSkillData ? (
|
||||||
|
<div className="radar-wrap"><RadarChart skills={skills} animate={radarReady} /></div>
|
||||||
|
) : (
|
||||||
|
<p className="skills-empty">Leg los — deine Stärken erscheinen, sobald du Karten löst.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!stats && (
|
||||||
|
<p className="tracking-hint">Dein Lernverlauf wird ab jetzt aufgezeichnet — komm morgen wieder! 🌱</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user