Test
This commit is contained in:
38
tournament-frontend/src/App.css
Normal file
38
tournament-frontend/src/App.css
Normal 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);
|
||||
}
|
||||
}
|
||||
335
tournament-frontend/src/App.js
Normal file
335
tournament-frontend/src/App.js
Normal 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;
|
||||
9
tournament-frontend/src/App.test.js
Normal file
9
tournament-frontend/src/App.test.js
Normal 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();
|
||||
});
|
||||
119
tournament-frontend/src/Bracket.css
Normal file
119
tournament-frontend/src/Bracket.css
Normal 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;
|
||||
}
|
||||
}
|
||||
261
tournament-frontend/src/Bracket.js
Normal file
261
tournament-frontend/src/Bracket.js
Normal 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;
|
||||
66
tournament-frontend/src/ChangePassword.js
Normal file
66
tournament-frontend/src/ChangePassword.js
Normal 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;
|
||||
54
tournament-frontend/src/ForgotPassword.js
Normal file
54
tournament-frontend/src/ForgotPassword.js
Normal 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;
|
||||
60
tournament-frontend/src/Header.js
Normal file
60
tournament-frontend/src/Header.js
Normal 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;
|
||||
67
tournament-frontend/src/Leaderboard.css
Normal file
67
tournament-frontend/src/Leaderboard.css
Normal 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;
|
||||
}
|
||||
86
tournament-frontend/src/Leaderboard.js
Normal file
86
tournament-frontend/src/Leaderboard.js
Normal 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;
|
||||
60
tournament-frontend/src/Login.js
Normal file
60
tournament-frontend/src/Login.js
Normal 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;
|
||||
112
tournament-frontend/src/MatchesSchedule.css
Normal file
112
tournament-frontend/src/MatchesSchedule.css
Normal 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;
|
||||
}
|
||||
240
tournament-frontend/src/MatchesSchedule.js
Normal file
240
tournament-frontend/src/MatchesSchedule.js
Normal 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;
|
||||
237
tournament-frontend/src/Pools.js
Normal file
237
tournament-frontend/src/Pools.js
Normal 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;
|
||||
94
tournament-frontend/src/ProfileEdit.js
Normal file
94
tournament-frontend/src/ProfileEdit.js
Normal 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;
|
||||
41
tournament-frontend/src/ProfileView.js
Normal file
41
tournament-frontend/src/ProfileView.js
Normal 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;
|
||||
84
tournament-frontend/src/Register.js
Normal file
84
tournament-frontend/src/Register.js
Normal 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;
|
||||
64
tournament-frontend/src/ResetPassword.js
Normal file
64
tournament-frontend/src/ResetPassword.js
Normal 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;
|
||||
68
tournament-frontend/src/Results.js
Normal file
68
tournament-frontend/src/Results.js
Normal 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;
|
||||
31
tournament-frontend/src/Sidebar.css
Normal file
31
tournament-frontend/src/Sidebar.css
Normal 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;
|
||||
}
|
||||
30
tournament-frontend/src/Sidebar.js
Normal file
30
tournament-frontend/src/Sidebar.js
Normal 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;
|
||||
55
tournament-frontend/src/TeamLogo.js
Normal file
55
tournament-frontend/src/TeamLogo.js
Normal 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;
|
||||
120
tournament-frontend/src/TeamRanking.js
Normal file
120
tournament-frontend/src/TeamRanking.js
Normal 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;
|
||||
86
tournament-frontend/src/UserMenu.css
Normal file
86
tournament-frontend/src/UserMenu.css
Normal 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;
|
||||
}
|
||||
59
tournament-frontend/src/UserMenu.js
Normal file
59
tournament-frontend/src/UserMenu.js
Normal 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 ▼
|
||||
</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;
|
||||
13
tournament-frontend/src/index.css
Normal file
13
tournament-frontend/src/index.css
Normal 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;
|
||||
}
|
||||
23
tournament-frontend/src/index.js
Normal file
23
tournament-frontend/src/index.js
Normal 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();
|
||||
101
tournament-frontend/src/leaderboardData.js
Normal file
101
tournament-frontend/src/leaderboardData.js
Normal 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;
|
||||
7
tournament-frontend/src/logo.svg
Normal file
7
tournament-frontend/src/logo.svg
Normal 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 |
13
tournament-frontend/src/reportWebVitals.js
Normal file
13
tournament-frontend/src/reportWebVitals.js
Normal 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;
|
||||
72
tournament-frontend/src/service-worker.js
Normal file
72
tournament-frontend/src/service-worker.js
Normal 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.
|
||||
137
tournament-frontend/src/serviceWorkerRegistration.js
Normal file
137
tournament-frontend/src/serviceWorkerRegistration.js
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
5
tournament-frontend/src/setupTests.js
Normal file
5
tournament-frontend/src/setupTests.js
Normal 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';
|
||||
Reference in New Issue
Block a user