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

17659
tournament-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
{
"name": "tournament-frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"cra-template-pwa": "2.0.0",
"jspdf": "^3.0.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-scripts": "5.0.1",
"socket.io-client": "^4.8.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"proxy": "http://localhost:4000"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created using create-react-app" />
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-eval'">
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React Tournament App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

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';