// app.jsx - MarioKart Liga - FINAL VERSION mit allen Fixes const { useState, useEffect } = React; // Lucide-style Info-Icon als SVG-Komponente const InfoIcon = ({ tooltip }) => { const [show, setShow] = useState(false); const [pos, setPos] = useState(null); const iconRef = React.useRef(null); React.useLayoutEffect(() => { if (!show || !iconRef.current) { setPos(null); return; } const rect = iconRef.current.getBoundingClientRect(); const margin = 8; const maxW = Math.min(260, window.innerWidth - 2 * margin); const iconCenter = rect.left + rect.width / 2; let left = iconCenter - maxW / 2; if (left < margin) left = margin; if (left + maxW > window.innerWidth - margin) left = window.innerWidth - margin - maxW; setPos({ left, top: rect.bottom + 4, maxW }); }, [show, tooltip]); return React.createElement('span', { ref: iconRef, style: { position: 'relative', display: 'inline-flex', alignItems: 'center', marginLeft: '2px', verticalAlign: 'middle' }, onClick: (e) => { e.stopPropagation(); setShow(s => !s); }, onMouseEnter: () => setShow(true), onMouseLeave: () => setShow(false), }, React.createElement('svg', { width: 13, height: 13, viewBox: '0 0 24 24', fill: 'none', stroke: '#9B8579', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', style: { cursor: 'help', display: 'block' } }, React.createElement('circle', { cx: 12, cy: 12, r: 10 }), React.createElement('line', { x1: 12, y1: 16, x2: 12, y2: 12 }), React.createElement('line', { x1: 12, y1: 8, x2: 12.01, y2: 8 }), ), show && pos && React.createElement('span', { style: { position: 'fixed', left: pos.left + 'px', top: pos.top + 'px', width: pos.maxW + 'px', background: '#2B2931', color: 'white', fontSize: '11px', padding: '6px 10px', borderRadius: '6px', whiteSpace: 'normal', lineHeight: 1.35, zIndex: 1000, fontWeight: '400', fontStyle: 'normal', pointerEvents: 'none', boxSizing: 'border-box', boxShadow: '0 2px 8px rgba(0,0,0,0.18)' } }, tooltip) ); }; const API_BASE = window.location.hostname === 'localhost' ? 'http://localhost:8888/mariokart-liga/api' : window.location.origin + window.location.pathname.replace(/\/[^/]*$/, '') + '/api'; const api = { token: localStorage.getItem('token'), setToken(token) { this.token = token; if (token) localStorage.setItem('token', token); else localStorage.removeItem('token'); }, async request(endpoint, options = {}) { const headers = { 'Content-Type': 'application/json', ...options.headers }; if (this.token) { headers['Authorization'] = `Bearer ${this.token}`; } // Token als Query-Parameter mitsenden (Fallback falls Header gefiltert wird) let url = `${API_BASE}${endpoint}`; if (this.token) { url += (url.includes('?') ? '&' : '?') + `token=${encodeURIComponent(this.token)}`; } try { const response = await fetch(url, { ...options, headers }); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Request failed'); return data; } catch (error) { console.error('API Error:', error); throw error; } }, async login(username, password) { const data = await this.request('/auth.php?action=login', { method: 'POST', body: JSON.stringify({ username, password }) }); this.setToken(data.token); return data; }, async logout() { try { await this.request('/auth.php?action=logout', { method: 'POST' }); } catch (e) { } this.setToken(null); }, async register(username, password, displayName, initials, email) { const data = { username, password, display_name: displayName, initials: initials || displayName.substring(0, 2).toUpperCase() }; if (email) data.email = email; return this.request('/auth.php?action=register', { method: 'POST', body: JSON.stringify(data) }); }, async getCurrentUser() { return this.request('/auth.php?action=me'); }, _playersCache: null, _playersCacheTime: 0, async getPlayers(includeInactive = false) { if (!includeInactive && this._playersCache && Date.now() - this._playersCacheTime < 30000) { return this._playersCache; } const data = await this.request(`/players.php?include_inactive=${includeInactive}`); if (!includeInactive) { this._playersCache = data; this._playersCacheTime = Date.now(); } return data; }, invalidatePlayersCache() { this._playersCache = null; }, async updatePlayer(id, data) { const r = await this.request(`/players.php?id=${id}`, { method: 'PUT', body: JSON.stringify(data) }); this.invalidatePlayersCache(); return r; }, async deactivatePlayer(id) { const r = await this.request(`/players.php?id=${id}`, { method: 'DELETE' }); this.invalidatePlayersCache(); return r; }, async permanentDeletePlayer(id) { const r = await this.request(`/players.php?id=${id}&permanent=true`, { method: 'DELETE' }); this.invalidatePlayersCache(); return r; }, async getMatches(limit = 50, offset = 0) { return this.request(`/matches.php?limit=${limit}&offset=${offset}`); }, async createMatch(matchData) { const r = await this.request('/matches.php', { method: 'POST', body: JSON.stringify(matchData) }); this.invalidatePlayersCache(); return r; }, async deleteMatch(id) { return this.request(`/matches.php?id=${id}`, { method: 'DELETE' }); }, async getRanking() { return this.request('/leaderboard.php?action=ranking'); }, async getRecords() { return this.request('/leaderboard.php?action=records'); }, async getPlayerStats(playerId) { return this.request(`/leaderboard.php?action=player-stats&player_id=${playerId}`); }, async getGlobalStats() { return this.request('/leaderboard.php?action=stats'); }, async getSettings() { return this.request('/settings.php'); }, async updateSettings(data) { return this.request('/settings.php', { method: 'PUT', body: JSON.stringify(data) }); }, async changePassword(currentPassword, newPassword) { return this.request('/auth.php?action=change-password', { method: 'POST', body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }) }); }, async resetPassword(playerId, newPassword) { return this.request('/auth.php?action=reset-password', { method: 'POST', body: JSON.stringify({ player_id: playerId, new_password: newPassword }) }); }, async uploadAvatar(file, playerId = null) { const formData = new FormData(); formData.append('avatar', file); let url = `${API_BASE}/auth.php?action=upload-avatar`; if (playerId) url += `&player_id=${playerId}`; if (this.token) url += `&token=${encodeURIComponent(this.token)}`; const response = await fetch(url, { method: 'POST', body: formData }); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Upload failed'); return data; }, }; const s = { card: { background: 'white', borderRadius: '16px', padding: '24px', marginBottom: '20px', boxShadow: '0 2px 8px rgba(43,41,49,0.08)' }, btn: { padding: '12px 24px', borderRadius: '8px', border: 'none', fontSize: '16px', fontWeight: '600', cursor: 'pointer', fontFamily: 'Montserrat,sans-serif' }, btnPrimary: { background: '#2B2931', color: 'white' }, btnSecondary: { background: '#F5F0EB', color: '#58595B' }, btnDanger: { background: '#9B8579', color: 'white' }, input: { width: '100%', padding: '12px 16px', borderRadius: '8px', border: '2px solid #E8E0D8', fontSize: '16px', marginBottom: '12px', fontFamily: 'Mulish,sans-serif', boxSizing: 'border-box' }, title: { fontSize: '28px', fontWeight: '700', marginBottom: '24px', color: '#F5F0EB', fontFamily: 'Montserrat,sans-serif' } }; function ConfirmDialog({ title, message, onConfirm, onCancel, confirmLabel = 'Bestätigen', confirmStyle = s.btnDanger }) { return (

