This commit is contained in:
2025-07-19 12:21:46 +02:00
parent 12822dfdbf
commit 2e7957d0a0
86 changed files with 25573 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,335 @@
import React, { useState } from 'react';
import './App.css';
import Header from './Header';
import UserMenu from './UserMenu';
import Login from './Login';
import ForgotPassword from './ForgotPassword';
import ResetPassword from './ResetPassword';
import Register from './Register';
import Sidebar from './Sidebar';
import MatchesSchedule from './MatchesSchedule';
import Results from './Results';
import Bracket from './Bracket';
import TeamRanking from './TeamRanking';
import Pools from './Pools';
import jsPDF from 'jspdf';
function getResetTokenFromUrl() {
const params = new URLSearchParams(window.location.search);
return params.get('token') || '';
}
function App() {
const [token, setToken] = useState(null);
const [userMenu, setUserMenu] = useState(null);
const [showForgot, setShowForgot] = useState(false);
const [showRegister, setShowRegister] = useState(false);
const [section, setSection] = useState('leaderboard');
const [rankingRefresh, setRankingRefresh] = useState(0);
const [tournamentName, setTournamentName] = useState(() => localStorage.getItem('tournamentName') || '');
const [showCreateTournament, setShowCreateTournament] = useState(false);
const [showLoadTournaments, setShowLoadTournaments] = useState(false);
const [tournaments, setTournaments] = useState([]);
const [loadingTournaments, setLoadingTournaments] = useState(false);
const [tournamentLocation, setTournamentLocation] = useState(() => localStorage.getItem('tournamentLocation') || '');
const [tournamentDate, setTournamentDate] = useState(() => localStorage.getItem('tournamentDate') || '');
const [startMsg, setStartMsg] = useState('');
const [starting, setStarting] = useState(false);
const [tournamentStarted, setTournamentStarted] = useState(false);
// Check if tournament has started (any round robin matches exist)
React.useEffect(() => {
fetch('/api/matches')
.then(res => res.ok ? res.json() : [])
.then(data => {
setTournamentStarted(data.some(m => m.stage?.type === 'ROUND_ROBIN'));
});
}, [startMsg]);
const handleUserMenuSelect = async (option) => {
if (option === 'logout') {
setToken(null);
setUserMenu(null);
} else if (option === 'create-tournament') {
setShowCreateTournament(true);
} else if (option === 'load-tournaments') {
setShowLoadTournaments(true);
setLoadingTournaments(true);
try {
const res = await fetch('/api/tournaments');
if (res.ok) {
const data = await res.json();
setTournaments(data);
} else {
console.error('Failed to load tournaments');
}
} catch (err) {
console.error('Error loading tournaments:', err);
}
setLoadingTournaments(false);
} else if (option === 'start-tournament') {
setStartMsg('');
setStarting(true);
try {
const res = await fetch('/api/admin/schedule/roundrobin', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }
});
if (res.ok) {
setStartMsg('Tournament started!');
} else {
const data = await res.json();
setStartMsg(data.error || 'Failed to start tournament');
}
} catch {
setStartMsg('Failed to start tournament');
}
setStarting(false);
} else {
setUserMenu(option);
}
};
// Auto-login after registration
const handleRegister = async (username, password) => {
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (res.ok && data.token) {
setToken(data.token);
setShowRegister(false);
}
} catch {
// ignore
}
};
// Handler for resetting the tournament
const handleResetTournament = async () => {
// 1. Fetch all relevant data
const [teams, matches, standings] = await Promise.all([
fetch('/api/teams').then(res => res.ok ? res.json() : []),
fetch('/api/matches').then(res => res.ok ? res.json() : []),
fetch('/api/standings').then(res => res.ok ? res.json() : [])
]);
// 2. Generate PDF
const doc = new jsPDF();
doc.setFontSize(18);
const dateStr = new Date().toLocaleString();
doc.text(tournamentName ? `Tournament: ${tournamentName}` : 'Tournament Summary', 14, 18);
doc.setFontSize(12);
doc.text(`Date: ${dateStr}`, 14, 26);
let y = 34;
doc.text('Teams:', 14, y);
y += 6;
teams.forEach(t => { doc.text(`- ${t.name}`, 18, y); y += 6; });
y += 4;
doc.text('Matches:', 14, y); y += 6;
matches.forEach(m => {
const t1 = m.team1?.name || 'TBD';
const t2 = m.team2?.name || 'TBD';
const score = m.result ? `${m.result.team1Score} - ${m.result.team2Score}` : '';
doc.text(`${t1} vs ${t2} ${score}`, 18, y); y += 6;
if (y > 270) { doc.addPage(); y = 18; }
});
y += 4;
doc.text('Final Rankings:', 14, y); y += 6;
standings.forEach((s, i) => { doc.text(`${i + 1}. ${s.name} (${s.points} pts)`, 18, y); y += 6; });
// 3. Download PDF
doc.save('tournament-summary.pdf');
// 4. Call backend to reset tournament
await fetch('/api/admin/reset-tournament', { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } });
window.location.reload();
};
// Show reset password form if token is in URL
const resetToken = getResetTokenFromUrl();
if (resetToken) {
return <ResetPassword token={resetToken} />;
}
// Show login/register/forgot only for user menu actions
let userMenuContent = null;
if (!token && (userMenu === 'profile' || userMenu === 'edit' || userMenu === 'password')) {
if (showRegister) {
userMenuContent = <Register onRegister={handleRegister} onBackToLogin={() => setShowRegister(false)} />;
} else if (showForgot) {
userMenuContent = <ForgotPassword onResetRequested={() => setShowForgot(false)} />;
} else {
userMenuContent = <Login onLogin={setToken} onForgotPassword={() => setShowForgot(true)} onShowRegister={() => setShowRegister(true)} />;
}
}
// Tournament creation form modal
const createTournamentModal = showCreateTournament && (
<div style={{
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.25)', zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center'
}}>
<form
onSubmit={async e => {
e.preventDefault();
try {
const res = await fetch('/api/tournaments', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: tournamentName,
date: tournamentDate,
location: tournamentLocation
})
});
if (res.ok) {
localStorage.setItem('tournamentName', tournamentName);
localStorage.setItem('tournamentDate', tournamentDate);
localStorage.setItem('tournamentLocation', tournamentLocation);
setShowCreateTournament(false);
} else {
const data = await res.json();
alert(data.error || 'Failed to create tournament');
}
} catch (err) {
alert('Failed to create tournament');
}
}}
style={{ background: '#fff', padding: 32, borderRadius: 12, minWidth: 320, boxShadow: '0 2px 16px rgba(0,0,0,0.12)' }}
>
<h2 style={{ marginBottom: 16 }}>Create Tournament</h2>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontWeight: 500 }}>Name</label>
<input type="text" value={tournamentName} onChange={e => { setTournamentName(e.target.value); }} required style={{ width: '100%', padding: 8, borderRadius: 6, border: '1px solid #ccc' }} />
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontWeight: 500 }}>Date</label>
<input type="date" value={tournamentDate} onChange={e => { setTournamentDate(e.target.value); }} required style={{ width: '100%', padding: 8, borderRadius: 6, border: '1px solid #ccc' }} />
</div>
<div style={{ marginBottom: 20 }}>
<label style={{ display: 'block', fontWeight: 500 }}>Location</label>
<input type="text" value={tournamentLocation} onChange={e => { setTournamentLocation(e.target.value); }} required style={{ width: '100%', padding: 8, borderRadius: 6, border: '1px solid #ccc' }} />
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
<button type="button" onClick={() => setShowCreateTournament(false)} style={{ background: '#e5e7eb', color: '#222', border: 'none', borderRadius: 6, padding: '8px 20px', fontWeight: 'bold', cursor: 'pointer' }}>Cancel</button>
<button type="submit" style={{ background: '#2563eb', color: '#fff', border: 'none', borderRadius: 6, padding: '8px 20px', fontWeight: 'bold', cursor: 'pointer' }}>Create</button>
</div>
</form>
</div>
);
// Load tournaments modal
const loadTournamentsModal = showLoadTournaments && (
<div style={{
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.25)', zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center'
}}>
<div style={{ background: '#fff', padding: 32, borderRadius: 12, minWidth: 500, maxHeight: '80vh', overflow: 'auto', boxShadow: '0 2px 16px rgba(0,0,0,0.12)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h2 style={{ margin: 0 }}>Available Tournaments</h2>
<button
onClick={() => setShowLoadTournaments(false)}
style={{ background: 'none', border: 'none', fontSize: '24px', cursor: 'pointer', color: '#666' }}
>
×
</button>
</div>
{loadingTournaments ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<div>Loading tournaments...</div>
</div>
) : tournaments.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#666' }}>
No tournaments available.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{tournaments.map(tournament => (
<div
key={tournament.id}
style={{
border: '1px solid #e5e7eb',
borderRadius: 8,
padding: 16,
cursor: 'pointer',
transition: 'all 0.2s',
':hover': { borderColor: '#2563eb', backgroundColor: '#f8fafc' }
}}
onClick={() => {
setTournamentName(tournament.name);
setTournamentDate(tournament.date);
setTournamentLocation(tournament.location);
localStorage.setItem('tournamentName', tournament.name);
localStorage.setItem('tournamentDate', tournament.date);
localStorage.setItem('tournamentLocation', tournament.location);
setShowLoadTournaments(false);
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h3 style={{ margin: '0 0 8px 0', fontSize: '18px', fontWeight: '600' }}>
{tournament.name}
</h3>
<div style={{ color: '#666', fontSize: '14px' }}>
{tournament.date} {tournament.location} {tournament.teams} teams
</div>
</div>
<div style={{
padding: '4px 12px',
borderRadius: 12,
fontSize: '12px',
fontWeight: '500',
backgroundColor:
tournament.status === 'active' ? '#dcfce7' :
tournament.status === 'completed' ? '#fef3c7' :
'#dbeafe',
color:
tournament.status === 'active' ? '#166534' :
tournament.status === 'completed' ? '#92400e' :
'#1e40af'
}}>
{tournament.status}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
return (
<div>
<Header tournamentName={tournamentName} />
<div style={{ display: 'flex', marginTop: '60px', height: 'calc(100vh - 60px)' }}>
{createTournamentModal}
{loadTournamentsModal}
{startMsg && (
<div style={{ position: 'fixed', top: 80, right: 40, background: '#fff', color: startMsg.startsWith('Tournament started') ? '#22c55e' : '#b91c1c', fontWeight: 'bold', padding: '12px 24px', borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', zIndex: 2000 }}>
{starting ? 'Starting...' : startMsg}
</div>
)}
<Sidebar onSelect={setSection} selected={section} />
<div style={{ marginLeft: 220, flex: 1, overflow: 'auto' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '1rem' }}>
<UserMenu onSelect={handleUserMenuSelect} token={token} onResetTournament={handleResetTournament} tournamentStarted={tournamentStarted} />
</div>
{userMenuContent}
{section === 'schedule' && <MatchesSchedule />}
{section === 'results' && <Results />}
{section === 'bracket' && <Bracket token={token} onTierComplete={() => setRankingRefresh(r => r + 1)} />}
{section === 'pools' && <Pools token={token} onTournamentNameChange={setTournamentName} />}
{section === 'team-ranking' && <TeamRanking refresh={rankingRefresh} />}
</div>
</div>
</div>
);
}
export default App;

View File

@@ -0,0 +1,9 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@@ -0,0 +1,119 @@
.bracket-container {
padding: 32px;
width: 100%;
max-width: 100vw;
box-sizing: border-box;
}
.bracket-flex {
display: flex;
flex-direction: column;
gap: 32px;
align-items: stretch;
width: 100%;
padding-bottom: 16px;
}
.bracket-round {
min-width: 260px;
max-width: 900px;
background: #f8fafc;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
flex: 1 0 260px;
box-sizing: border-box;
margin-bottom: 8px;
margin-left: auto;
margin-right: auto;
}
.bracket-round-title {
font-weight: bold;
margin-bottom: 12px;
text-align: center;
font-size: 1.15rem;
}
.bracket-match {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 6px;
margin-bottom: 16px;
padding: 12px 8px;
text-align: center;
box-shadow: 0 1px 4px rgba(0,0,0,0.03);
}
.bracket-player {
font-size: 1rem;
margin: 2px 0;
}
.bracket-vs {
color: #64748b;
font-size: 0.95rem;
margin: 2px 0;
}
.bracket-winner {
margin-top: 6px;
color: #22c55e;
font-weight: bold;
font-size: 0.95rem;
}
.bracket-edit-btn {
background: #2563eb;
color: #fff;
border: none;
border-radius: 4px;
padding: 0.4rem 1rem;
cursor: pointer;
margin-top: 8px;
font-size: 1rem;
transition: background 0.2s;
}
.bracket-edit-btn:hover {
background: #1d4ed8;
}
.bracket-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0,0,0,0.25);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.bracket-modal {
background: #fff;
border-radius: 10px;
box-shadow: 0 4px 32px rgba(0,0,0,0.18);
padding: 32px 24px 24px 24px;
min-width: 320px;
max-width: 90vw;
position: relative;
z-index: 1001;
}
@media (max-width: 900px) {
.bracket-round {
min-width: 90vw;
max-width: 100vw;
padding: 10px;
}
.bracket-flex {
gap: 16px;
}
.bracket-modal {
min-width: 90vw;
padding: 18px 8px 12px 8px;
}
}

View File

@@ -0,0 +1,261 @@
import React, { useEffect, useState } from 'react';
import './Bracket.css';
import TeamLogo from './TeamLogo';
// Helper to group matches by round (assumes matches are ordered by round)
function groupMatchesByRound(matches) {
const rounds = {};
matches.forEach(match => {
const key = match.scheduledAt;
if (!rounds[key]) rounds[key] = [];
rounds[key].push(match);
});
return Object.values(rounds);
}
const tierColors = [
'#f8fafc', // light blue
'#fef9c3', // light yellow
'#fce7f3', // light pink
'#d1fae5', // light green
'#fee2e2', // light red
'#e0e7ff', // light purple
];
const Bracket = ({ token, onTierComplete }) => {
const [tiers, setTiers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [editingMatch, setEditingMatch] = useState(null);
const [score1, setScore1] = useState('');
const [score2, setScore2] = useState('');
const [submitMsg, setSubmitMsg] = useState('');
const fetchBrackets = () => {
setLoading(true);
fetch('/api/matches')
.then(res => res.ok ? res.json() : Promise.reject(res))
.then(data => {
const tierMap = {};
data.forEach(match => {
if (match.stage?.type === 'SINGLE_ELIM') {
const tier = match.stage.tier || 1;
if (!tierMap[tier]) tierMap[tier] = [];
tierMap[tier].push(match);
}
});
const tiersArr = Object.entries(tierMap)
.sort((a, b) => Number(a[0]) - Number(b[0]))
.map(([tier, matches]) => ({ tier: Number(tier), matches }));
setTiers(tiersArr);
setLoading(false);
})
.catch(() => {
setError('Failed to load brackets');
setLoading(false);
});
};
// Helper to fetch brackets and, if all results are set for the latest round, advance the tier
const fetchBracketsAndAdvance = async (tier) => {
fetch('/api/matches')
.then(res => res.ok ? res.json() : Promise.reject(res))
.then(async data => {
const tierMatches = data.filter(match => match.stage?.type === 'SINGLE_ELIM' && match.stage?.tier === tier);
if (!tierMatches.length) return fetchBrackets();
// Group by round
const rounds = groupMatchesByRound(tierMatches);
const latestRound = rounds[rounds.length - 1];
if (latestRound && latestRound.every(m => m.result) && latestRound.length > 1) {
// All results set, advance this tier (only if more than one match, i.e., not final)
await fetch(`/api/admin/schedule/singleelim/next?tier=${tier}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
if (onTierComplete) onTierComplete();
}
fetchBrackets();
})
.catch(() => fetchBrackets());
};
// On initial load, check all tiers for advancement
useEffect(() => {
if (!token) return fetchBrackets();
fetch('/api/matches')
.then(res => res.ok ? res.json() : Promise.reject(res))
.then(async data => {
const tierMap = {};
data.forEach(match => {
if (match.stage?.type === 'SINGLE_ELIM') {
const tier = match.stage.tier || 1;
if (!tierMap[tier]) tierMap[tier] = [];
tierMap[tier].push(match);
}
});
const tierNumbers = Object.keys(tierMap).map(Number);
for (const tier of tierNumbers) {
const matches = tierMap[tier];
const rounds = groupMatchesByRound(matches);
const latestRound = rounds[rounds.length - 1];
if (latestRound && latestRound.every(m => m.result) && latestRound.length > 1) {
// All results set, advance this tier
await fetch(`/api/admin/schedule/singleelim/next?tier=${tier}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
}
}
fetchBrackets();
})
.catch(() => fetchBrackets());
// eslint-disable-next-line
}, [token]);
const openModal = (match) => {
setEditingMatch(match);
setScore1('');
setScore2('');
setSubmitMsg('');
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setEditingMatch(null);
setScore1('');
setScore2('');
setSubmitMsg('');
};
const handleSubmit = async (e) => {
e.preventDefault();
setSubmitMsg('');
// Validation
if (score1 === '' || score2 === '') {
setSubmitMsg('Both scores are required.');
return;
}
if (!/^[0-9]+$/.test(score1) || !/^[0-9]+$/.test(score2)) {
setSubmitMsg('Scores must be non-negative integers.');
return;
}
if (Number(score1) < 0 || Number(score2) < 0) {
setSubmitMsg('Scores must be non-negative.');
return;
}
if (score1 === score2) {
setSubmitMsg('Scores must be different (no draws).');
return;
}
if (!token) {
setSubmitMsg('You must be logged in as admin to submit results.');
return;
}
try {
const res = await fetch(`/api/admin/matches/${editingMatch.id}/result`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ team1Score: Number(score1), team2Score: Number(score2) })
});
const data = await res.json();
if (res.ok) {
setSubmitMsg('Result saved!');
closeModal();
// After saving, refetch and check if the latest round for this tier is complete
setTimeout(() => {
fetchBracketsAndAdvance(editingMatch.stage?.tier);
}, 300);
} else {
setSubmitMsg(data.error || 'Failed to save result');
}
} catch {
setSubmitMsg('Failed to save result');
}
};
if (loading) return <div style={{ padding: 32 }}>Loading brackets...</div>;
if (error) return <div style={{ padding: 32, color: 'red' }}>{error}</div>;
if (!tiers.length) return <div style={{ padding: 32 }}>No elimination brackets found.</div>;
return (
<div className="bracket-container">
<h2>Elimination Brackets by Tier</h2>
<div className="bracket-flex">
{tiers.map((tierObj, idx) => {
const rounds = groupMatchesByRound(tierObj.matches);
const bgColor = tierColors[idx % tierColors.length];
return (
<div className="bracket-round" key={tierObj.tier} style={{ background: bgColor }}>
<div className="bracket-round-title">{`Tier ${tierObj.tier}`}</div>
{rounds.map((round, i) => (
<div key={i}>
<div style={{ fontWeight: 'bold', margin: '8px 0', textAlign: 'center' }}>{`Round ${i + 1}`}</div>
{round.map(match => (
<div className="bracket-match" key={match.id}>
<div className="bracket-player">
<TeamLogo team={match.team1} size="small" />
</div>
<div className="bracket-vs">vs</div>
<div className="bracket-player">
<TeamLogo team={match.team2} size="small" />
</div>
{match.result ? (
<div className="bracket-winner">Winner: {match.result.winnerId === match.team1?.id ? match.team1?.name : match.team2?.name}</div>
) : (
token && <button className="bracket-edit-btn" onClick={() => openModal(match)}>
Enter Result
</button>
)}
</div>
))}
</div>
))}
</div>
);
})}
</div>
{modalOpen && (
<div className="bracket-modal-overlay" onClick={closeModal}>
<div className="bracket-modal" onClick={e => e.stopPropagation()}>
<h3>Enter Result</h3>
<div style={{ marginBottom: 8 }}>
<strong>{editingMatch?.team1?.name || 'TBD'}</strong> vs <strong>{editingMatch?.team2?.name || 'TBD'}</strong>
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<input
type="number"
value={score1}
onChange={e => setScore1(e.target.value)}
placeholder="Team 1 Score"
autoFocus
min="0"
style={{ padding: '0.5rem', borderRadius: 4, border: '1px solid #ccc' }}
disabled={!token}
/>
<input
type="number"
value={score2}
onChange={e => setScore2(e.target.value)}
placeholder="Team 2 Score"
min="0"
style={{ padding: '0.5rem', borderRadius: 4, border: '1px solid #ccc' }}
disabled={!token}
/>
<button type="submit" className="bracket-edit-btn" disabled={!token}>Save</button>
<button type="button" className="bracket-edit-btn" onClick={closeModal} style={{ background: '#e5e7eb', color: '#222' }}>Cancel</button>
{!token && <div style={{ color: 'red', marginTop: 4 }}>You must be logged in as admin to submit results.</div>}
<div style={{ color: '#2563eb', marginTop: 4 }}>{submitMsg}</div>
</form>
</div>
</div>
)}
</div>
);
};
export default Bracket;

View File

@@ -0,0 +1,66 @@
import React, { useState } from 'react';
const ChangePassword = ({ token }) => {
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [message, setMessage] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setMessage('');
try {
const res = await fetch('/api/player/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ oldPassword, newPassword })
});
const data = await res.json();
if (res.ok) {
setMessage('Password changed successfully!');
setOldPassword('');
setNewPassword('');
} else {
setMessage(data.error || 'Failed to change password');
}
} catch {
setMessage('Failed to change password');
}
setLoading(false);
};
if (!token) return <div>Please log in.</div>;
return (
<form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '2rem auto', textAlign: 'center' }}>
<div>
<input
type="password"
placeholder="Old Password"
value={oldPassword}
onChange={e => setOldPassword(e.target.value)}
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
required
/>
</div>
<div>
<input
type="password"
placeholder="New Password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
required
/>
</div>
<button type="submit" disabled={loading}>{loading ? 'Changing...' : 'Change Password'}</button>
<div style={{ marginTop: 8, color: '#2563eb' }}>{message}</div>
</form>
);
};
export default ChangePassword;

