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:
2026-06-13 16:41:09 +02:00
parent 8154f08e04
commit 712f9a243c
16 changed files with 744 additions and 341 deletions

View File

@@ -1,21 +1,13 @@
import { useEffect, useState } from 'react'
import './Profil.css'
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() {
const { logout } = useAuth()
return (
<button onClick={logout} title="Abmelden" style={{
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' }}
>
<button onClick={logout} title="Abmelden" className="profil-logout">
<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"/>
<polyline points="16 17 21 12 16 7"/>
@@ -25,92 +17,111 @@ function LogoutButton() {
)
}
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 ─────────────────────────────────────────────── */
/* ── Radar Chart (echte Skill-Daten) ─────────────────────────── */
function RadarChart({ skills, animate }) {
const size = 220
const cx = 110
const cy = 105
const r = 70
const n = skills.length
const size = 220, cx = 110, cy = 105, r = 70, 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
}
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)); 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 }
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} />
<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" />
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' }}
/>
<polygon points={dataPoly} fill="#C4A85A" fillOpacity="0.4" 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)
const p = point(i, 1.3)
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>
<text key={i} x={p.x} y={p.y + labelOffset(i)} textAnchor={labelAnchor(i)} dominantBaseline="middle"
fontSize="11" fontWeight="700" fill="#4A3728" fontFamily="var(--font-ui)">{s.label}</text>
)
})}
</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 (MoSo) 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() {
const { user, token } = useAuth()
const [radarReady, setRadarReady] = useState(false)
const [profil, setProfil] = useState(null)
const [pair, setPair] = useState(null)
const [langs, setLangs] = useState([])
const [profil, setProfil] = useState(null)
const [stats, setStats] = useState(null)
const [langs, setLangs] = useState([])
useEffect(() => {
const t = setTimeout(() => setRadarReady(true), 120)
@@ -120,100 +131,151 @@ export default function Profil() {
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
}
const [p, langs] = await Promise.all([getProfilData(token), getLanguageOptions()])
setProfil(p); setLangs(langs)
} catch { /* Fallback unten */ }
// Stats getrennt degradiert lautlos, falls /auth/stats noch nicht deployed ist
try { setStats(await getStats(token)) } catch { /* kein Tracking verfügbar */ }
}
load()
}, [token, user.username])
}, [token])
const displayName = profil?.username || user?.username || '…'
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 epIntoLevel = points - level * 500 // EP innerhalb des aktuellen Levels
const epIntoLevel = points - level * 500
const epPerLevel = 500
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 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 (
<div className="profil" style={{ position: 'relative' }}>
<div className="profil page-enter">
<LogoutButton />
{/* ── Header ── */}
<div className="profil-header">
<div className="avatar-wrap">
<div className="avatar-ring">
<div className="avatar-inner">
<div className="avatar">{initials}</div>
</div>
<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" />
<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>
<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>
<p className="profil-streak">🔥 {streak} Tag{streak !== 1 ? 'e' : ''} Streak</p>
)}
</div>
</div>
{/* ── Progress Card ── */}
<div className="progress-card">
<p className="card-title">DEIN FORTSCHRITT</p>
{/* ── Tagesziel ── */}
<div className="card goal-card">
<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">
<span className="lang-label">{langLabel}</span>
<span className="xp-value">{points.toLocaleString('de')} EP gesamt</span>
</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">
<span className="level-pill">Level {level}</span>
<span className="level-hint">{(epPerLevel - epIntoLevel).toLocaleString('de')} EP 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} />
{/* ── Wochen-Aktivität ── */}
{stats && (
<div className="card">
<p className="card-title">DIESE WOCHE</p>
<WeekBars daily={daily} goal={goal} />
</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>
{!stats && (
<p className="tracking-hint">Dein Lernverlauf wird ab jetzt aufgezeichnet komm morgen wieder! 🌱</p>
)}
</div>
)
}