init: HejYou Language Learning App (React + Vite)

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

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

195
src/pages/Profil.jsx Normal file
View 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>
)
}