// 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}
Abbrechen
{confirmLabel}
);
}
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 (
);
}
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
) : (
#
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')}
{columns.slice(1).map(col => (
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)}
))}
{sorted.map((p, idx) => {
const bg = idx % 2 === 0 ? rowOdd : rowEven;
return (
navigate('player-stats/' + p.id)} style={{ cursor: 'pointer' }}>
{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)}%
{hasPoints && {p.avg_points != null ? Number(p.avg_points).toFixed(1) : '—'} }
{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 {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 (
);
}
function MatchHistory({ user }) {
const [matches, setMatches] = useState([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
const [expandedNews, setExpandedNews] = useState({});
const perPage = 10;
useEffect(() => { loadMatches(); }, [page]);
const loadMatches = async () => {
setLoading(true);
try {
const data = await api.getMatches(perPage, page * perPage);
setMatches(data.matches);
setTotal(data.total);
} finally {
setLoading(false);
}
};
const totalPages = Math.ceil(total / perPage);
const handleDelete = async (matchId) => {
if (!confirm('Match wirklich löschen?')) return;
try {
await api.deleteMatch(matchId);
loadMatches();
} catch (err) {
alert('Fehler: ' + err.message);
}
};
if (loading && matches.length === 0) return Lade Matches...
;
return (
Match-Historie
{matches.length === 0 ? (
Noch keine Matches eingetragen
) : (
matches.map((match) => (
{new Date(match.match_date).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
von {match.created_by_name}
{user && (
handleDelete(match.id)} style={{ ...s.btn, ...s.btnDanger, padding: '8px 16px', fontSize: '14px' }}>
Löschen
)}
{match.results.map((r, idx) => {
const clickable = r.player_id && r.player_name !== '[Gelöscht]';
return (
navigate('player-stats/' + r.player_id) : undefined}
style={{ display: 'flex', alignItems: 'center', gap: '10px', padding: '12px', background: idx === 0 ? '#fff3cd' : '#F5F0EB', borderRadius: '8px', cursor: clickable ? 'pointer' : 'default' }}>
{r.position}.
{r.player_picture ? (
) : (
{r.player_initials || r.player_name.substring(0, 2).toUpperCase()}
)}
{r.player_username || r.player_name}
{r.player_name}
{r.elo_before} → {r.elo_after}
{r.points != null && • {r.points} Pkt }
= 0 ? '#28a745' : '#dc3545', flexShrink: 0 }}>
{r.elo_change >= 0 ? '+' : ''}{r.elo_change}
);
})}
{match.notes &&
📝 {match.notes}
}
{match.news && match.news.length > 0 && (() => {
const topNews = match.news.slice(0, 5);
const extraNews = match.news.slice(5);
const isExpanded = expandedNews[match.id];
const renderItem = (item, i) => (
$1') }} />
);
return (
📰 News
{topNews.map(renderItem)}
{extraNews.length > 0 && !isExpanded && (
setExpandedNews(prev => ({ ...prev, [match.id]: true }))}
style={{ marginTop: '4px', fontSize: '11px', color: '#9B8579', cursor: 'pointer', userSelect: 'none' }}>
▸ {extraNews.length} weitere News anzeigen
)}
{isExpanded && extraNews.map((item, i) => renderItem(item, i + 5))}
{isExpanded && (
setExpandedNews(prev => ({ ...prev, [match.id]: false }))}
style={{ marginTop: '4px', fontSize: '11px', color: '#9B8579', cursor: 'pointer', userSelect: 'none' }}>
▾ Weniger anzeigen
)}
);
})()}
))
)}
{totalPages > 1 && (
setPage(0)} disabled={page === 0} style={{ ...s.btn, ...s.btnSecondary, padding: '8px 12px', fontSize: '14px', opacity: page === 0 ? 0.4 : 1 }}>«
setPage(p => p - 1)} disabled={page === 0} style={{ ...s.btn, ...s.btnSecondary, padding: '8px 14px', fontSize: '14px', opacity: page === 0 ? 0.4 : 1 }}>‹
{Array.from({ length: totalPages }, (_, i) => i)
.filter(i => i === 0 || i === totalPages - 1 || Math.abs(i - page) <= 1)
.reduce((acc, i, idx, arr) => {
if (idx > 0 && i - arr[idx - 1] > 1) acc.push('...');
acc.push(i);
return acc;
}, [])
.map((item, idx) =>
item === '...' ? (
...
) : (
setPage(item)} style={{ ...s.btn, padding: '8px 14px', fontSize: '14px', fontWeight: item === page ? '700' : '500', background: item === page ? '#2B2931' : '#F5F0EB', color: item === page ? 'white' : '#58595B' }}>{item + 1}
)
)
}
setPage(p => p + 1)} disabled={page >= totalPages - 1} style={{ ...s.btn, ...s.btnSecondary, padding: '8px 14px', fontSize: '14px', opacity: page >= totalPages - 1 ? 0.4 : 1 }}>›
setPage(totalPages - 1)} disabled={page >= totalPages - 1} style={{ ...s.btn, ...s.btnSecondary, padding: '8px 12px', fontSize: '14px', opacity: page >= totalPages - 1 ? 0.4 : 1 }}>»
)}
);
}
function PlayersManagement() {
const [players, setPlayers] = useState([]);
const [loading, setLoading] = useState(true);
const [showAddForm, setShowAddForm] = useState(false);
const [showInactive, setShowInactive] = useState(false);
const [newPlayer, setNewPlayer] = useState({ username: '', password: '', display_name: '', initials: '', email: '' });
const [error, setError] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState(null);
const [permDeleteConfirm, setPermDeleteConfirm] = useState(null);
useEffect(() => { loadPlayers(); }, []);
const loadPlayers = async () => {
try {
const data = await api.getPlayers(true);
setPlayers(data.players);
} finally {
setLoading(false);
}
};
const handleAddPlayer = async (e) => {
e.preventDefault();
setError('');
try {
await api.register(newPlayer.username, newPlayer.password, newPlayer.display_name, newPlayer.initials, newPlayer.email);
setNewPlayer({ username: '', password: '', display_name: '', initials: '', email: '' });
setShowAddForm(false);
loadPlayers();
} catch (err) {
setError(err.message);
}
};
const handleToggleActive = async (player) => {
try {
await api.updatePlayer(player.id, { is_active: !player.is_active });
loadPlayers();
} catch (err) {
alert('Fehler: ' + err.message);
}
};
const handleDeleteClick = (player) => {
setDeleteConfirm({ step: 1, player });
};
const handleDeleteConfirmStep = async () => {
if (deleteConfirm.step === 1) {
setDeleteConfirm({ step: 2, player: deleteConfirm.player });
} else {
try {
await api.deactivatePlayer(deleteConfirm.player.id);
setDeleteConfirm(null);
loadPlayers();
} catch (err) {
alert('Fehler: ' + err.message);
setDeleteConfirm(null);
}
}
};
const handlePermDeleteClick = (player) => {
setPermDeleteConfirm({ step: 1, player });
};
const handlePermDeleteConfirmStep = async () => {
if (permDeleteConfirm.step === 1) {
setPermDeleteConfirm({ step: 2, player: permDeleteConfirm.player });
} else {
try {
await api.permanentDeletePlayer(permDeleteConfirm.player.id);
setPermDeleteConfirm(null);
loadPlayers();
} catch (err) {
alert('Fehler: ' + err.message);
setPermDeleteConfirm(null);
}
}
};
if (loading) return
Lade Spieler...
;
const activePlayers = players.filter(p => p.is_active);
const inactivePlayers = players.filter(p => !p.is_active);
const renderPlayerRow = (p) => (
{p.profile_picture ? (
) : (
{p.initials || p.display_name.substring(0, 2).toUpperCase()}
)}
{p.username}
{p.role === 'admin' && Admin }
{p.display_name} • ELO: {p.elo_rating}
navigate('player-edit/' + p.id)} style={{ ...s.btn, ...s.btnSecondary, padding: '8px 12px', fontSize: '13px' }}>
Bearbeiten
{p.is_active ? (
handleDeleteClick(p)} style={{ ...s.btn, ...s.btnDanger, padding: '8px 12px', fontSize: '13px' }}>
Deaktivieren
) : (
<>
handleToggleActive(p)} style={{ ...s.btn, ...s.btnPrimary, padding: '8px 12px', fontSize: '13px' }}>
Aktivieren
handlePermDeleteClick(p)} style={{ ...s.btn, padding: '8px 12px', fontSize: '13px', background: '#c0392b', color: 'white' }}>
Löschen
>
)}
);
return (
{deleteConfirm && deleteConfirm.step === 1 && (
setDeleteConfirm(null)}
/>
)}
{deleteConfirm && deleteConfirm.step === 2 && (
setDeleteConfirm(null)}
/>
)}
{permDeleteConfirm && permDeleteConfirm.step === 1 && (
setPermDeleteConfirm(null)}
/>
)}
{permDeleteConfirm && permDeleteConfirm.step === 2 && (
setPermDeleteConfirm(null)}
/>
)}
Spielerverwaltung
setShowAddForm(!showAddForm)} style={{ ...s.btn, ...s.btnPrimary, fontSize: '14px', padding: '10px 18px' }}>
{showAddForm ? 'Abbrechen' : '+ Spieler hinzufügen'}
{showAddForm && (
)}
{/* Tabs */}
setShowInactive(false)} style={{ ...s.btn, padding: '10px 24px', fontSize: '14px', borderRadius: 0, background: !showInactive ? 'white' : 'transparent', color: !showInactive ? '#2B2931' : 'rgba(255,255,255,0.6)', border: 'none' }}>
Aktiv ({activePlayers.length})
setShowInactive(true)} style={{ ...s.btn, padding: '10px 24px', fontSize: '14px', borderRadius: 0, background: showInactive ? 'white' : 'transparent', color: showInactive ? '#2B2931' : 'rgba(255,255,255,0.6)', border: 'none' }}>
Inaktiv ({inactivePlayers.length})
{!showInactive ? (
activePlayers.length === 0
?
Keine aktiven Spieler
: activePlayers.map(renderPlayerRow)
) : (
inactivePlayers.length === 0
?
Keine inaktiven Spieler
: inactivePlayers.map(renderPlayerRow)
)}
);
}
function PlayerEdit({ playerId }) {
const [player, setPlayer] = useState(null);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState({});
const [password, setPassword] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [avatarLoading, setAvatarLoading] = useState(false);
const fileInputRef = React.useRef(null);
const loadPlayer = async () => {
try {
const data = await api.getPlayers(true);
const p = data.players.find(pl => pl.id === playerId);
if (!p) { setError('Spieler nicht gefunden'); return; }
setPlayer(p);
setForm({ username: p.username, email: p.email || '', display_name: p.display_name, initials: p.initials || '', role: p.role });
} finally {
setLoading(false);
}
};
useEffect(() => { loadPlayer(); }, [playerId]);
const handleSave = async (e) => {
e.preventDefault();
setError('');
setSuccess('');
setSaving(true);
const updates = {};
if (form.username !== player.username) updates.username = form.username;
if (form.email !== (player.email || '')) updates.email = form.email || null;
if (form.display_name !== player.display_name) updates.display_name = form.display_name;
if (form.initials !== (player.initials || '')) updates.initials = form.initials;
if (form.role !== player.role) updates.role = form.role;
if (password.length > 0) {
if (password.length < 6) {
setError('Passwort muss mindestens 6 Zeichen lang sein');
setSaving(false);
return;
}
updates.password = password;
}
if (Object.keys(updates).length === 0) {
setError('Keine Änderungen');
setSaving(false);
return;
}
try {
await api.updatePlayer(playerId, updates);
setPassword('');
setSuccess('Gespeichert');
await loadPlayer();
setTimeout(() => setSuccess(''), 3000);
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleAvatarChange = async (e) => {
const file = e.target.files[0];
if (!file) return;
setAvatarLoading(true);
setError('');
try {
await api.uploadAvatar(file, playerId);
await loadPlayer();
} catch (err) {
setError(err.message);
} finally {
setAvatarLoading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
if (loading) return
Lade Spieler...
;
if (!player) return
{error || 'Spieler nicht gefunden'}
;
const avatarUrl = player.profile_picture ? `${API_BASE}/../${player.profile_picture}` : null;
return (
navigate('players')} style={{ ...s.btn, ...s.btnSecondary, padding: '8px 16px', fontSize: '14px' }}>
← Zurück zur Übersicht
);
}
function AccountSettings({ user, onUserUpdate }) {
const [form, setForm] = useState({ username: user.username, email: user.email || '', display_name: user.display_name, initials: user.initials || '' });
const [profileSaving, setProfileSaving] = useState(false);
const [profileError, setProfileError] = useState('');
const [profileSuccess, setProfileSuccess] = useState('');
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [pwLoading, setPwLoading] = useState(false);
const [pwError, setPwError] = useState('');
const [pwSuccess, setPwSuccess] = useState('');
const [avatarLoading, setAvatarLoading] = useState(false);
const fileInputRef = React.useRef(null);
// Sync form wenn user-Objekt sich ändert (z.B. nach Avatar-Upload)
useEffect(() => {
setForm({ username: user.username, email: user.email || '', display_name: user.display_name, initials: user.initials || '' });
}, [user.id, user.username, user.email, user.display_name, user.initials]);
const handleProfileSave = async (e) => {
e.preventDefault();
setProfileError('');
setProfileSuccess('');
const updates = {};
if (form.username !== user.username) updates.username = form.username;
if (form.email !== (user.email || '')) updates.email = form.email || null;
if (form.display_name !== user.display_name) updates.display_name = form.display_name;
if (form.initials !== (user.initials || '')) updates.initials = form.initials;
if (Object.keys(updates).length === 0) {
setProfileError('Keine Änderungen');
return;
}
setProfileSaving(true);
try {
await api.updatePlayer(user.id, updates);
const refreshed = await api.getCurrentUser();
onUserUpdate(refreshed.user);
setProfileSuccess('Profil gespeichert');
setTimeout(() => setProfileSuccess(''), 3000);
} catch (err) {
setProfileError(err.message);
} finally {
setProfileSaving(false);
}
};
const handlePasswordChange = async (e) => {
e.preventDefault();
setPwError('');
setPwSuccess('');
if (newPassword.length < 6) {
setPwError('Neues Passwort muss mindestens 6 Zeichen lang sein');
return;
}
if (newPassword !== confirmPassword) {
setPwError('Passwörter stimmen nicht überein');
return;
}
setPwLoading(true);
try {
await api.changePassword(currentPassword, newPassword);
setPwSuccess('Passwort erfolgreich geändert');
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} catch (err) {
setPwError(err.message);
} finally {
setPwLoading(false);
}
};
const handleAvatarChange = async (e) => {
const file = e.target.files[0];
if (!file) return;
setAvatarLoading(true);
try {
const data = await api.uploadAvatar(file);
onUserUpdate({ ...user, profile_picture: data.profile_picture });
} catch (err) {
setProfileError(err.message);
} finally {
setAvatarLoading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const avatarUrl = user.profile_picture ? `${API_BASE}/../${user.profile_picture}` : null;
return (
Konto
{/* Profil-Card */}
{/* Passwort-Card */}
);
}
function PlayerStats({ playerId }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [expandedGain, setExpandedGain] = useState(false);
const [expandedLoss, setExpandedLoss] = useState(false);
const chartScrollRef = React.useRef(null);
const [tipIdx, setTipIdx] = useState(null);
const [showElo, setShowElo] = useState(() => {
const v = (typeof localStorage !== 'undefined') ? localStorage.getItem('playerStats.showElo') : null;
return v === null ? true : v === '1';
});
const [showPoints, setShowPoints] = useState(() => {
const v = (typeof localStorage !== 'undefined') ? localStorage.getItem('playerStats.showPoints') : null;
return v === null ? true : v === '1';
});
const [showAvg10, setShowAvg10] = useState(() => {
const v = (typeof localStorage !== 'undefined') ? localStorage.getItem('playerStats.showAvg10') : null;
return v === null ? true : v === '1';
});
const [showAvg20, setShowAvg20] = useState(() => {
const v = (typeof localStorage !== 'undefined') ? localStorage.getItem('playerStats.showAvg20') : null;
return v === null ? false : v === '1';
});
const [visibleCount, setVisibleCount] = useState(() => {
const v = (typeof localStorage !== 'undefined') ? localStorage.getItem('playerStats.visibleCount') : null;
const parsed = v !== null ? parseInt(v, 10) : NaN;
return isNaN(parsed) ? 50 : Math.max(10, parsed);
});
const pendingScrollCenter = React.useRef(null);
useEffect(() => { try { localStorage.setItem('playerStats.showElo', showElo ? '1' : '0'); } catch (e) {} }, [showElo]);
useEffect(() => { try { localStorage.setItem('playerStats.showPoints', showPoints ? '1' : '0'); } catch (e) {} }, [showPoints]);
useEffect(() => { try { localStorage.setItem('playerStats.showAvg10', showAvg10 ? '1' : '0'); } catch (e) {} }, [showAvg10]);
useEffect(() => { try { localStorage.setItem('playerStats.showAvg20', showAvg20 ? '1' : '0'); } catch (e) {} }, [showAvg20]);
useEffect(() => { try { localStorage.setItem('playerStats.visibleCount', String(visibleCount)); } catch (e) {} }, [visibleCount]);
React.useLayoutEffect(() => {
if (pendingScrollCenter.current !== null && chartScrollRef.current) {
const el = chartScrollRef.current;
el.scrollLeft = pendingScrollCenter.current * el.scrollWidth - el.clientWidth / 2;
pendingScrollCenter.current = null;
}
});
const handleZoomChange = (newVisibleCount) => {
if (chartScrollRef.current) {
const el = chartScrollRef.current;
if (el.scrollWidth > 0) {
pendingScrollCenter.current = (el.scrollLeft + el.clientWidth / 2) / el.scrollWidth;
}
}
setVisibleCount(newVisibleCount);
};
useEffect(() => {
(async () => {
try {
const result = await api.getPlayerStats(playerId);
setData(result);
} finally {
setLoading(false);
}
})();
}, [playerId]);
useEffect(() => {
if (chartScrollRef.current) chartScrollRef.current.scrollLeft = chartScrollRef.current.scrollWidth;
}, [data]);
if (loading) return
Lade Spielerprofil...
;
if (!data) return
Spieler nicht gefunden
;
const { player, head_to_head, lieblingsgegner_absolut, lieblingsgegner_relativ, nemesis_absolut, nemesis_relativ, elo_history, recent_matches } = data;
const positionEmoji = (pos, size) => {
if (pos === 1) return '🥇';
if (pos === 2) return '🥈';
if (pos === 3) return '🥉';
return pos === size ? '🚨' : pos + '.';
};
const statBox = (label, value, icon, accent) => (
);
const avatarEl = (pic, initials, size = 80) => pic ? (
) : (
{initials || '??'}
);
return (
navigate('leaderboard')} style={{ ...s.btn, ...s.btnSecondary, marginBottom: '16px', fontSize: '14px' }}>
← Zurück zur Rangliste
{/* Header Card */}
{avatarEl(player.profile_picture, player.initials, 80)}
{player.display_name}
@{player.username}
{player.elo_rating}
ELO
{player.current_streak.count >= 2 && (
{player.current_streak.type === 'win' ? '🔥' : '💀'} {player.current_streak.count}er {player.current_streak.type === 'win' ? 'Siegesserie' : 'Verlierer-Serie'}
)}
{/* ELO-Verlauf (mit Punkte-Linie als Overlay) */}
{elo_history.length > 0 && (() => {
const POINTS_COLOR = '#dc3545'; // Punkte-Verlauf – mittleres Rot
const AVG10_COLOR = '#ff6b6b'; // Ø 10 Spiele – helles Rot
const AVG20_COLOR = '#8b0000'; // Ø 20 Spiele – dunkles Rot
const elos = elo_history.map(e => parseInt(e.elo_rating));
const pointsVals = elo_history.map(e =>
e.points !== null && e.points !== undefined ? parseInt(e.points) : null
);
const hasPoints = pointsVals.some(p => p !== null);
const min = Math.min(...elos) - 20;
const max = Math.max(...elos) + 20;
const range = max - min || 1;
const pointsMin = 20;
const pointsMax = 60;
const pointsRange = pointsMax - pointsMin;
const n = elo_history.length;
const maxVisible = Math.max(n, 10);
const minVisible = 10;
const effectiveVisible = Math.min(Math.max(visibleCount, minVisible), maxVisible);
const widthPct = Math.max(100, (n / effectiveVisible) * 100);
const zoomable = n > minVisible;
const gridStep = range <= 100 ? 25 : range <= 200 ? 50 : 100;
const firstLine = Math.ceil(min / gridStep) * gridStep;
const lines = [];
for (let v = firstLine; v <= max; v += gridStep) lines.push(v);
const pointsLines = [20, 30, 40, 50, 60];
const pointsY = (v) => ((Math.min(pointsMax, Math.max(pointsMin, v)) - pointsMin) / pointsRange) * 100;
const xCenter = (i) => ((i + 0.5) / n) * 100;
// Werte-Reihe (mit Lücken bei null) in zusammenhängende Linien-Segmente zerlegen
const buildSegments = (vals) => {
const segs = [];
let cur = [];
vals.forEach((p, i) => {
if (p === null) {
if (cur.length) { segs.push(cur); cur = []; }
} else {
cur.push({ i, y: 100 - pointsY(p) });
}
});
if (cur.length) segs.push(cur);
return segs;
};
const pointsSegments = buildSegments(pointsVals);
// Gleitender Durchschnitt über die letzten N Spiele (nur Matches mit gesetzten Punkten zählen)
const rollingAvg = (window) => pointsVals.map((_, i) => {
const start = Math.max(0, i - window + 1);
const slice = pointsVals.slice(start, i + 1).filter(p => p !== null);
if (slice.length < 3) return null;
return slice.reduce((a, b) => a + b, 0) / slice.length;
});
const rollingAvg10 = rollingAvg(10);
const rollingAvg20 = rollingAvg(20);
const avg10Segments = buildSegments(rollingAvg10);
const avg20Segments = buildSegments(rollingAvg20);
const eloOn = showElo;
const pointsOn = hasPoints && showPoints;
const avg10On = hasPoints && showAvg10;
const avg20On = hasPoints && showAvg20;
const pointsAxisOn = pointsOn || avg10On || avg20On; // rechte Punkte-Achse/Skala aktiv?
const anyOn = eloOn || pointsAxisOn;
const leftPad = eloOn ? 40 : 0;
const rightPad = pointsAxisOn ? 40 : 0;
const useEloGrid = eloOn;
const usePointsGrid = !eloOn && pointsAxisOn;
return (
setShowElo(e.target.checked)}
style={{ width: '16px', height: '16px', cursor: 'pointer', accentColor: '#2B2931', margin: 0 }} />
ELO-Verlauf
{hasPoints && (
setShowPoints(e.target.checked)}
style={{ width: '16px', height: '16px', cursor: 'pointer', accentColor: POINTS_COLOR, margin: 0 }} />
Punkte-Verlauf
)}
{hasPoints && (
setShowAvg10(e.target.checked)}
style={{ width: '16px', height: '16px', cursor: 'pointer', accentColor: AVG10_COLOR, margin: 0 }} />
Ø Punkte (10)
)}
{hasPoints && (
setShowAvg20(e.target.checked)}
style={{ width: '16px', height: '16px', cursor: 'pointer', accentColor: AVG20_COLOR, margin: 0 }} />
Ø Punkte (20)
)}
{zoomable && (
Zoom
−
handleZoomChange(minVisible + maxVisible - parseInt(e.target.value, 10))}
style={{ flex: 1, accentColor: '#2B2931', cursor: 'pointer', minWidth: '120px' }}
aria-label="Chart-Zoom"
/>
+
{effectiveVisible} / {n}
)}
{eloOn && (
{lines.map(v => {
const pct = ((v - min) / range) * 100;
return (
{v}
);
})}
)}
{pointsAxisOn && (
{pointsLines.map(v => (
{v}
))}
)}
{useEloGrid && lines.map(v => {
const pct = ((v - min) / range) * 100;
return (
);
})}
{pointsAxisOn && pointsLines.filter(v => v > 0).map(v => (
))}
{eloOn && elos.map((elo, i) => (
))}
{pointsAxisOn && (
{pointsOn && pointsSegments.map((seg, idx) => (
seg.length >= 2 ? (
`${pt.i + 0.5},${pt.y}`).join(' ')} />
) : null
))}
{avg10On && avg10Segments.map((seg, idx) => (
seg.length >= 2 ? (
`${pt.i + 0.5},${pt.y}`).join(' ')} />
) : null
))}
{avg20On && avg20Segments.map((seg, idx) => (
seg.length >= 2 ? (
`${pt.i + 0.5},${pt.y}`).join(' ')} />
) : null
))}
{pointsOn && pointsVals.map((p, i) => p === null ? null : (
))}
)}
{anyOn && elos.map((_, i) => (
{ if (e.pointerType === 'mouse') setTipIdx(i); }}
onPointerLeave={(e) => { if (e.pointerType === 'mouse') setTipIdx(prev => prev === i ? null : prev); }}
onPointerDown={(e) => { if (e.pointerType !== 'mouse') setTipIdx(prev => prev === i ? null : i); }}
style={{ position: 'absolute', left: ((i / n) * 100) + '%', top: 0, bottom: 0, width: (100 / n) + '%', cursor: 'pointer', zIndex: 4 }} />
))}
{tipIdx !== null && anyOn && (() => {
const d = elo_history[tipIdx];
const dt = d.match_date ? new Date(d.match_date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }) : '';
const pVal = pointsVals[tipIdx];
const leftPct = xCenter(tipIdx);
const anchorRight = leftPct > 80;
const anchorLeft = leftPct < 20;
const tx = anchorLeft ? '0' : anchorRight ? '-100%' : '-50%';
return (
{dt}
{eloOn &&
ELO: {elos[tipIdx]}
}
{pointsOn && pVal !== null && (
Punkte: {pVal}
)}
{avg10On && rollingAvg10[tipIdx] !== null && (
Ø 10 Spiele: {rollingAvg10[tipIdx].toFixed(1)}
)}
{avg20On && rollingAvg20[tipIdx] !== null && (
Ø 20 Spiele: {rollingAvg20[tipIdx].toFixed(1)}
)}
);
})()}
{tipIdx !== null && anyOn && (
)}
{elo_history[0].match_date}
{elo_history[n - 1].match_date}
);
})()}
{/* Statistik-Grid */}
🎮
{player.total_matches}
Matches
{statBox('Siegrate', player.win_rate + '%', '📊')}
{statBox('Ø Platz', player.avg_position || '—', '📈')}
{statBox('Bestes ELO', player.highest_elo, '⭐')}
🚨
{player.last_places}
Letzte Plätze
{player.last_places_4p}
4er
{player.last_places_3p}
3er
{player.last_places_2p}
2er
{/* Stärkster Sieg & Niederlage */}
{(player.biggest_elo_gain || player.biggest_elo_loss) && (
{player.biggest_elo_gain && (
setExpandedGain(!expandedGain)} style={{ ...s.card, marginBottom: 0, borderLeft: '4px solid #28a745', cursor: 'pointer' }}>
Stärkster Sieg
📈
+{player.biggest_elo_gain.elo_change} ELO
{new Date(player.biggest_elo_gain.match_date).toLocaleDateString('de-DE')}
{expandedGain && player.biggest_elo_gain.participants && player.biggest_elo_gain.participants.length > 0 && (
{player.biggest_elo_gain.participants.map((p, i) => (
{p.position}. {p.display_name} ({p.elo_before})
= 0 ? '#28a745' : '#c0392b' }}>{p.elo_change >= 0 ? '+' : ''}{p.elo_change}
))}
)}
)}
{player.biggest_elo_loss && (
setExpandedLoss(!expandedLoss)} style={{ ...s.card, marginBottom: 0, borderLeft: '4px solid #c0392b', cursor: 'pointer' }}>
Überraschendste Niederlage
📉
{player.biggest_elo_loss.elo_change} ELO
{new Date(player.biggest_elo_loss.match_date).toLocaleDateString('de-DE')}
{expandedLoss && player.biggest_elo_loss.participants && player.biggest_elo_loss.participants.length > 0 && (
{player.biggest_elo_loss.participants.map((p, i) => (
{p.position}. {p.display_name} ({p.elo_before})
= 0 ? '#28a745' : '#c0392b' }}>{p.elo_change >= 0 ? '+' : ''}{p.elo_change}
))}
)}
)}
)}
{/* Lieblingsgegner & Nemesis */}
{(lieblingsgegner_absolut || lieblingsgegner_relativ || nemesis_absolut || nemesis_relativ) && (
{lieblingsgegner_absolut && (
navigate('player-stats/' + lieblingsgegner_absolut.opponent_id)} style={{ ...s.card, marginBottom: 0, borderLeft: '4px solid #28a745', cursor: 'pointer' }}>
Lieblingsgegner (absolut)
{avatarEl(lieblingsgegner_absolut.opponent_picture, lieblingsgegner_absolut.opponent_initials, 40)}
{lieblingsgegner_absolut.opponent_name}
{lieblingsgegner_absolut.my_wins} von {lieblingsgegner_absolut.matches_together} gewonnen
)}
{lieblingsgegner_relativ && (
navigate('player-stats/' + lieblingsgegner_relativ.opponent_id)} style={{ ...s.card, marginBottom: 0, borderLeft: '4px solid #28a745', cursor: 'pointer' }}>
Lieblingsgegner (relativ)
{avatarEl(lieblingsgegner_relativ.opponent_picture, lieblingsgegner_relativ.opponent_initials, 40)}
{lieblingsgegner_relativ.opponent_name}
{Math.round(lieblingsgegner_relativ.my_wins / lieblingsgegner_relativ.matches_together * 100)}% Siegrate ({lieblingsgegner_relativ.my_wins}/{lieblingsgegner_relativ.matches_together})
)}
{nemesis_absolut && (
navigate('player-stats/' + nemesis_absolut.opponent_id)} style={{ ...s.card, marginBottom: 0, borderLeft: '4px solid #c0392b', cursor: 'pointer' }}>
Nemesis (absolut)
{avatarEl(nemesis_absolut.opponent_picture, nemesis_absolut.opponent_initials, 40)}
{nemesis_absolut.opponent_name}
{nemesis_absolut.opp_wins} von {nemesis_absolut.matches_together} verloren
)}
{nemesis_relativ && (
navigate('player-stats/' + nemesis_relativ.opponent_id)} style={{ ...s.card, marginBottom: 0, borderLeft: '4px solid #c0392b', cursor: 'pointer' }}>
Nemesis (relativ)
{avatarEl(nemesis_relativ.opponent_picture, nemesis_relativ.opponent_initials, 40)}
{nemesis_relativ.opponent_name}
{Math.round(nemesis_relativ.opp_wins / nemesis_relativ.matches_together * 100)}% Niederlagenrate ({nemesis_relativ.opp_wins}/{nemesis_relativ.matches_together})
)}
)}
{/* Head-to-Head Tabelle */}
{head_to_head.length > 0 && (
Head-to-Head
{head_to_head.map(h => {
const total = h.matches_together;
const myPct = total > 0 ? (h.my_wins / total * 100) : 0;
const drawPct = total > 0 ? (h.draws / total * 100) : 0;
const oppPct = total > 0 ? (h.opp_wins / total * 100) : 0;
const streakIsMe = h.current_streak_holder === 'me';
const streakName = streakIsMe ? player.display_name : h.opponent_name;
const bestStreak = Math.max(h.best_streak_me || 0, h.best_streak_opp || 0);
const bestStreakName = (h.best_streak_me || 0) >= (h.best_streak_opp || 0) ? player.display_name : h.opponent_name;
return (
navigate('player-stats/' + h.opponent_id)} style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', overflow: 'hidden' }}>
{avatarEl(h.opponent_picture, h.opponent_initials, 32)}
{h.opponent_name}
{h.matches_together} Spiele
{h.current_streak > 1 && (
🔥 Streak: {streakName} ({h.current_streak}x)
{bestStreak > 1 && 📊 Rekord: {bestStreakName} ({bestStreak}x) }
)}
{h.current_streak <= 1 && bestStreak > 1 && (
📊 Längster Streak: {bestStreakName} ({bestStreak}x)
)}
);
})}
)}
{/* Letzte Matches */}
{recent_matches.length > 0 && (
Letzte Matches
{recent_matches.map(m => (
{positionEmoji(m.position, m.match_size)}
{m.match_date}
{m.participants.map((p, i) => (
{i > 0 ? ', ' : ''}{p.display_name} ({p.position}.)
))}
= 0 ? '#28a745' : '#c0392b', minWidth: '50px', textAlign: 'right' }}>
{m.elo_change >= 0 ? '+' : ''}{m.elo_change}
))}
)}
);
}
function SystemSettings() {
const [settings, setSettings] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
(async () => {
try {
const data = await api.getSettings();
setSettings(data.settings);
} finally {
setLoading(false);
}
})();
}, []);
const toggle = async (key) => {
const newVal = settings[key] === '1' ? false : true;
setSaving(true);
try {
const data = await api.updateSettings({ [key]: newVal });
setSettings(data.settings);
} finally {
setSaving(false);
}
};
const saveSetting = async (key, value) => {
setSaving(true);
try {
const data = await api.updateSettings({ [key]: value });
setSettings(data.settings);
} finally {
setSaving(false);
}
};
if (loading) return
Lade Einstellungen...
;
if (!settings) return
Einstellungen konnten nicht geladen werden.
;
const ToggleSwitch = ({ label, description, settingKey }) => {
const enabled = settings[settingKey] === '1';
return (
toggle(settingKey)} disabled={saving} style={{
width: '52px', height: '28px', borderRadius: '14px', border: 'none', cursor: saving ? 'wait' : 'pointer',
background: enabled ? '#28a745' : '#E8E0D8', position: 'relative', transition: 'background 0.2s', flexShrink: 0, marginLeft: '16px'
}}>
);
};
return (
Systemeinstellungen
Benachrichtigungen
Steuere, wohin Match-Ergebnisse automatisch gesendet werden.
ELO-System
Einstellungen für das ELO-Rating-System.
Start-ELO für neue Spieler
ELO-Punktzahl, mit der neue Spieler starten
setSettings({ ...settings, default_elo: e.target.value })}
onBlur={(e) => { const v = parseInt(e.target.value); if (v > 0) saveSetting('default_elo', v); }}
style={{ ...s.input, width: '80px', marginBottom: 0, textAlign: 'center', flexShrink: 0, marginLeft: '16px' }} min="0" />
);
}
const VALID_VIEWS = ['leaderboard', 'add-match', 'history', 'players', 'player-edit', 'player-stats', 'settings', 'account', 'login'];
const PUBLIC_VIEWS = ['leaderboard', 'history', 'player-stats', 'login'];
function getViewFromHash() {
const hash = window.location.hash.replace('#/', '').replace('#', '');
if (hash.startsWith('player-edit/')) return 'player-edit';
if (hash.startsWith('player-stats/')) return 'player-stats';
return VALID_VIEWS.includes(hash) ? hash : 'leaderboard';
}
function getPlayerIdFromHash() {
const hash = window.location.hash.replace('#/', '').replace('#', '');
const match = hash.match(/^player-edit\/(\d+)$/);
return match ? parseInt(match[1]) : null;
}
function getPlayerStatsIdFromHash() {
const hash = window.location.hash.replace('#/', '').replace('#', '');
const match = hash.match(/^player-stats\/(\d+)$/);
return match ? parseInt(match[1]) : null;
}
function navigate(view) {
window.location.hash = '#/' + view;
}
function App() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [currentView, setCurrentView] = useState(getViewFromHash);
const [menuOpen, setMenuOpen] = useState(false);
const [fontSize, setFontSize] = useState(() => parseInt(localStorage.getItem('mk-fontsize') || '14'));
// Hash-Änderungen lauschen (Browser vor/zurück)
const [hashKey, setHashKey] = useState(0);
useEffect(() => {
const onHashChange = () => {
setCurrentView(getViewFromHash());
setHashKey(k => k + 1);
};
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, []);
useEffect(() => {
(async () => {
if (!api.token) {
setLoading(false);
return;
}
try {
const [userData] = await Promise.all([
api.getCurrentUser(),
api.getPlayers() // Prefetch Spielerliste
]);
setUser(userData.user);
} catch (err) {
api.setToken(null);
} finally {
setLoading(false);
}
})();
}, []);
const editPlayerId = getPlayerIdFromHash();
const statsPlayerId = getPlayerStatsIdFromHash();
// Nicht-öffentliche Views ohne Login → zurück zu Leaderboard
useEffect(() => {
if (!loading && !user && !PUBLIC_VIEWS.includes(currentView)) {
navigate('leaderboard');
}
}, [currentView, user, loading]);
const handleLoginSuccess = (userData) => {
setUser(userData);
navigate('leaderboard');
};
const handleLogout = async () => {
try { await api.logout(); } catch (err) { }
setUser(null);
api.setToken(null);
navigate('leaderboard');
};
const changeFontSize = (delta) => {
setFontSize(prev => {
const next = Math.max(10, Math.min(20, prev + delta));
localStorage.setItem('mk-fontsize', next);
return next;
});
};
if (loading) return null;
const navBtn = (view, icon, label) => (
{ navigate(view); setMenuOpen(false); }} style={{
...s.btn,
padding: '10px 20px',
fontSize: '14px',
width: menuOpen ? '100%' : undefined,
textAlign: menuOpen ? 'left' : undefined,
background: (currentView === view || (view === 'players' && currentView === 'player-edit')) ? '#2B2931' : 'transparent',
color: (currentView === view || (view === 'players' && currentView === 'player-edit')) ? 'white' : '#58595B'
}}>
{icon} {label}
);
const burgerBar = (
setMenuOpen(!menuOpen)} style={{
...s.btn, background: 'transparent', padding: '8px', fontSize: '24px', lineHeight: '1', color: '#58595B'
}} aria-label="Menü">
{menuOpen ? '✕' : '☰'}
🏎️ MK-Rangliste
{user ? (
{ navigate('account'); setMenuOpen(false); }} style={{ cursor: 'pointer' }}>
{user.profile_picture ? (
) : (
{user.initials || user.display_name.substring(0, 2).toUpperCase()}
)}
) : (
{ navigate('login'); setMenuOpen(false); }} style={{ ...s.btn, ...s.btnPrimary, padding: '6px 14px', fontSize: '13px' }}>Anmelden
)}
);
return (
{burgerBar}
{navBtn('leaderboard', '🏆', 'MK-Rangliste')}
{navBtn('history', '📊', 'Historie')}
{user && navBtn('add-match', '➕', 'Match eintragen')}
{user && user.role === 'admin' && navBtn('players', '👥', 'Spieler')}
{user && user.role === 'admin' && navBtn('settings', '🔧', 'System')}
{user && navBtn('account', '⚙️', 'Konto')}
{user ? (
<>
{user.profile_picture ? (
) : (
{user.initials || user.display_name.substring(0, 2).toUpperCase()}
)}
{user.display_name}
{ handleLogout(); setMenuOpen(false); }} style={{ ...s.btn, ...s.btnSecondary, padding: '8px 16px', fontSize: '14px' }}>Abmelden
>
) : (
{ navigate('login'); setMenuOpen(false); }} style={{ ...s.btn, ...s.btnPrimary, padding: '8px 16px', fontSize: '14px' }}>Anmelden
)}
Schriftgr.
changeFontSize(-1)} style={{ ...s.btn, ...s.btnSecondary, padding: '6px 14px', fontSize: '14px', lineHeight: '1' }}>A-
{fontSize}
changeFontSize(1)} style={{ ...s.btn, ...s.btnSecondary, padding: '6px 14px', fontSize: '14px', lineHeight: '1' }}>A+
{currentView === 'leaderboard' && <>
>}
{currentView === 'login' && !user &&
}
{currentView === 'add-match' && user &&
navigate('history')} />}
{currentView === 'history' && }
{currentView === 'players' && user && user.role === 'admin' && }
{currentView === 'player-edit' && user && user.role === 'admin' && editPlayerId && }
{currentView === 'settings' && user && user.role === 'admin' && }
{currentView === 'player-stats' && statsPlayerId && }
{currentView === 'account' && user && }
);
}
ReactDOM.createRoot(document.getElementById('root')).render(
);