View File

@@ -0,0 +1,54 @@
import React, { useState } from 'react';
const ForgotPassword = ({ onResetRequested }) => {
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
setMessage('');
try {
const res = await fetch('/api/player/request-password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
const data = await res.json();
if (res.ok) {
setMessage('If the email exists, a reset link has been sent (see backend console).');
setEmail('');
if (onResetRequested) onResetRequested();
} else {
setError(data.error || 'Failed to request reset');
}
} catch {
setError('Failed to request reset');
}
setLoading(false);
};
return (
<form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '2rem auto', textAlign: 'center' }}>
<h2>Forgot Password</h2>
<div>
<input
type="email"
placeholder="Email"
value={email}
onChange={e => setEmail(e.target.value)}
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
required
/>
</div>
<button type="submit" disabled={loading}>{loading ? 'Sending...' : 'Send Reset Link'}</button>
<div style={{ marginTop: 8, color: 'green' }}>{message}</div>
<div style={{ marginTop: 8, color: 'red' }}>{error}</div>
</form>
);
};
export default ForgotPassword;

View File

@@ -0,0 +1,60 @@
import React from 'react';
const Header = ({ tournamentName }) => {
return (
<header style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: '60px',
backgroundColor: '#ffffff',
borderBottom: '1px solid #e5e7eb',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 20px',
zIndex: 1000,
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px'
}}>
<div style={{
width: '32px',
height: '32px',
backgroundColor: '#2563eb',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontWeight: 'bold',
fontSize: '14px'
}}>
T
</div>
<h1 style={{
fontSize: '18px',
fontWeight: '600',
margin: 0,
color: '#1e293b'
}}>
{tournamentName || 'Tournament Manager'}
</h1>
</div>
<div style={{
fontSize: '12px',
color: '#64748b',
fontWeight: '500'
}}>
{new Date().toLocaleDateString()}
</div>
</header>
);
};
export default Header;

