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
+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>
);
}