{title}

{message}

); } function Login({ onLoginSuccess }) { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const handleSubmit = async (e) => { e.preventDefault(); setError(''); setLoading(true); try { const data = await api.login(username, password); onLoginSuccess(data.user); } catch (err) { setError(err.message); } finally { setLoading(false); } }; return (
🏎️

MK-Rangliste

setUsername(e.target.value)} style={s.input} autoFocus /> setPassword(e.target.value)} style={s.input} /> {error &&
{error}
}
); } function Leaderboard() { const [ranking, setRanking] = useState([]); const [loading, setLoading] = useState(true); const [sortKey, setSortKey] = useState('elo_rating'); const [sortDir, setSortDir] = useState('desc'); useEffect(() => { (async () => { try { const data = await api.getRanking(); setRanking(data.ranking); } finally { setLoading(false); } })(); }, []); if (loading) return
Lade MK-Rangliste...
; const hasPoints = ranking.some(p => p.avg_points !== null); const handleSort = (key) => { if (sortKey === key) { setSortDir(d => d === 'desc' ? 'asc' : 'desc'); } else { setSortKey(key); setSortDir(key === 'username' ? 'asc' : 'desc'); } }; const eloSorted = [...ranking].sort((a, b) => b.elo_rating - a.elo_rating); const gapMap = {}; eloSorted.forEach((p, i) => { gapMap[p.id] = i === 0 ? null : p.elo_rating - eloSorted[i - 1].elo_rating; }); const sorted = [...ranking].sort((a, b) => { let va = sortKey === '_gap' ? gapMap[a.id] : a[sortKey]; let vb = sortKey === '_gap' ? gapMap[b.id] : b[sortKey]; if (va == null) va = -Infinity; if (vb == null) vb = -Infinity; if (typeof va === 'string') { const cmp = va.localeCompare(vb, 'de'); return sortDir === 'asc' ? cmp : -cmp; } return sortDir === 'asc' ? va - vb : vb - va; }); const arrow = (key) => sortKey === key ? (sortDir === 'desc' ? ' ▼' : ' ▲') : ''; const thBase = { padding: '10px 8px', textAlign: 'center', whiteSpace: 'nowrap', cursor: 'pointer', userSelect: 'none' }; const tdBase = { padding: '10px 8px', textAlign: 'center' }; const stickyBase = { position: 'sticky', zIndex: 2 }; const rowEven = '#F5F0EB'; const rowOdd = 'white'; const columns = [ { key: 'username', label: 'Spieler', align: 'left' }, { key: 'elo_rating', label: 'ELO' }, { key: '_gap', label: 'Abstand', title: 'ELO-Abstand zum nächsten Platz' }, { key: 'total_matches', label: 'Matches' }, { key: 'wins', label: '🥇', title: '1. Platz', style: { color: '#ffd700' } }, { key: 'second_places', label: '🥈', title: '2. Platz', info: 'Nur 4er-Matches', style: { color: '#c0c0c0' } }, { key: 'third_places', label: '🥉', title: '3. Platz', info: 'Nur 4er-Matches', style: { color: '#cd7f32' } }, { key: 'fourth_places', label: '🚨', title: 'Letzter Platz', info: 'Nur 4er-Matches' }, { key: 'win_rate', label: 'Siegrate' }, { key: 'last_place_rate', label: 'Verkackerrate', title: 'Nur 4er-Matches' }, ]; if (hasPoints) columns.push({ key: 'avg_points', label: '∅ Punkte' }); if (hasPoints) columns.push({ key: 'avg_points_last10', label: '∅ Pkt (10)', info: 'Durchschnittliche Punkte der letzten 10 Matches — grün = aktuelle Tendenz besser als Gesamtschnitt, rot = schlechter' }); return (

MK-Rangliste

{ranking.length === 0 ? (
Noch keine Matches gespielt
) : (
{columns.slice(1).map(col => ( ))} {sorted.map((p, idx) => { const bg = idx % 2 === 0 ? rowOdd : rowEven; return ( navigate('player-stats/' + p.id)} style={{ cursor: 'pointer' }}> {hasPoints && } {hasPoints && (() => { const v = p.avg_points_last10; const overall = p.avg_points; let color; if (v != null && overall != null) { if (v > overall) color = '#28a745'; else if (v < overall) color = '#c0392b'; } return ; })()} ); })}
# handleSort('username')} title="Spieler" style={{ ...thBase, textAlign: 'left', ...stickyBase, left: '40px', minWidth: '160px', background: 'white', zIndex: 3, borderBottom: '2px solid #E8E0D8', boxShadow: '2px 0 4px rgba(0,0,0,0.06)' }}>Spieler{arrow('username')} handleSort(col.key)} title={col.title || col.label} style={{ ...thBase, textAlign: col.align || 'center', ...(col.style || {}), borderBottom: '2px solid #E8E0D8' }}> {col.label}{col.info && }{arrow(col.key)}
{p.rank <= 3 ? ['🥇','🥈','🥉'][p.rank - 1] : p.rank}
{p.profile_picture ? ( ) : (
{p.initials || p.username.substring(0, 2).toUpperCase()}
)}
{p.username}
{p.display_name}
{p.elo_rating} {gapMap[p.id] == null ? '—' : gapMap[p.id]} {p.total_matches} {p.wins} {p.second_places} {p.third_places} {p.fourth_places} {Math.round(p.win_rate)}% 30 ? '#c0392b' : undefined, background: bg, borderBottom: '1px solid #EDE8E3' }}>{Math.round(p.last_place_rate)}%{p.avg_points != null ? Number(p.avg_points).toFixed(1) : '—'}{v != null ? Number(v).toFixed(1) : '—'}
)}
); } function Records() { const [records, setRecords] = useState(null); const [loading, setLoading] = useState(true); const [expandedRecord, setExpandedRecord] = useState(null); useEffect(() => { (async () => { try { const data = await api.getRecords(); setRecords(data.records); } finally { setLoading(false); } })(); }, []); if (loading) return
Lade Rekorde...
; if (!records) return null; const formatDate = (d) => d ? new Date(d).toLocaleDateString('de-DE') : ''; const formatDateRange = (from, to) => { if (!from) return ''; const f = formatDate(from); const t = formatDate(to); return f === t ? f : `${f} – ${t}`; }; const RecordCard = ({ icon, title, value, sub, date, expandKey, participants }) => (
setExpandedRecord(expandedRecord === expandKey ? null : expandKey) : undefined} style={{ background: '#F5F0EB', borderRadius: '12px', padding: '16px', display: 'flex', alignItems: 'flex-start', gap: '12px', flexWrap: 'wrap', cursor: participants ? 'pointer' : 'default' }}>
{icon}
{title}
{value}
{sub &&
{sub}
} {date &&
{date}
} {expandedRecord === expandKey && participants && participants.length > 0 && (
{participants.map((p, i) => (
{p.position}. {p.display_name} ({p.elo_before}) = 0 ? '#28a745' : '#c0392b' }}>{p.elo_change >= 0 ? '+' : ''}{p.elo_change}
))}
)}
); return (

Rekorde & Statistiken

{records.longest_win_streak && records.longest_win_streak.streak > 0 && ( )} {records.longest_last_streak && records.longest_last_streak.streak > 0 && ( )} {records.most_matches && ( )} {records.most_wins && ( )} {records.most_last_places && ( )} {records.most_frequent_duel && ( )} {records.biggest_elo_gain && ( )} {records.biggest_elo_loss && ( )} {records.highest_elo_ever && ( )} {records.lowest_elo_ever && ( )} {records.highest_win_rate && ( )} {records.highest_last_rate && ( )} {records.best_avg_position && ( )}
{records.match_stats && records.match_stats.total > 0 && (
Gespielte Matches
🎮
Gesamt
{records.match_stats.total}
Matches gespielt
{records.match_stats.matches_2p > 0 && (
🎮
🎮
2er Matches
{records.match_stats.matches_2p}
{Math.round(records.match_stats.matches_2p / records.match_stats.total * 100)}% aller Matches
)} {records.match_stats.matches_3p > 0 && (
🎮
🎮
🎮
3er Matches
{records.match_stats.matches_3p}
{Math.round(records.match_stats.matches_3p / records.match_stats.total * 100)}% aller Matches
)} {records.match_stats.matches_4p > 0 && (
🎮
🎮
🎮
🎮
4er Matches
{records.match_stats.matches_4p}
{Math.round(records.match_stats.matches_4p / records.match_stats.total * 100)}% aller Matches
)}
)} {records.most_active && Object.keys(records.most_active).length > 0 && (
Aktivste Spieler
{Object.entries(records.most_active).map(([key, data]) => (
{data.label}
{data.player} ({data.count} Matches)
))}
)} {records.current_streaks && records.current_streaks.length > 0 && (
Aktuelle Serien
{records.current_streaks.map((streak, i) => (
{streak.type === 'win' ? '🔥' : '💩'} {streak.player}: {streak.streak}x {streak.type === 'win' ? 'Sieg' : 'Letzter'} in Folge {streak.date_from && ({formatDateRange(streak.date_from, streak.date_to)})}
))}
)}
); } function AddMatch({ onMatchAdded }) { const [players, setPlayers] = useState([]); const [matchDate, setMatchDate] = useState(() => { const now = new Date(); now.setMinutes(now.getMinutes() - now.getTimezoneOffset()); return now.toISOString().slice(0, 16); }); const [selectedPlayers, setSelectedPlayers] = useState([null, null, null, null]); const [playerPoints, setPlayerPoints] = useState(['', '', '', '']); const [notes, setNotes] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); useEffect(() => { (async () => { try { const data = await api.getPlayers(); setPlayers(data.players); } catch (err) { console.error(err); } })(); }, []); const handleSubmit = async (e) => { e.preventDefault(); setError(''); setLoading(true); const results = selectedPlayers.map((playerId, index) => { if (!playerId) return null; const r = { player_id: playerId, position: index + 1 }; if (playerPoints[index] !== '') { r.points = parseInt(playerPoints[index]); } return r; }).filter(Boolean); if (results.length === 0) { setError('Bitte wähle mindestens einen Spieler aus'); setLoading(false); return; } // Plausibilitätsprüfung: bessere Platzierung muss >= Punkte haben const resultsWithPoints = results.filter(r => r.points !== undefined); if (resultsWithPoints.length >= 2) { for (let i = 0; i < resultsWithPoints.length; i++) { for (let j = i + 1; j < resultsWithPoints.length; j++) { const a = resultsWithPoints[i]; const b = resultsWithPoints[j]; if (a.position < b.position && a.points < b.points) { setError(`Platz ${a.position} darf nicht weniger Punkte haben als Platz ${b.position}`); setLoading(false); return; } if (b.position < a.position && b.points < a.points) { setError(`Platz ${b.position} darf nicht weniger Punkte haben als Platz ${a.position}`); setLoading(false); return; } } } } try { await api.createMatch({ match_date: matchDate, results, notes: notes || null }); setSelectedPlayers([null, null, null, null]); setPlayerPoints(['', '', '', '']); setNotes(''); onMatchAdded(); } catch (err) { setError(err.message); } finally { setLoading(false); } }; return (

Match eintragen

setMatchDate(e.target.value)} style={{ ...s.input, maxWidth: '100%', width: '100%', boxSizing: 'border-box' }} /> {[0, 1, 2, 3].map((index) => (
{index + 1}.
{ const newPoints = [...playerPoints]; newPoints[index] = e.target.value; setPlayerPoints(newPoints); }} style={{ ...s.input, marginBottom: 0, width: '64px', flexShrink: 0, textAlign: 'center' }} min="0" />
))}