View File

@@ -0,0 +1,67 @@
.leaderboard-container {
margin: 2rem auto;
max-width: 600px;
padding: 1rem;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.leaderboard-filter {
width: 100%;
padding: 0.5rem 1rem;
margin-bottom: 1rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
.leaderboard-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.leaderboard-table th, .leaderboard-table td {
padding: 0.75rem 1rem;
text-align: center;
border-bottom: 1px solid #eee;
}
.leaderboard-table th {
background: #f5f5f5;
font-weight: bold;
user-select: none;
transition: background 0.2s;
}
.leaderboard-table th.active-sort {
background: #e0e7ff;
color: #1d4ed8;
}
.leaderboard-table th:hover {
background: #e0e7ff;
}
.leaderboard-table tr:last-child td {
border-bottom: none;
}
.first-place {
background: #fffbe6;
font-weight: bold;
color: #bfa100;
}
.second-place {
background: #f0f4ff;
font-weight: bold;
color: #7d8799;
}
.third-place {
background: #fff4e6;
font-weight: bold;
color: #b87333;
}

View File

@@ -0,0 +1,86 @@
import React, { useState } from 'react';
import leaderboardData from './leaderboardData';
import './Leaderboard.css';
const columns = [
{ key: 'rank', label: 'Rank' },
{ key: 'name', label: 'Name' },
{ key: 'score', label: 'Score' },
{ key: 'matchesPlayed', label: 'Matches Played' },
{ key: 'wins', label: 'Wins' },
{ key: 'losses', label: 'Losses' },
];
const Leaderboard = () => {
const [sortKey, setSortKey] = useState('rank');
const [sortOrder, setSortOrder] = useState('asc');
const [filter, setFilter] = useState('');
const handleSort = (key) => {
if (sortKey === key) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(key);
setSortOrder('asc');
}
};
const filteredData = leaderboardData.filter(entry =>
entry.name.toLowerCase().includes(filter.toLowerCase())
);
const sortedData = [...filteredData].sort((a, b) => {
if (a[sortKey] < b[sortKey]) return sortOrder === 'asc' ? -1 : 1;
if (a[sortKey] > b[sortKey]) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
return (
<div className="leaderboard-container">
<h2>Leaderboard</h2>
<input
type="text"
id="leaderboard-filter"
name="leaderboard-filter"
placeholder="Filter by name..."
value={filter}
onChange={e => setFilter(e.target.value)}
className="leaderboard-filter"
/>
<table className="leaderboard-table">
<thead>
<tr>
{columns.map(col => (
<th
key={col.key}
onClick={() => handleSort(col.key)}
className={sortKey === col.key ? 'active-sort' : ''}
style={{ cursor: 'pointer' }}
>
{col.label} {sortKey === col.key ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
))}
</tr>
</thead>
<tbody>
{sortedData.map((entry, idx) => (
<tr key={entry.rank} className={
entry.rank === 1 ? 'first-place' :
entry.rank === 2 ? 'second-place' :
entry.rank === 3 ? 'third-place' : ''
}>
<td>{entry.rank}</td>
<td>{entry.name}</td>
<td>{entry.score}</td>
<td>{entry.matchesPlayed}</td>
<td>{entry.wins}</td>
<td>{entry.losses}</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default Leaderboard;

View File

@@ -0,0 +1,60 @@
import React, { useState } from 'react';
const Login = ({ onLogin }) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const res = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (res.ok && data.token) {
onLogin(data.token);
} else {
setError(data.error || 'Login failed');
}
} catch {
setError('Login failed');
}
setLoading(false);
};
return (
<form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '2rem auto', textAlign: 'center' }}>
<h2>Admin Login</h2>
<div>
<input
type="text"
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
required
/>
</div>
<div>
<input
type="password"
placeholder="Password"
value={password}
onChange={e => setPassword(e.target.value)}
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
required
/>
</div>
<button type="submit" disabled={loading}>{loading ? 'Logging in...' : 'Login'}</button>
<div style={{ marginTop: 8, color: 'red' }}>{error}</div>
</form>
);
};
export default Login;

View File

@@ -0,0 +1,112 @@
.matches-schedule-container {
padding: 32px;
}
.matches-rounds-flex {
display: flex;
gap: 32px;
align-items: flex-start;
overflow-x: auto;
}
.matches-round {
min-width: 260px;
background: #f8fafc;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.matches-round-title {
font-weight: bold;
margin-bottom: 12px;
text-align: center;
}
.matches-grid {
display: flex;
flex-direction: column;
gap: 16px;
}
.match-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 12px 8px;
text-align: center;
box-shadow: 0 1px 4px rgba(0,0,0,0.03);
}
.match-stage {
font-size: 0.95rem;
color: #64748b;
margin-bottom: 4px;
}
.match-players {
font-size: 1.1rem;
margin-bottom: 4px;
display: flex;
justify-content: center;
gap: 8px;
}
.vs {
color: #2563eb;
font-weight: bold;
}
.match-time {
font-size: 0.9rem;
color: #64748b;
margin-bottom: 6px;
}
.match-result {
color: #22c55e;
font-weight: bold;
margin-top: 6px;
}
.match-edit-btn {
background: #2563eb;
color: #fff;
border: none;
border-radius: 4px;
padding: 0.4rem 1rem;
cursor: pointer;
margin-top: 6px;
}
.match-edit-form {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
margin-top: 6px;
}
.match-edit-form input {
width: 60px;
padding: 0.3rem;
border: 1px solid #e5e7eb;
border-radius: 4px;
text-align: center;
}
.match-edit-form button {
margin: 2px 4px;
padding: 0.3rem 0.8rem;
border: none;
border-radius: 4px;
background: #2563eb;
color: #fff;
cursor: pointer;
}
.match-edit-msg {
color: #2563eb;
font-size: 0.95rem;
margin-top: 4px;
}

View File

@@ -0,0 +1,240 @@
import React, { useEffect, useState } from 'react';
import './MatchesSchedule.css';
import TeamLogo from './TeamLogo';
const poolColors = [
'#f8fafc', // light blue
'#fef9c3', // light yellow
'#fce7f3', // light pink
'#d1fae5', // light green
'#fee2e2', // light red
'#e0e7ff', // light purple
];
function groupMatchesByPoolAndRound(matches) {
// Only round robin matches
const rrMatches = matches.filter(m => m.stage?.type === 'ROUND_ROBIN');
let poolMap = {};
rrMatches.forEach(m => {
const pool = m.pool || 1;
if (!poolMap[pool]) poolMap[pool] = [];
poolMap[pool].push(m);
});
// For each pool, group matches by round (scheduledAt)
const poolRounds = {};
Object.entries(poolMap).forEach(([pool, matches]) => {
const rounds = {};
matches.forEach(match => {
const key = match.scheduledAt;
if (!rounds[key]) rounds[key] = [];
rounds[key].push(match);
});
poolRounds[pool] = Object.values(rounds).sort((a, b) => new Date(a[0].scheduledAt) - new Date(b[0].scheduledAt));
});
return poolRounds;
}
const MatchesSchedule = () => {
const [matches, setMatches] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingMatchId, setEditingMatchId] = useState(null);
const [score1, setScore1] = useState('');
const [score2, setScore2] = useState('');
const [submitMsg, setSubmitMsg] = useState('');
const [mobileTab, setMobileTab] = useState(null);
// Calculate poolRounds and poolNumbers BEFORE any useEffect/useState that uses them
const poolRounds = groupMatchesByPoolAndRound(matches);
const poolNumbers = Object.keys(poolRounds).sort((a, b) => Number(a) - Number(b));
useEffect(() => {
setLoading(true);
fetch('/api/matches')
.then(res => res.ok ? res.json() : Promise.reject(res))
.then(data => {
setMatches(data);
setLoading(false);
})
.catch(() => {
setError('Failed to load matches');
setLoading(false);
});
}, []);
// Set default mobile tab
React.useEffect(() => {
if (mobileTab === null && poolNumbers.length > 0) setMobileTab(poolNumbers[0]);
}, [poolNumbers, mobileTab]);
// Responsive: tabs for mobile, columns for desktop
const isMobile = window.innerWidth < 700;
const handleEdit = (match) => {
setEditingMatchId(match.id);
setScore1('');
setScore2('');
setSubmitMsg('');
};
const handleSubmit = async (match) => {
setSubmitMsg('');
if (score1 === '' || score2 === '' || score1 === score2) {
setSubmitMsg('Scores must be different and not empty.');
return;
}
try {
const res = await fetch(`/api/admin/matches/${match.id}/result`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ team1Score: Number(score1), team2Score: Number(score2) })
});
const data = await res.json();
if (res.ok) {
setSubmitMsg('Result saved!');
setEditingMatchId(null);
setLoading(true);
fetch('/api/matches')
.then(res => res.ok ? res.json() : Promise.reject(res))
.then(data => {
setMatches(data);
setLoading(false);
});
} else {
setSubmitMsg(data.error || 'Failed to save result');
}
} catch {
setSubmitMsg('Failed to save result');
}
};
if (loading) return <div style={{ padding: 32 }}>Loading matches...</div>;
if (error) return <div style={{ padding: 32, color: 'red' }}>{error}</div>;
return (
<div className="matches-schedule-container">
<h2>Matches Schedule (by Pool)</h2>
{isMobile ? (
<>
<div style={{ display: 'flex', gap: 12, marginBottom: 24 }}>
{poolNumbers.map((pool, idx) => (
<button
key={pool}
onClick={() => setMobileTab(pool)}
style={{
padding: '8px 20px',
borderRadius: 8,
border: 'none',
background: mobileTab === pool ? poolColors[idx % poolColors.length] : '#e5e7eb',
color: '#222',
fontWeight: mobileTab === pool ? 'bold' : 'normal',
cursor: 'pointer',
boxShadow: mobileTab === pool ? '0 2px 8px rgba(0,0,0,0.08)' : 'none',
outline: mobileTab === pool ? '2px solid #2563eb' : 'none',
transition: 'background 0.2s, box-shadow 0.2s',
}}
>
{`Pool ${pool}`}
</button>
))}
</div>
{poolNumbers.map((pool, idx) => (
mobileTab === pool && (
<div key={pool} style={{
minWidth: 320,
background: poolColors[idx % poolColors.length],
borderRadius: 8,
padding: 16,
boxShadow: '0 2px 8px rgba(0,0,0,0.04)',
marginBottom: 24
}}>
<h3 style={{ textAlign: 'center' }}>{`Pool ${pool}`}</h3>
{poolRounds[pool].map((round, roundIdx) => (
<div key={roundIdx} className="matches-grid">
<div style={{ fontWeight: 'bold', margin: '8px 0', textAlign: 'center' }}>{`Round ${roundIdx + 1}`}</div>
{round.map(match => (
<div className="match-card" key={match.id}>
<div className="match-players">
<div style={{ textAlign: 'center' }}>
<TeamLogo team={match.team1} size="small" />
</div>
<span className="vs">vs</span>
<div style={{ textAlign: 'center' }}>
<TeamLogo team={match.team2} size="small" />
</div>
</div>
<div className="match-time">{new Date(match.scheduledAt).toLocaleString()}</div>
{match.result ? (
<div className="match-result">Result: {match.result.team1Score} - {match.result.team2Score}</div>
) : (
editingMatchId === match.id ? (
<div className="match-edit-form">
<input type="number" value={score1} onChange={e => setScore1(e.target.value)} placeholder="Team 1 Score" />
<input type="number" value={score2} onChange={e => setScore2(e.target.value)} placeholder="Team 2 Score" />
<button onClick={() => handleSubmit(match)}>Save</button>
<button onClick={() => setEditingMatchId(null)}>Cancel</button>
<div className="match-edit-msg">{submitMsg}</div>
</div>
) : (
<button className="match-edit-btn" onClick={() => handleEdit(match)}>Enter Result</button>
)
)}
</div>
))}
</div>
))}
</div>
)
))}
</>
) : (
<div style={{ display: 'flex', gap: 32, alignItems: 'flex-start', overflowX: 'auto' }}>
{poolNumbers.map((pool, idx) => (
<div className="matches-round" key={pool} style={{ minWidth: 320, background: poolColors[idx % poolColors.length], borderRadius: 8, padding: 16, boxShadow: '0 2px 8px rgba(0,0,0,0.04)', marginBottom: 24 }}>
<div className="matches-round-title">{`Pool ${pool}`}</div>
{poolRounds[pool].map((round, roundIdx) => (
<div key={roundIdx} className="matches-grid">
<div style={{ fontWeight: 'bold', margin: '8px 0', textAlign: 'center' }}>{`Round ${roundIdx + 1}`}</div>
{round.map(match => (
<div className="match-card" key={match.id}>
<div className="match-players">
<div style={{ textAlign: 'center' }}>
<TeamLogo team={match.team1} size="small" />
</div>
<span className="vs">vs</span>
<div style={{ textAlign: 'center' }}>
<TeamLogo team={match.team2} size="small" />
</div>
</div>
<div className="match-time">{new Date(match.scheduledAt).toLocaleString()}</div>
{match.result ? (
<div className="match-result">Result: {match.result.team1Score} - {match.result.team2Score}</div>
) : (
editingMatchId === match.id ? (
<div className="match-edit-form">
<input type="number" value={score1} onChange={e => setScore1(e.target.value)} placeholder="Team 1 Score" />
<input type="number" value={score2} onChange={e => setScore2(e.target.value)} placeholder="Team 2 Score" />
<button onClick={() => handleSubmit(match)}>Save</button>
<button onClick={() => setEditingMatchId(null)}>Cancel</button>
<div className="match-edit-msg">{submitMsg}</div>
</div>
) : (
<button className="match-edit-btn" onClick={() => handleEdit(match)}>Enter Result</button>
)
)}
</div>
))}
</div>
))}
</div>
))}
</div>
)}
</div>
);
};
export default MatchesSchedule;

