feat: WM2026 Tippspiel - Initial Backend + Frontend

This commit is contained in:
Ronny Müller
2026-04-03 21:41:19 +02:00
commit 1c685b90a0
2507 changed files with 997210 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.header {
background: rgba(10,14,26,0.92);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(75,183,248,0.1);
position: sticky;
top: 0;
z-index: 100;
}
.headerInner {
max-width: 1100px;
margin: 0 auto;
padding: 0 24px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
}
.logoFlag { font-size: 22px; }
.logoText {
font-family: 'Plus Jakarta Sans', sans-serif;
font-weight: 800;
font-size: 17px;
color: var(--text-primary);
letter-spacing: -0.3px;
}
.nav {
display: flex;
align-items: center;
gap: 4px;
}
.navLink, .navLinkActive {
padding: 6px 14px;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
text-decoration: none;
transition: all 0.15s;
color: var(--text-secondary);
}
.navLink:hover { color: var(--text-primary); background: var(--surface-mid); }
.navLinkActive {
color: var(--primary);
background: var(--primary-dim);
}
.main {
flex: 1;
max-width: 1100px;
margin: 0 auto;
padding: 32px 24px;
width: 100%;
}
+44
View File
@@ -0,0 +1,44 @@
import { Routes, Route, NavLink } from 'react-router-dom';
import MatchesPage from './pages/MatchesPage';
import LeaderboardPage from './pages/LeaderboardPage';
import ProfilePage from './pages/ProfilePage';
import AdminPage from './pages/AdminPage';
import styles from './App.module.css';
export default function App() {
return (
<div className={styles.app}>
<header className={styles.header}>
<div className={styles.headerInner}>
<div className={styles.logo}>
<span className={styles.logoFlag}>🏆</span>
<span className={styles.logoText}>WM 2026 Tippspiel</span>
</div>
<nav className={styles.nav}>
<NavLink to="/" end className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
Spielplan
</NavLink>
<NavLink to="/rangliste" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
Rangliste
</NavLink>
<NavLink to="/profil" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
Mein Profil
</NavLink>
<NavLink to="/admin" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
Admin
</NavLink>
</nav>
</div>
</header>
<main className={styles.main}>
<Routes>
<Route path="/" element={<MatchesPage />} />
<Route path="/rangliste" element={<LeaderboardPage />} />
<Route path="/profil" element={<ProfilePage />} />
<Route path="/admin" element={<AdminPage />} />
</Routes>
</main>
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
const BASE = '/api';
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.message || err.error || 'Request failed');
}
return res.json();
}
export const api = {
// Matches
getMatches: (params?: { stage?: string; group?: string }) => {
const q = new URLSearchParams(params as Record<string, string>).toString();
return request<{ matches: Match[]; count: number }>(`/matches${q ? '?' + q : ''}`);
},
// Tips
submitTip: (matchId: number, tipHome: number, tipAway: number) =>
request<{ success: boolean; tip: TipInfo; message: string }>('/tips', {
method: 'POST',
body: JSON.stringify({ matchId, tipHome, tipAway }),
}),
getMyTips: () =>
request<{ tips: MyTip[]; count: number }>('/tips'),
// Leaderboard
getLeaderboard: () =>
request<LeaderboardResponse>('/leaderboard'),
getMyStats: () =>
request<UserStats>('/leaderboard/me'),
// Admin
syncMatches: () =>
request<{ success: boolean; total: number; created: number; updated: number }>(
'/admin/sync',
{ method: 'POST' }
),
evaluateTips: () =>
request<{ success: boolean; matchesEvaluated: number; tipsUpdated: number }>(
'/admin/evaluate',
{ method: 'POST' }
),
};
// Types (gespiegelt vom Backend)
export interface Match {
id: number;
externalId: number;
utcDate: string;
status: string;
stage: string;
group: string | null;
homeTeam: { name: string; shortName: string; crest: string | null };
awayTeam: { name: string; shortName: string; crest: string | null };
score: { home: number | null; away: number | null };
userTip: TipInfo | null;
minutesUntilKickoff: number;
tippable: boolean;
}
export interface TipInfo {
home: number;
away: number;
points: number | null;
}
export interface MyTip {
match_id: number;
tip_home: number;
tip_away: number;
points: number | null;
utc_date: string;
home_team_short: string;
away_team_short: string;
status: string;
}
export interface LeaderboardEntry {
user_id: string;
full_name: string;
total_points: number;
tips_count: number;
exact_count: number;
tendency_count: number;
rank: number;
}
export interface LeaderboardResponse {
entries: LeaderboardEntry[];
currentUserRank: number | null;
totalParticipants: number;
lastUpdated: string;
}
export interface UserStats {
userId: string;
fullName: string;
totalPoints: number;
rank: number | null;
tipsCount: number;
exactCount: number;
tendencyCount: number;
wrongCount: number;
accuracy: number;
}
@@ -0,0 +1,125 @@
.card { padding: 16px 20px; transition: box-shadow 0.2s; }
.card:hover { box-shadow: 0 12px 30px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.09); }
.live { box-shadow: 0 0 0 1px rgba(248,113,113,0.3), 0 10px 25px rgba(0,0,0,0.25) !important; }
.topRow {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.status { font-size: 12px; color: var(--text-muted); }
.statusLive { color: var(--error); font-weight: 600; }
.kickoff { font-size: 13px; color: var(--text-secondary); margin-left: auto; }
.badge, .badgeUrgent {
font-size: 11px;
padding: 3px 8px;
border-radius: 10px;
font-weight: 600;
}
.badge { background: var(--surface-high); color: var(--text-secondary); }
.badgeUrgent { background: rgba(254,174,50,0.15); color: var(--gold); }
.group {
font-size: 11px;
color: var(--primary);
background: var(--primary-dim);
padding: 2px 8px;
border-radius: 10px;
}
.matchRow {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.teamHome { display: flex; align-items: center; gap: 10px; justify-content: flex-end; }
.teamAway { display: flex; align-items: center; gap: 10px; }
.crest { width: 28px; height: 28px; object-fit: contain; }
.teamName {
font-family: 'Plus Jakarta Sans', sans-serif;
font-weight: 600;
font-size: 15px;
color: var(--text-primary);
}
.scoreBox {
min-width: 80px;
text-align: center;
background: var(--surface-high);
border-radius: var(--radius-sm);
padding: 8px 12px;
}
.score {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 22px;
font-weight: 800;
color: var(--text-primary);
letter-spacing: 2px;
}
.vs {
font-size: 14px;
font-weight: 600;
color: var(--text-muted);
}
/* Tipp */
.tipRow {
border-top: 1px solid rgba(255,255,255,0.06);
padding-top: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.tipBtn { width: 100%; max-width: 240px; }
.tipDisplay {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
.tipLabel { font-size: 13px; color: var(--text-secondary); }
.tipScore {
font-family: 'Plus Jakarta Sans', sans-serif;
font-weight: 700;
font-size: 16px;
color: var(--primary);
}
.points {
font-size: 13px;
font-weight: 600;
padding: 3px 10px;
border-radius: 10px;
}
.exact { background: rgba(52,211,153,0.15); color: var(--success); }
.tendency { background: rgba(75,183,248,0.15); color: var(--primary); }
.wrong { background: rgba(248,113,113,0.12); color: var(--error); }
.editBtn {
background: transparent;
border: 1px solid rgba(255,255,255,0.15);
color: var(--text-secondary);
padding: 4px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.editBtn:hover { border-color: var(--primary); color: var(--primary); }
.noTip { font-size: 13px; color: var(--text-muted); }
+113
View File
@@ -0,0 +1,113 @@
import { Match } from '../api/client';
import styles from './MatchCard.module.css';
interface Props {
match: Match;
onTip: () => void;
}
const STATUS_LABELS: Record<string, string> = {
SCHEDULED: 'Geplant',
TIMED: 'Terminiert',
IN_PLAY: '🔴 Live',
PAUSED: 'Pause',
FINISHED: 'Beendet',
POSTPONED: 'Verschoben',
CANCELLED: 'Abgesagt',
};
function formatKickoff(utcDate: string): string {
return new Date(utcDate).toLocaleString('de-DE', {
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin'
}) + ' Uhr';
}
function CountdownBadge({ minutes }: { minutes: number }) {
if (minutes <= 0) return null;
if (minutes < 60) return <span className={styles.badgeUrgent}>in {minutes} Min.</span>;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h < 24) return <span className={styles.badge}>in {h}h {m > 0 ? `${m}m` : ''}</span>;
const d = Math.floor(h / 24);
return <span className={styles.badge}>in {d} Tag{d > 1 ? 'en' : ''}</span>;
}
export default function MatchCard({ match, onTip }: Props) {
const isFinished = match.status === 'FINISHED';
const isLive = match.status === 'IN_PLAY' || match.status === 'PAUSED';
const hasTip = !!match.userTip;
return (
<div className={`card ${styles.card} ${isLive ? styles.live : ''}`}>
{/* Status + Kickoff */}
<div className={styles.topRow}>
<span className={`${styles.status} ${isLive ? styles.statusLive : ''}`}>
{STATUS_LABELS[match.status] ?? match.status}
</span>
<span className={styles.kickoff}>{formatKickoff(match.utcDate)}</span>
{match.tippable && <CountdownBadge minutes={match.minutesUntilKickoff} />}
{match.group && <span className={styles.group}>{match.group.replace('GROUP_', 'Gruppe ')}</span>}
</div>
{/* Teams + Score */}
<div className={styles.matchRow}>
{/* Home Team */}
<div className={styles.teamHome}>
{match.homeTeam.crest && (
<img className={styles.crest} src={match.homeTeam.crest} alt="" />
)}
<span className={styles.teamName}>{match.homeTeam.name}</span>
</div>
{/* Score / VS */}
<div className={styles.scoreBox}>
{isFinished || isLive ? (
<span className={styles.score}>
{match.score.home ?? ''} : {match.score.away ?? ''}
</span>
) : (
<span className={styles.vs}>vs</span>
)}
</div>
{/* Away Team */}
<div className={styles.teamAway}>
<span className={styles.teamName}>{match.awayTeam.name}</span>
{match.awayTeam.crest && (
<img className={styles.crest} src={match.awayTeam.crest} alt="" />
)}
</div>
</div>
{/* Tipp-Bereich */}
<div className={styles.tipRow}>
{hasTip ? (
<div className={styles.tipDisplay}>
<span className={styles.tipLabel}>Dein Tipp:</span>
<span className={styles.tipScore}>
{match.userTip!.home} : {match.userTip!.away}
</span>
{match.userTip!.points !== null && (
<span className={`${styles.points} ${
match.userTip!.points === 3 ? styles.exact :
match.userTip!.points === 1 ? styles.tendency : styles.wrong
}`}>
{match.userTip!.points === 3 ? '🎯 3 Punkte' :
match.userTip!.points === 1 ? '✓ 1 Punkt' : '✗ 0 Punkte'}
</span>
)}
{match.tippable && (
<button className={styles.editBtn} onClick={onTip}>Ändern</button>
)}
</div>
) : match.tippable ? (
<button className={`btn-primary ${styles.tipBtn}`} onClick={onTip}>
Tipp abgeben
</button>
) : (
<span className={styles.noTip}>Kein Tipp abgegeben</span>
)}
</div>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
.overlay {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center;
padding: 20px;
}
.modal {
background: var(--surface-mid);
border-radius: var(--radius-xl);
padding: 28px;
width: 100%; max-width: 440px;
box-shadow: 0 24px 60px rgba(0,0,0,0.6), 0 0 0 1px rgba(75,183,248,0.1);
position: relative;
}
.header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 24px;
}
.title {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 20px; font-weight: 800;
}
.closeBtn {
background: var(--surface-high);
border: none; color: var(--text-secondary);
width: 32px; height: 32px; border-radius: 50%;
cursor: pointer; font-size: 14px;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.closeBtn:hover { background: var(--surface-high); color: var(--text-primary); }
.teamsRow {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; margin-bottom: 28px;
}
.team { display: flex; align-items: center; gap: 8px; flex: 1; }
.teamRight { flex-direction: row-reverse; }
.crest { width: 32px; height: 32px; object-fit: contain; }
.teamName {
font-family: 'Plus Jakarta Sans', sans-serif;
font-weight: 600; font-size: 14px;
color: var(--text-primary);
}
.vs { font-size: 13px; color: var(--text-muted); font-weight: 600; }
.pickerRow {
display: flex; align-items: center; justify-content: center;
gap: 20px; margin-bottom: 20px;
}
.colon {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 40px; font-weight: 800;
color: var(--text-secondary);
line-height: 1;
}
.picker {
display: flex; flex-direction: column;
align-items: center; gap: 12px;
}
.pickerBtn {
width: 48px; height: 48px;
background: var(--surface-high);
border: 1px solid rgba(255,255,255,0.1);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 22px; font-weight: 300;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.pickerBtn:hover { background: var(--primary-dim); border-color: var(--primary); color: var(--primary); }
.pickerValue {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 52px; font-weight: 800;
color: var(--text-primary);
min-width: 60px; text-align: center;
line-height: 1;
}
.tendencyRow {
display: flex; align-items: center; justify-content: center;
gap: 8px; margin-bottom: 24px;
padding: 10px;
background: var(--surface-high);
border-radius: var(--radius-sm);
}
.tendencyLabel { font-size: 13px; color: var(--text-secondary); }
.tendencyValue { font-size: 14px; font-weight: 700; color: var(--primary); }
.error {
color: var(--error);
font-size: 13px;
text-align: center;
margin-bottom: 16px;
padding: 10px;
background: rgba(248,113,113,0.1);
border-radius: var(--radius-sm);
}
.actions {
display: flex; gap: 12px; justify-content: flex-end;
}
.actions .btn-primary { flex: 1; }
+95
View File
@@ -0,0 +1,95 @@
import { useState } from 'react';
import { Match, api } from '../api/client';
import styles from './TipModal.module.css';
interface Props {
match: Match;
onClose: () => void;
onSaved: (matchId: number, home: number, away: number) => void;
}
export default function TipModal({ match, onClose, onSaved }: Props) {
const existing = match.userTip;
const [home, setHome] = useState(existing?.home ?? 0);
const [away, setAway] = useState(existing?.away ?? 0);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const adjust = (setter: React.Dispatch<React.SetStateAction<number>>, val: number, delta: number) => {
setter(Math.max(0, Math.min(20, val + delta)));
};
const handleSave = async () => {
setSaving(true);
setError(null);
try {
await api.submitTip(match.id, home, away);
onSaved(match.id, home, away);
} catch (e) {
setError((e as Error).message);
setSaving(false);
}
};
// Tendenz-Anzeige
const tendency = home > away ? match.homeTeam.shortName :
away > home ? match.awayTeam.shortName : 'Unentschieden';
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
{/* Header */}
<div className={styles.header}>
<h2 className={styles.title}>Tipp abgeben</h2>
<button className={styles.closeBtn} onClick={onClose}></button>
</div>
{/* Teams */}
<div className={styles.teamsRow}>
<div className={styles.team}>
{match.homeTeam.crest && <img className={styles.crest} src={match.homeTeam.crest} alt="" />}
<span className={styles.teamName}>{match.homeTeam.name}</span>
</div>
<span className={styles.vs}>vs</span>
<div className={`${styles.team} ${styles.teamRight}`}>
<span className={styles.teamName}>{match.awayTeam.name}</span>
{match.awayTeam.crest && <img className={styles.crest} src={match.awayTeam.crest} alt="" />}
</div>
</div>
{/* Score Picker */}
<div className={styles.pickerRow}>
<ScorePicker value={home} onChange={v => setHome(v)} />
<div className={styles.colon}>:</div>
<ScorePicker value={away} onChange={v => setAway(v)} />
</div>
{/* Tendenz */}
<div className={styles.tendencyRow}>
<span className={styles.tendencyLabel}>Tendenz:</span>
<span className={styles.tendencyValue}>{tendency}</span>
</div>
{error && <div className={styles.error}>{error}</div>}
{/* Buttons */}
<div className={styles.actions}>
<button className="btn-ghost" onClick={onClose}>Abbrechen</button>
<button className="btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Wird gespeichert…' : '✓ Tipp speichern'}
</button>
</div>
</div>
</div>
);
}
function ScorePicker({ value, onChange }: { value: number; onChange: (v: number) => void }) {
return (
<div className={styles.picker}>
<button className={styles.pickerBtn} onClick={() => onChange(Math.min(20, value + 1))}>+</button>
<span className={styles.pickerValue}>{value}</span>
<button className={styles.pickerBtn} onClick={() => onChange(Math.max(0, value - 1))}></button>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
/* ============================================================
WM 2026 Tippspiel — Stadium Elite Design System
============================================================ */
:root {
--bg-deep: #0A0E1A;
--bg-mid: #0F1628;
--surface-low: #111827;
--surface-mid: #151D30;
--surface-high: #1C2640;
--primary: #4BB7F8;
--primary-dim: rgba(75,183,248,0.12);
--gold: #FEAE32;
--gold-glow: rgba(254,174,50,0.4);
--cyan: #69DAFF;
--text-primary: #F0F4FF;
--text-secondary: rgba(240,244,255,0.55);
--text-muted: rgba(240,244,255,0.3);
--success: #34D399;
--error: #F87171;
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 20px;
--radius-xl: 28px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root {
height: 100%;
background: var(--bg-deep);
color: var(--text-primary);
font-family: 'Inter', sans-serif;
font-size: 15px;
-webkit-font-smoothing: antialiased;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--surface-high); border-radius: 3px; }
/* Utility */
.font-display { font-family: 'Plus Jakarta Sans', sans-serif; }
.text-primary { color: var(--primary); }
.text-gold { color: var(--gold); }
.text-muted { color: var(--text-secondary); }
/* Glass Card */
.card {
background: var(--surface-mid);
border-radius: var(--radius-md);
box-shadow:
0 10px 25px rgba(0,0,0,0.25),
inset 0 1px 0 rgba(255,255,255,0.07),
inset 1px 0 0 rgba(255,255,255,0.04);
position: relative;
overflow: hidden;
}
.card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; height: 50%;
background: linear-gradient(180deg, rgba(255,255,255,0.04) 0%, transparent 100%);
pointer-events: none;
}
/* Button primary */
button.btn-primary {
background: linear-gradient(135deg, var(--primary) 0%, #2196f3 100%);
color: #fff;
border: none;
border-radius: var(--radius-sm);
padding: 10px 20px;
font-family: 'Plus Jakarta Sans', sans-serif;
font-weight: 700;
font-size: 14px;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
box-shadow: 0 4px 15px rgba(75,183,248,0.3);
}
button.btn-primary:hover { opacity: 0.9; }
button.btn-primary:active { transform: scale(0.97); }
button.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
/* Button ghost */
button.btn-ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid rgba(255,255,255,0.1);
border-radius: var(--radius-sm);
padding: 8px 16px;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
button.btn-ghost:hover { border-color: var(--primary); color: var(--primary); }
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+11
View File
@@ -0,0 +1,11 @@
.page { display: flex; flex-direction: column; gap: 24px; max-width: 800px; }
.title { font-size: 28px; font-weight: 800; }
.hint { font-size: 13px; color: var(--text-secondary); padding: 12px 16px; background: var(--surface-mid); border-radius: var(--radius-sm); border-left: 3px solid var(--primary); }
.cards { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.actionCard { padding: 28px; display: flex; flex-direction: column; gap: 14px; }
.cardIcon { font-size: 32px; }
.cardTitle { font-family: 'Plus Jakarta Sans', sans-serif; font-size: 18px; font-weight: 700; }
.cardDesc { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }
.status { font-size: 13px; padding: 10px 14px; border-radius: var(--radius-sm); }
.success { background: rgba(52,211,153,0.1); color: var(--success); }
.error { background: rgba(248,113,113,0.1); color: var(--error); }
+73
View File
@@ -0,0 +1,73 @@
import { useState } from 'react';
import { api } from '../api/client';
import styles from './AdminPage.module.css';
export default function AdminPage() {
const [syncStatus, setSyncStatus] = useState<string | null>(null);
const [evalStatus, setEvalStatus] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const [evaluating, setEvaluating] = useState(false);
const handleSync = async () => {
setSyncing(true);
setSyncStatus(null);
try {
const res = await api.syncMatches();
setSyncStatus(`${res.total} Spiele geladen — ${res.created} neu, ${res.updated} aktualisiert`);
} catch (e) {
setSyncStatus(`⚠️ Fehler: ${(e as Error).message}`);
} finally {
setSyncing(false);
}
};
const handleEvaluate = async () => {
setEvaluating(true);
setEvalStatus(null);
try {
const res = await api.evaluateTips();
setEvalStatus(`${res.matchesEvaluated} Spiele ausgewertet — ${res.tipsUpdated} Tipps bewertet`);
} catch (e) {
setEvalStatus(`⚠️ Fehler: ${(e as Error).message}`);
} finally {
setEvaluating(false);
}
};
return (
<div className={styles.page}>
<h1 className={`font-display ${styles.title}`}> Administration</h1>
<p className={styles.hint}>Diese Seite ist nur für Editoren. Nach der Staffbase-Integration wird sie durch Rollenprüfung geschützt.</p>
<div className={styles.cards}>
<div className={`card ${styles.actionCard}`}>
<div className={styles.cardIcon}>🔄</div>
<h2 className={styles.cardTitle}>Spiele synchronisieren</h2>
<p className={styles.cardDesc}>Lädt alle WM 2026-Spiele von football-data.org und speichert sie in der Datenbank. Täglich ausführen oder nach Spielplan-Änderungen.</p>
{syncStatus && (
<div className={`${styles.status} ${syncStatus.startsWith('✓') ? styles.success : styles.error}`}>
{syncStatus}
</div>
)}
<button className="btn-primary" onClick={handleSync} disabled={syncing}>
{syncing ? '⏳ Wird synchronisiert…' : '🔄 Jetzt synchronisieren'}
</button>
</div>
<div className={`card ${styles.actionCard}`}>
<div className={styles.cardIcon}>🧮</div>
<h2 className={styles.cardTitle}>Tipps auswerten</h2>
<p className={styles.cardDesc}>Berechnet Punkte für alle abgeschlossenen Spiele und aktualisiert die Rangliste. Nach jedem Spieltag ausführen.</p>
{evalStatus && (
<div className={`${styles.status} ${evalStatus.startsWith('✓') ? styles.success : styles.error}`}>
{evalStatus}
</div>
)}
<button className="btn-primary" onClick={handleEvaluate} disabled={evaluating}>
{evaluating ? '⏳ Wird ausgewertet…' : '🧮 Tipps auswerten'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,38 @@
.page { display: flex; flex-direction: column; gap: 24px; }
.pageHeader { display: flex; align-items: baseline; gap: 16px; }
.title { font-size: 28px; font-weight: 800; }
.meta { font-size: 13px; color: var(--text-secondary); }
.loading { display: flex; justify-content: center; padding: 60px; }
.spinner { width: 32px; height: 32px; border: 3px solid var(--surface-high); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.empty { text-align: center; color: var(--text-secondary); padding: 60px; }
.list { display: flex; flex-direction: column; gap: 8px; }
.row {
padding: 14px 20px;
display: grid;
grid-template-columns: 44px 1fr auto auto;
align-items: center;
gap: 16px;
transition: all 0.15s;
}
.row:hover { box-shadow: 0 8px 24px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.09); }
.topThree { box-shadow: 0 10px 25px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.1), 0 0 0 1px rgba(254,174,50,0.1); }
.rank { font-size: 22px; text-align: center; }
.rankNum { font-family: 'Plus Jakarta Sans', sans-serif; font-size: 16px; font-weight: 700; color: var(--text-secondary); }
.name { font-family: 'Plus Jakarta Sans', sans-serif; font-weight: 600; font-size: 15px; }
.stats { display: flex; gap: 16px; }
.stat { display: flex; align-items: center; gap: 4px; }
.statVal { font-weight: 700; font-size: 14px; }
.statLbl { font-size: 12px; color: var(--text-muted); }
.points { font-family: 'Plus Jakarta Sans', sans-serif; font-size: 20px; font-weight: 800; min-width: 70px; text-align: right; }
.ptLabel { font-size: 12px; font-weight: 500; color: var(--text-secondary); }
.gold { color: var(--gold); }
.silver { color: #C0C0C0; }
.bronze { color: #CD7F32; }
+69
View File
@@ -0,0 +1,69 @@
import { useState, useEffect } from 'react';
import { api, LeaderboardEntry } from '../api/client';
import styles from './LeaderboardPage.module.css';
const MEDALS = ['🥇', '🥈', '🥉'];
export default function LeaderboardPage() {
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
const [myRank, setMyRank] = useState<number | null>(null);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.getLeaderboard().then(res => {
setEntries(res.entries);
setMyRank(res.currentUserRank);
setTotal(res.totalParticipants);
}).finally(() => setLoading(false));
}, []);
if (loading) return (
<div className={styles.loading}><div className={styles.spinner} /></div>
);
return (
<div className={styles.page}>
<div className={styles.pageHeader}>
<h1 className={`font-display ${styles.title}`}>🏆 Rangliste</h1>
<div className={styles.meta}>
{total} Teilnehmer{myRank ? ` · Du: Platz ${myRank}` : ''}
</div>
</div>
{entries.length === 0 ? (
<div className={styles.empty}>
Noch keine Punkte vergeben. Spiele müssen erst abgeschlossen sein.
</div>
) : (
<div className={styles.list}>
{entries.map((entry, i) => (
<div key={entry.user_id} className={`card ${styles.row} ${i < 3 ? styles.topThree : ''}`}>
<div className={styles.rank}>
{i < 3 ? MEDALS[i] : <span className={styles.rankNum}>{entry.rank}</span>}
</div>
<div className={styles.name}>{entry.full_name}</div>
<div className={styles.stats}>
<span className={styles.stat}>
<span className={styles.statVal}>{entry.exact_count}</span>
<span className={styles.statLbl}>🎯</span>
</span>
<span className={styles.stat}>
<span className={styles.statVal}>{entry.tendency_count}</span>
<span className={styles.statLbl}></span>
</span>
<span className={styles.stat}>
<span className={styles.statVal}>{entry.tips_count}</span>
<span className={styles.statLbl}>Tipps</span>
</span>
</div>
<div className={`${styles.points} ${i === 0 ? styles.gold : i === 1 ? styles.silver : i === 2 ? styles.bronze : ''}`}>
{entry.total_points} <span className={styles.ptLabel}>Pkt</span>
</div>
</div>
))}
</div>
)}
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
.page { display: flex; flex-direction: column; gap: 24px; }
.statsRow {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.statCard {
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.statValue {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 32px;
font-weight: 800;
line-height: 1;
}
.statLabel {
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filter, .filterActive {
padding: 7px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid rgba(255,255,255,0.1);
background: transparent;
color: var(--text-secondary);
transition: all 0.15s;
}
.filter:hover { border-color: var(--primary); color: var(--primary); }
.filterActive {
background: var(--primary-dim);
border-color: rgba(75,183,248,0.3);
color: var(--primary);
}
.dayGroup { display: flex; flex-direction: column; gap: 12px; }
.dayHeader {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0 4px;
}
.matchList { display: flex; flex-direction: column; gap: 10px; }
/* States */
.loadingState, .errorState, .emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 60px 20px;
color: var(--text-secondary);
}
.spinner {
width: 32px; height: 32px;
border: 3px solid var(--surface-high);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.emptyIcon { font-size: 48px; }
.emptyHint { font-size: 13px; color: var(--text-muted); }
.errorState { color: var(--error); }
+141
View File
@@ -0,0 +1,141 @@
import { useState, useEffect, useCallback } from 'react';
import { api, Match } from '../api/client';
import MatchCard from '../components/MatchCard';
import TipModal from '../components/TipModal';
import styles from './MatchesPage.module.css';
const STAGES: { key: string; label: string }[] = [
{ key: '', label: 'Alle' },
{ key: 'GROUP_STAGE', label: 'Gruppenphase' },
{ key: 'ROUND_OF_32', label: 'Runde der 32' },
{ key: 'ROUND_OF_16', label: 'Achtelfinale' },
{ key: 'QUARTER_FINALS', label: 'Viertelfinale' },
{ key: 'SEMI_FINALS', label: 'Halbfinale' },
{ key: 'FINAL', label: 'Finale' },
];
export default function MatchesPage() {
const [matches, setMatches] = useState<Match[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [stage, setStage] = useState('');
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
const loadMatches = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await api.getMatches(stage ? { stage } : undefined);
setMatches(res.matches);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}, [stage]);
useEffect(() => { loadMatches(); }, [loadMatches]);
const handleTipSaved = (matchId: number, tipHome: number, tipAway: number) => {
setMatches(prev => prev.map(m =>
m.id === matchId
? { ...m, userTip: { home: tipHome, away: tipAway, points: null } }
: m
));
setSelectedMatch(null);
};
// Spiele nach Datum gruppieren
const grouped = matches.reduce<Record<string, Match[]>>((acc, m) => {
const day = new Date(m.utcDate).toLocaleDateString('de-DE', {
weekday: 'long', day: 'numeric', month: 'long'
});
if (!acc[day]) acc[day] = [];
acc[day].push(m);
return acc;
}, {});
const tipped = matches.filter(m => m.userTip).length;
const tippable = matches.filter(m => m.tippable).length;
return (
<div className={styles.page}>
{/* Header Stats */}
<div className={styles.statsRow}>
<div className={`card ${styles.statCard}`}>
<span className={styles.statValue}>{matches.length}</span>
<span className={styles.statLabel}>Spiele gesamt</span>
</div>
<div className={`card ${styles.statCard}`}>
<span className={`${styles.statValue} text-primary`}>{tipped}</span>
<span className={styles.statLabel}>Tipps abgegeben</span>
</div>
<div className={`card ${styles.statCard}`}>
<span className={`${styles.statValue} text-gold`}>{tippable}</span>
<span className={styles.statLabel}>Noch tippbar</span>
</div>
</div>
{/* Stage Filter */}
<div className={styles.filters}>
{STAGES.map(s => (
<button
key={s.key}
className={stage === s.key ? styles.filterActive : styles.filter}
onClick={() => setStage(s.key)}
>
{s.label}
</button>
))}
</div>
{/* Content */}
{loading && (
<div className={styles.loadingState}>
<div className={styles.spinner} />
<span>Spiele werden geladen</span>
</div>
)}
{error && (
<div className={styles.errorState}>
<span> {error}</span>
<button className="btn-ghost" onClick={loadMatches}>Erneut versuchen</button>
</div>
)}
{!loading && !error && matches.length === 0 && (
<div className={styles.emptyState}>
<span className={styles.emptyIcon}></span>
<p>Noch keine Spiele vorhanden.</p>
<p className={styles.emptyHint}>
Geh auf die Admin-Seite und klicke "Spiele synchronisieren".
</p>
</div>
)}
{!loading && !error && Object.entries(grouped).map(([day, dayMatches]) => (
<div key={day} className={styles.dayGroup}>
<h2 className={styles.dayHeader}>{day}</h2>
<div className={styles.matchList}>
{dayMatches.map(match => (
<MatchCard
key={match.id}
match={match}
onTip={() => setSelectedMatch(match)}
/>
))}
</div>
</div>
))}
{selectedMatch && (
<TipModal
match={selectedMatch}
onClose={() => setSelectedMatch(null)}
onSaved={handleTipSaved}
/>
)}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
.page { display: flex; flex-direction: column; gap: 20px; max-width: 640px; }
.loading { display: flex; justify-content: center; padding: 60px; }
.spinner { width: 32px; height: 32px; border: 3px solid var(--surface-high); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.empty { color: var(--text-secondary); padding: 40px; text-align: center; }
.heroCard { padding: 28px; display: flex; align-items: center; gap: 20px; }
.avatar { width: 60px; height: 60px; border-radius: 50%; background: var(--primary-dim); border: 2px solid rgba(75,183,248,0.3); display: flex; align-items: center; justify-content: center; font-family: 'Plus Jakarta Sans', sans-serif; font-size: 24px; font-weight: 800; color: var(--primary); flex-shrink: 0; }
.heroInfo { flex: 1; }
.name { font-size: 22px; font-weight: 800; }
.rankBadge { font-size: 13px; color: var(--gold); margin-top: 4px; font-weight: 600; }
.heroPoints { text-align: right; }
.pointsVal { font-size: 40px; font-weight: 800; color: var(--primary); line-height: 1; display: block; }
.pointsLbl { font-size: 12px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; }
.statsGrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.statCard { padding: 20px; text-align: center; }
.statVal { font-size: 36px; font-weight: 800; display: block; }
.statLbl { font-size: 13px; color: var(--text-secondary); display: block; margin-top: 4px; }
.accuracyCard { padding: 24px; }
.accuracyHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
.accuracyLabel { font-size: 14px; color: var(--text-secondary); }
.accuracyVal { font-size: 28px; font-weight: 800; color: var(--text-primary); }
.bar { height: 10px; background: var(--surface-high); border-radius: 5px; overflow: hidden; display: flex; margin-bottom: 12px; }
.barFill { height: 100%; transition: width 0.5s ease; }
.exact { background: var(--gold); }
.tendency { background: var(--primary); }
.barLegend { display: flex; gap: 16px; font-size: 12px; color: var(--text-secondary); }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; }
+74
View File
@@ -0,0 +1,74 @@
import { useState, useEffect } from 'react';
import { api, UserStats } from '../api/client';
import styles from './ProfilePage.module.css';
export default function ProfilePage() {
const [stats, setStats] = useState<UserStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.getMyStats().then(setStats).finally(() => setLoading(false));
}, []);
if (loading) return <div className={styles.loading}><div className={styles.spinner} /></div>;
if (!stats) return <div className={styles.empty}>Profil nicht verfügbar.</div>;
const evaluated = stats.exactCount + stats.tendencyCount + stats.wrongCount;
return (
<div className={styles.page}>
<div className={`card ${styles.heroCard}`}>
<div className={styles.avatar}>{stats.fullName.charAt(0).toUpperCase()}</div>
<div className={styles.heroInfo}>
<h1 className={`font-display ${styles.name}`}>{stats.fullName}</h1>
{stats.rank && (
<div className={styles.rankBadge}>🏆 Platz {stats.rank}</div>
)}
</div>
<div className={styles.heroPoints}>
<span className={`font-display ${styles.pointsVal}`}>{stats.totalPoints}</span>
<span className={styles.pointsLbl}>Punkte</span>
</div>
</div>
<div className={styles.statsGrid}>
<div className={`card ${styles.statCard}`}>
<span className={`font-display ${styles.statVal} text-gold`}>{stats.exactCount}</span>
<span className={styles.statLbl}>🎯 Exakt</span>
</div>
<div className={`card ${styles.statCard}`}>
<span className={`font-display ${styles.statVal} text-primary`}>{stats.tendencyCount}</span>
<span className={styles.statLbl}> Tendenz</span>
</div>
<div className={`card ${styles.statCard}`}>
<span className={`font-display ${styles.statVal}`} style={{ color: 'var(--error)' }}>{stats.wrongCount}</span>
<span className={styles.statLbl}> Falsch</span>
</div>
<div className={`card ${styles.statCard}`}>
<span className={`font-display ${styles.statVal}`}>{stats.tipsCount}</span>
<span className={styles.statLbl}>Tipps gesamt</span>
</div>
</div>
{evaluated > 0 && (
<div className={`card ${styles.accuracyCard}`}>
<div className={styles.accuracyHeader}>
<span className={styles.accuracyLabel}>Trefferquote</span>
<span className={`font-display ${styles.accuracyVal}`}>{stats.accuracy}%</span>
</div>
<div className={styles.bar}>
<div className={`${styles.barFill} ${styles.exact}`}
style={{ width: `${(stats.exactCount / evaluated) * 100}%` }} />
<div className={`${styles.barFill} ${styles.tendency}`}
style={{ width: `${(stats.tendencyCount / evaluated) * 100}%` }} />
</div>
<div className={styles.barLegend}>
<span><span className={styles.dot} style={{ background: 'var(--gold)' }} /> Exakt</span>
<span><span className={styles.dot} style={{ background: 'var(--primary)' }} /> Tendenz</span>
<span><span className={styles.dot} style={{ background: 'var(--surface-high)' }} /> Falsch</span>
</div>
</div>
)}
</div>
);
}