View File

@@ -0,0 +1,237 @@
import React, { useEffect, useState } from 'react';
import TeamLogo from './TeamLogo';
const poolColors = [
'#f8fafc', // light blue
'#fef9c3', // light yellow
'#fce7f3', // light pink
'#d1fae5', // light green
'#fee2e2', // light red
'#e0e7ff', // light purple
];
const Pools = ({ token, onTournamentNameChange }) => {
const [matches, setMatches] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [activeTab, setActiveTab] = useState(null);
const [showForm, setShowForm] = useState(false);
const [teamName, setTeamName] = useState('');
const [registerMsg, setRegisterMsg] = useState('');
const [registrationDisabled, setRegistrationDisabled] = useState(false);
const [tournamentName, setTournamentName] = useState(() => localStorage.getItem('tournamentName') || '');
const fetchMatches = async () => {
setLoading(true);
try {
const res = await fetch('/api/matches');
if (res.ok) {
const data = await res.json();
setMatches(data.filter(m => m.stage?.type === 'ROUND_ROBIN'));
setRegistrationDisabled(data.some(m => m.stage?.type === 'ROUND_ROBIN'));
} else {
setError('Failed to load matches');
}
} catch {
setError('Failed to load matches');
}
setLoading(false);
};
useEffect(() => {
fetchMatches();
}, [registerMsg]);
useEffect(() => {
localStorage.setItem('tournamentName', tournamentName);
if (onTournamentNameChange) onTournamentNameChange(tournamentName);
}, [tournamentName, onTournamentNameChange]);
// Group matches by pool
const poolMap = {};
matches.forEach(match => {
const pool = match.pool || 1;
if (!poolMap[pool]) poolMap[pool] = [];
poolMap[pool].push(match);
});
const poolNumbers = Object.keys(poolMap).sort((a, b) => Number(a) - Number(b));
// Get all teams per pool
const poolTeams = {};
poolNumbers.forEach(pool => {
const teams = {};
poolMap[pool].forEach(match => {
if (match.team1) teams[match.team1.id] = match.team1;
if (match.team2) teams[match.team2.id] = match.team2;
});
poolTeams[pool] = Object.values(teams);
});
// Set default active tab
React.useEffect(() => {
if (activeTab === null && poolNumbers.length > 0) setActiveTab(poolNumbers[0]);
}, [poolNumbers, activeTab]);
return (
<div style={{ padding: 32 }}>
<h2>Pools</h2>
{token && !registrationDisabled && (
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 12 }}>
<input
type="text"
value={tournamentName}
onChange={e => setTournamentName(e.target.value)}
placeholder="Tournament Name"
style={{ padding: '0.5rem', borderRadius: 4, border: '1px solid #ccc', minWidth: 220, fontWeight: 'bold', fontSize: '1.1rem' }}
disabled={registrationDisabled}
/>
</div>
</div>
)}
{token && !registrationDisabled && (
<div style={{ marginBottom: 24 }}>
<button
onClick={() => setShowForm(f => !f)}
style={{
background: '#2563eb', color: '#fff', border: 'none', borderRadius: 6, padding: '8px 20px', cursor: 'pointer', fontWeight: 'bold', marginBottom: 8
}}
>
{showForm ? 'Cancel' : 'Register Team'}
</button>
{showForm && (
<form
onSubmit={async e => {
e.preventDefault();
setRegisterMsg('');
if (!teamName.trim()) {
setRegisterMsg('Team name is required.');
return;
}
try {
const res = await fetch('/api/teams', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ name: teamName.trim() })
});
const data = await res.json();
if (res.ok) {
setTeamName('');
setShowForm(false);
if (data.poolsCreated) {
setRegisterMsg(`✅ Team registered! ${data.poolMessage} (${data.matchesCreated} matches created)`);
} else {
setRegisterMsg(`✅ Team registered! ${data.poolMessage}`);
}
// Refresh the matches data to show the new pools
fetchMatches();
} else {
setRegisterMsg(data.error || 'Failed to register team');
}
} catch {
setRegisterMsg('Failed to register team');
}
}}
style={{ marginTop: 8, display: 'flex', gap: 8, alignItems: 'center' }}
>
<input
type="text"
value={teamName}
onChange={e => setTeamName(e.target.value)}
placeholder="Team Name"
style={{ padding: '0.5rem', borderRadius: 4, border: '1px solid #ccc', minWidth: 180 }}
required
/>
<button type="submit" style={{ background: '#22c55e', color: '#fff', border: 'none', borderRadius: 4, padding: '8px 16px', fontWeight: 'bold', cursor: 'pointer' }}>Submit</button>
<span style={{ color: registerMsg.startsWith('Team registered') ? '#22c55e' : 'red', marginLeft: 8 }}>{registerMsg}</span>
</form>
)}
</div>
)}
{token && registrationDisabled && (
<div style={{ marginBottom: 24, color: '#b91c1c', fontWeight: 'bold' }}>
Team registration is disabled after the tournament has started.
</div>
)}
{loading && <div>Loading pools...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
{/* Tabs for pools */}
<div style={{ display: 'flex', gap: 12, marginBottom: 24 }}>
{poolNumbers.map((pool, idx) => (
<button
key={pool}
onClick={() => setActiveTab(pool)}
style={{
padding: '8px 20px',
borderRadius: 8,
border: 'none',
background: activeTab === pool ? poolColors[idx % poolColors.length] : '#e5e7eb',
color: '#222',
fontWeight: activeTab === pool ? 'bold' : 'normal',
cursor: 'pointer',
boxShadow: activeTab === pool ? '0 2px 8px rgba(0,0,0,0.08)' : 'none',
outline: activeTab === pool ? '2px solid #2563eb' : 'none',
transition: 'background 0.2s, box-shadow 0.2s',
}}
>
{`Pool ${pool}`}
</button>
))}
</div>
{/* Table view for active pool */}
{poolNumbers.map((pool, idx) => (
activeTab === pool && (
<div key={pool} style={{
minWidth: 320,
background: poolColors[idx % poolColors.length],
borderRadius: 8,
padding: 16,
boxShadow: '0 2px 8px rgba(0,0,0,0.04)',
marginBottom: 24
}}>
<h3 style={{ textAlign: 'center' }}>{`Pool ${pool}`}</h3>
<div style={{ marginBottom: 8 }}>
<strong>Teams:</strong>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: '12px', marginTop: '8px' }}>
{poolTeams[pool].map(team => (
<div key={team.id} style={{ textAlign: 'center' }}>
<TeamLogo team={team} size="small" />
</div>
))}
</div>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#fff', borderRadius: 6 }}>
<thead>
<tr>
<th>Team 1</th>
<th>Team 2</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{poolMap[pool].map(match => (
<tr key={match.id}>
<td style={{ textAlign: 'center', padding: '8px' }}>
<TeamLogo team={match.team1} size="small" />
</td>
<td style={{ textAlign: 'center', padding: '8px' }}>
<TeamLogo team={match.team2} size="small" />
</td>
<td style={{ textAlign: 'center', fontWeight: 'bold' }}>
{match.result ? `${match.result.team1Score} - ${match.result.team2Score}` : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
))}
</div>
);
};
export default Pools;

View File

@@ -0,0 +1,94 @@
import React, { useEffect, useState, useRef } from 'react';
const ProfileEdit = ({ token }) => {
const [user, setUser] = useState(null);
const [name, setName] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState('');
const fileInputRef = useRef();
useEffect(() => {
if (!token) return;
fetch('/api/me', {
headers: { 'Authorization': `Bearer ${token}` }
})
.then(res => res.ok ? res.json() : Promise.reject(res))
.then(data => {
setUser(data);
setName(data.name);
setImageUrl(data.imageUrl || '');
});
}, [token]);
const handleImageUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
setUploading(true);
setMessage('');
const formData = new FormData();
formData.append('image', file);
try {
const res = await fetch('/api/player/upload-image', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
if (!res.ok) throw new Error('Upload failed');
const data = await res.json();
setImageUrl(data.imageUrl);
setMessage('Image uploaded!');
} catch {
setMessage('Failed to upload image');
}
setUploading(false);
};
const handleNameChange = (e) => setName(e.target.value);
const handleSave = async (e) => {
e.preventDefault();
setMessage('');
try {
const res = await fetch('/api/player/update-name', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ name })
});
const data = await res.json();
if (res.ok) {
setUser(data);
setMessage('Name updated!');
} else {
setMessage(data.error || 'Failed to update name');
}
} catch {
setMessage('Failed to update name');
}
};
if (!token) return <div>Please log in.</div>;
if (!user) return <div>Loading...</div>;
return (
<form onSubmit={handleSave} style={{ maxWidth: 400, margin: '2rem auto', textAlign: 'center' }}>
<div>
{imageUrl && (
<img src={imageUrl} alt="Profile" style={{ width: 120, height: 120, borderRadius: '50%', objectFit: 'cover', marginBottom: 16 }} />
)}
</div>
<div>
<input type="file" accept="image/*" ref={fileInputRef} onChange={handleImageUpload} disabled={uploading} />
</div>
<div style={{ margin: '1rem 0' }}>
<input type="text" value={name} onChange={handleNameChange} placeholder="Name" style={{ padding: '0.5rem', width: '80%' }} />
</div>
<button type="submit" disabled={uploading}>{uploading ? 'Uploading...' : 'Save'}</button>
<div style={{ marginTop: 8, color: '#2563eb' }}>{message}</div>
</form>
);
};
export default ProfileEdit;

View File

@@ -0,0 +1,41 @@
import React, { useEffect, useState } from 'react';
const ProfileView = ({ token }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!token) return;
setLoading(true);
fetch('/api/me', {
headers: { 'Authorization': `Bearer ${token}` }
})
.then(res => res.ok ? res.json() : Promise.reject(res))
.then(data => {
setUser(data);
setLoading(false);
})
.catch(() => {
setError('Failed to load profile');
setLoading(false);
});
}, [token]);
if (!token) return <div>Please log in.</div>;
if (loading) return <div>Loading profile...</div>;
if (error) return <div>{error}</div>;
if (!user) return null;
return (
<div style={{ maxWidth: 400, margin: '2rem auto', textAlign: 'center' }}>
{user.imageUrl && (
<img src={user.imageUrl} alt="Profile" style={{ width: 120, height: 120, borderRadius: '50%', objectFit: 'cover', marginBottom: 16 }} />
)}
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
};
export default ProfileView;

View File

@@ -0,0 +1,84 @@
import React, { useState } from 'react';
const Register = ({ onRegister, onBackToLogin }) => {
const [name, setName] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
setSuccess('');
try {
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, username, password })
});
const data = await res.json();
if (res.ok) {
// Auto-login: call onRegister with username and password
if (onRegister) onRegister(username, password);
setSuccess('Registration successful! Logging in...');
setName('');
setUsername('');
setPassword('');
} else {
setError(data.error || 'Registration failed');
}
} catch {
setError('Registration failed');
}
setLoading(false);
};
return (
<form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '2rem auto', textAlign: 'center' }}>
<h2>Register</h2>
<div>
<input
type="text"
placeholder="Name"
value={name}
onChange={e => setName(e.target.value)}
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
required
/>
</div>
<div>
<input
type="text"
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
required
/>
</div>
<div>
<input
type="password"
placeholder="Password"
value={password}
onChange={e => setPassword(e.target.value)}
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
required
/>
</div>
<button type="submit" disabled={loading}>{loading ? 'Registering...' : 'Register'}</button>
<div style={{ marginTop: 8, color: 'green' }}>{success}</div>
<div style={{ marginTop: 8, color: 'red' }}>{error}</div>
<div style={{ marginTop: 16 }}>
<button type="button" style={{ background: 'none', border: 'none', color: '#2563eb', cursor: 'pointer', textDecoration: 'underline' }} onClick={onBackToLogin}>
Back to Login
</button>
</div>
</form>
);
};
export default Register;

View File

@@ -0,0 +1,64 @@
import React, { useState } from 'react';
const ResetPassword = ({ token: propToken }) => {
const [token, setToken] = useState(propToken || '');
const [newPassword, setNewPassword] = useState('');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
setMessage('');
try {
const res = await fetch('/api/player/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, newPassword })
});
const data = await res.json();
if (res.ok) {
setMessage('Password has been reset! You can now log in.');
setNewPassword('');
} else {
setError(data.error || 'Failed to reset password');
}
} catch {
setError('Failed to reset password');
}
setLoading(false);
};
return (
<form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '2rem auto', textAlign: 'center' }}>
<h2>Reset Password</h2>
<div>
<input
type="text"
placeholder="Reset Token"
value={token}
onChange={e => setToken(e.target.value)}
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
required
/>
</div>
<div>
<input
type="password"
placeholder="New Password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
required
/>
</div>
<button type="submit" disabled={loading}>{loading ? 'Resetting...' : 'Reset Password'}</button>
<div style={{ marginTop: 8, color: 'green' }}>{message}</div>
<div style={{ marginTop: 8, color: 'red' }}>{error}</div>
</form>
);
};
export default ResetPassword;

View File

@@ -0,0 +1,68 @@
import React, { useEffect, useState } from 'react';
import TeamLogo from './TeamLogo';
const Results = () => {
const [matches, setMatches] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true);
fetch('/api/matches')
.then(res => res.ok ? res.json() : Promise.reject(res))
.then(data => {
setMatches(data.filter(m => m.result));
setLoading(false);
})
.catch(() => {
setError('Failed to load results');
setLoading(false);
});
}, []);
if (loading) return <div style={{ padding: 32 }}>Loading results...</div>;
if (error) return <div style={{ padding: 32, color: 'red' }}>{error}</div>;
return (
<div style={{ padding: 32 }}>
<h2>Results</h2>
<table style={{ width: '100%', marginTop: 16, borderCollapse: 'collapse' }}>
<thead>
<tr>
<th>Stage</th>
<th>Team 1</th>
<th>Team 2</th>
<th>Score</th>
<th>Winner</th>
</tr>
</thead>
<tbody>
{matches.map(match => {
const winnerId = match.result?.winnerId;
return (
<tr key={match.id}>
<td>{match.stage?.type || ''}</td>
<td style={{ textAlign: 'center', padding: '8px' }}>
<TeamLogo team={match.team1} size="small" />
</td>
<td style={{ textAlign: 'center', padding: '8px' }}>
<TeamLogo team={match.team2} size="small" />
</td>
<td>{match.result ? `${match.result.team1Score} - ${match.result.team2Score}` : ''}</td>
<td>
{match.result ? (
<span style={{ color: 'green', fontWeight: 'bold' }}>
{winnerId === match.team1?.id ? match.team1?.name : match.team2?.name}
</span>
) : ''}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};
export default Results;

View File

@@ -0,0 +1,31 @@
.sidebar {
width: 220px;
height: calc(100vh - 60px);
background: #f3f4f6;
border-right: 1px solid #e5e7eb;
padding-top: 1rem;
position: fixed;
left: 0;
top: 60px;
z-index: 10;
overflow-y: auto;
}
.sidebar ul {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar li {
padding: 1rem 1.5rem;
cursor: pointer;
color: #1e293b;
font-size: 1.1rem;
transition: background 0.2s, color 0.2s;
}
.sidebar li.active, .sidebar li:hover {
background: #2563eb;
color: #fff;
}

View File

@@ -0,0 +1,30 @@
import React from 'react';
import './Sidebar.css';
const Sidebar = ({ onSelect, selected }) => {
const links = [
{ key: 'pools', label: 'Pools' },
{ key: 'schedule', label: 'Matches Schedule' },
{ key: 'bracket', label: 'Bracket' },
{ key: 'results', label: 'Results' },
{ key: 'team-ranking', label: 'Team Ranking' },
];
return (
<nav className="sidebar">
<ul>
{links.map(link => (
<li
key={link.key}
className={selected === link.key ? 'active' : ''}
onClick={() => onSelect(link.key)}
>
{link.label}
</li>
))}
</ul>
</nav>
);
};
export default Sidebar;

View File

@@ -0,0 +1,55 @@
import React from 'react';
const TeamLogo = ({ team, size = 'medium', showName = true, style = {} }) => {
if (!team) return null;
const sizeStyles = {
small: { fontSize: '16px', marginBottom: '2px' },
medium: { fontSize: '24px', marginBottom: '4px' },
large: { fontSize: '32px', marginBottom: '6px' }
};
const logoStyle = {
display: 'block',
textAlign: 'center',
...sizeStyles[size],
...style
};
// Check if logo is SVG
const isSVG = team.logo && team.logo.trim().startsWith('<svg');
return (
<div style={{ textAlign: 'center' }}>
{team.logo && (
<div style={logoStyle}>
{isSVG ? (
<div
dangerouslySetInnerHTML={{ __html: team.logo }}
style={{
display: 'inline-block',
width: size === 'small' ? '24px' : size === 'large' ? '40px' : '32px',
height: size === 'small' ? '24px' : size === 'large' ? '40px' : '32px'
}}
/>
) : (
<span style={{ fontSize: sizeStyles[size].fontSize }}>
{team.logo}
</span>
)}
</div>
)}
{showName && (
<div style={{
fontSize: size === 'small' ? '12px' : size === 'large' ? '18px' : '14px',
fontWeight: '500',
textAlign: 'center'
}}>
{team.name}
</div>
)}
</div>
);
};
export default TeamLogo;

View File

@@ -0,0 +1,120 @@
import React, { useEffect, useState } from 'react';
import TeamLogo from './TeamLogo';
const columns = [
{ key: 'rank', label: 'Rank' },
{ key: 'name', label: 'Name' },
{ key: 'points', label: 'Points' },
{ key: 'played', label: 'Matches Played' },
{ key: 'wins', label: 'Wins' },
{ key: 'losses', label: 'Losses' },
];
const TeamRanking = ({ refresh }) => {
const [standings, setStandings] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [sortKey, setSortKey] = useState('rank');
const [sortOrder, setSortOrder] = useState('asc');
const [filter, setFilter] = useState('');
const [isFinal, setIsFinal] = useState(false);
useEffect(() => {
setLoading(true);
fetch('/api/standings')
.then(res => res.ok ? res.json() : Promise.reject(res))
.then(data => {
// Add rank field
const withRank = data.map((entry, idx) => ({ ...entry, rank: idx + 1 }));
setStandings(withRank);
setLoading(false);
})
.catch(() => {
setError('Failed to load team rankings');
setLoading(false);
});
// Check if all matches are complete
fetch('/api/matches')
.then(res => res.ok ? res.json() : Promise.reject(res))
.then(data => {
// Tournament is final if all SINGLE_ELIM matches have a result
const singleElimMatches = data.filter(m => m.stage?.type === 'SINGLE_ELIM');
setIsFinal(singleElimMatches.length > 0 && singleElimMatches.every(m => m.result));
})
.catch(() => setIsFinal(false));
}, [refresh]);
const handleSort = (key) => {
if (sortKey === key) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(key);
setSortOrder('asc');
}
};
const filteredData = standings.filter(entry =>
entry.name.toLowerCase().includes(filter.toLowerCase())
);
const sortedData = [...filteredData].sort((a, b) => {
if (a[sortKey] < b[sortKey]) return sortOrder === 'asc' ? -1 : 1;
if (a[sortKey] > b[sortKey]) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
if (loading) return <div style={{ padding: 32 }}>Loading team rankings...</div>;
if (error) return <div style={{ padding: 32, color: 'red' }}>{error}</div>;
return (
<div className="leaderboard-container">
<h2>Team Ranking</h2>
{isFinal && <div style={{ color: '#22c55e', fontWeight: 'bold', fontSize: '1.2rem', marginBottom: 12 }}>Final Rankings</div>}
<input
type="text"
id="team-ranking-filter"
name="team-ranking-filter"
placeholder="Filter by name..."
value={filter}
onChange={e => setFilter(e.target.value)}
className="leaderboard-filter"
/>
<table className="leaderboard-table">
<thead>
<tr>
{columns.map(col => (
<th
key={col.key}
onClick={() => handleSort(col.key)}
className={sortKey === col.key ? 'active-sort' : ''}
style={{ cursor: 'pointer' }}
>
{col.label} {sortKey === col.key ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
))}
</tr>
</thead>
<tbody>
{sortedData.map((entry, idx) => (
<tr key={entry.id} className={
entry.rank === 1 ? 'first-place' :
entry.rank === 2 ? 'second-place' :
entry.rank === 3 ? 'third-place' : ''
}>
<td>{entry.rank}</td>
<td style={{ textAlign: 'center', padding: '8px' }}>
<TeamLogo team={{ name: entry.name, logo: entry.logo }} size="small" />
</td>
<td>{entry.points}</td>
<td>{entry.played}</td>
<td>{entry.wins}</td>
<td>{entry.losses}</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default TeamRanking;

View File

@@ -0,0 +1,86 @@
.user-menu {
position: relative;
display: inline-block;
}
.user-menu-btn {
background: #2563eb;
color: #fff;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.user-menu-btn:focus {
outline: none;
}
.user-menu-dropdown {
position: absolute;
right: 0;
top: 110%;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
min-width: 160px;
z-index: 100;
padding: 0;
margin: 0;
list-style: none;
}
.user-menu-dropdown li {
padding: 0.75rem 1rem;
cursor: pointer;
transition: background 0.2s;
}
.user-menu-dropdown li:hover {
background: #f3f4f6;
}
.start-tournament-option {
color: #166534;
font-weight: bold;
}
.start-tournament-option:hover {
background: #bbf7d0;
color: #166534;
}
.start-tournament-option.disabled {
background: none !important;
color: #166534 !important;
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
.tooltip-text {
visibility: hidden;
opacity: 0;
width: 240px;
background: #222;
color: #fff;
text-align: center;
border-radius: 6px;
padding: 8px 12px;
position: absolute;
left: 110%;
top: 50%;
transform: translateY(-50%);
z-index: 1001;
font-size: 0.95rem;
transition: opacity 0.2s;
pointer-events: none;
}
.start-tournament-option.disabled:hover .tooltip-text,
.start-tournament-option.disabled:focus .tooltip-text {
visibility: visible;
opacity: 1;
}

View File

@@ -0,0 +1,59 @@
import React, { useState, useRef, useEffect } from 'react';
import './UserMenu.css';
const UserMenu = ({ onSelect, token, onResetTournament, tournamentStarted }) => {
const [open, setOpen] = useState(false);
const menuRef = useRef(null);
useEffect(() => {
const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const handleSelect = (option) => {
setOpen(false);
if (onSelect) onSelect(option);
};
return (
<div className="user-menu" ref={menuRef}>
<button className="user-menu-btn" onClick={() => setOpen(!open)}>
Admin Menu &#x25BC;
</button>
{open && (
<ul className="user-menu-dropdown">
{!token ? (
<li onClick={() => handleSelect('profile')}>Login</li>
) : (
<>
<li onClick={() => handleSelect('create-tournament')}>Create tournament</li>
<li onClick={() => handleSelect('load-tournaments')}>Load tournaments</li>
<li
className={`start-tournament-option${tournamentStarted ? ' disabled' : ''}`}
onClick={tournamentStarted ? undefined : () => handleSelect('start-tournament')}
style={tournamentStarted ? { cursor: 'not-allowed', opacity: 0.6, position: 'relative' } : {}}
{...(tournamentStarted ? { 'data-tooltip': 'The tournament has already started. You cannot start it again.' } : {})}
>
Start tournament
{tournamentStarted && (
<span className="tooltip-text">The tournament has already started. You cannot start it again.</span>
)}
</li>
<li onClick={() => handleSelect('logout')}>Logout</li>
<li style={{ color: '#b91c1c', fontWeight: 'bold' }} onClick={() => { setOpen(false); if (onResetTournament) onResetTournament(); }}>Reset Tournament</li>
</>
)}
</ul>
)}
</div>
);
};
export default UserMenu;

View File

@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://cra.link/PWA
serviceWorkerRegistration.unregister();
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View File

@@ -0,0 +1,101 @@
// Mock leaderboard data for development
const leaderboardData = [
{
rank: 1,
name: 'Team Alpha',
score: 15,
matchesPlayed: 5,
wins: 5,
losses: 0
},
{
rank: 2,
name: 'Team Beta',
score: 12,
matchesPlayed: 5,
wins: 4,
losses: 1
},
{
rank: 3,
name: 'Team Gamma',
score: 9,
matchesPlayed: 5,
wins: 3,
losses: 2
},
{
rank: 4,
name: 'Team Delta',
score: 6,
matchesPlayed: 5,
wins: 2,
losses: 3
},
{
rank: 5,
name: 'Team Epsilon',
score: 5,
matchesPlayed: 5,
wins: 1,
losses: 4
},
{
rank: 6,
name: 'Team Zeta',
score: 4,
matchesPlayed: 5,
wins: 1,
losses: 4
},
{
rank: 7,
name: 'Team Eta',
score: 3,
matchesPlayed: 5,
wins: 1,
losses: 4
},
{
rank: 8,
name: 'Team Theta',
score: 2,
matchesPlayed: 5,
wins: 0,
losses: 5
},
{
rank: 9,
name: 'Team Iota',
score: 2,
matchesPlayed: 5,
wins: 0,
losses: 5
},
{
rank: 10,
name: 'Team Kappa',
score: 1,
matchesPlayed: 5,
wins: 0,
losses: 5
},
{
rank: 11,
name: 'Team Lambda',
score: 1,
matchesPlayed: 5,
wins: 0,
losses: 5
},
{
rank: 12,
name: 'Team Mu',
score: 0,
matchesPlayed: 5,
wins: 0,
losses: 5
}
];
export default leaderboardData;

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,13 @@
const reportWebVitals = (onPerfEntry) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@@ -0,0 +1,72 @@
/* eslint-disable no-restricted-globals */
// This service worker can be customized!
// See https://developers.google.com/web/tools/workbox/modules
// for the list of available Workbox modules, or add any other
// code you'd like.
// You can also remove this file if you'd prefer not to use a
// service worker, and the Workbox build step will be skipped.
import { clientsClaim } from 'workbox-core';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';
clientsClaim();
// Precache all of the assets generated by your build process.
// Their URLs are injected into the manifest variable below.
// This variable must be present somewhere in your service worker file,
// even if you decide not to use precaching. See https://cra.link/PWA
precacheAndRoute(self.__WB_MANIFEST);
// Set up App Shell-style routing, so that all navigation requests
// are fulfilled with your index.html shell. Learn more at
// https://developers.google.com/web/fundamentals/architecture/app-shell
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
registerRoute(
// Return false to exempt requests from being fulfilled by index.html.
({ request, url }) => {
// If this isn't a navigation, skip.
if (request.mode !== 'navigate') {
return false;
} // If this is a URL that starts with /_, skip.
if (url.pathname.startsWith('/_')) {
return false;
} // If this looks like a URL for a resource, because it contains // a file extension, skip.
if (url.pathname.match(fileExtensionRegexp)) {
return false;
} // Return true to signal that we want to use the handler.
return true;
},
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
);
// An example runtime caching route for requests that aren't handled by the
// precache, in this case same-origin .png requests like those from in public/
registerRoute(
// Add in any other file extensions or routing criteria as needed.
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'), // Customize this strategy as needed, e.g., by changing to CacheFirst.
new StaleWhileRevalidate({
cacheName: 'images',
plugins: [
// Ensure that once this runtime cache reaches a maximum size the
// least-recently used images are removed.
new ExpirationPlugin({ maxEntries: 50 }),
],
})
);
// This allows the web app to trigger skipWaiting via
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Any other custom service worker logic can go here.

View File

@@ -0,0 +1,137 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://cra.link/PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
);
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://cra.link/PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://cra.link/PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch((error) => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' },
})
.then((response) => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log('No internet connection found. App is running in offline mode.');
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
registration.unregister();
})
.catch((error) => {
console.error(error.message);
});
}
}

View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';