148 lines
7.0 KiB
React
148 lines
7.0 KiB
React
// frontend/src/pages/Tournament.jsx
|
|
|
|
import { CalendarDays, Loader2, Network, Settings } from 'lucide-react';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { useOutletContext, useParams } from 'react-router-dom';
|
|
import BracketView from '../components/Bracket/BracketView';
|
|
import TournamentForm from '../components/Forms/TournamentForm';
|
|
import ScheduleView from '../components/Schedule/ScheduleView';
|
|
import ScoreModal from '../components/Tournament/ScoreModal';
|
|
import Modal from '../components/UI/Modal';
|
|
import api, { WS_URL } from '../services/api';
|
|
|
|
export default function Tournament() {
|
|
const { id } = useParams();
|
|
const { setNavTitle, setNavSubtitle, isAdmin } = useOutletContext();
|
|
|
|
const [matches, setMatches] = useState([]);
|
|
const [view, setView] = useState(() => localStorage.getItem('volley_view') || 'bracket');
|
|
const [loading, setLoading] = useState(true);
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
const [scoreMatch, setScoreMatch] = useState(null);
|
|
const wsRef = useRef(null);
|
|
|
|
const handleViewChange = (newView) => {
|
|
setView(newView);
|
|
localStorage.setItem('volley_view', newView);
|
|
};
|
|
|
|
const processMatches = (rawMatches, courts, teams) => {
|
|
if (!rawMatches) return [];
|
|
|
|
const courtMap = Object.fromEntries(courts.map(c => [c.id, c.name]));
|
|
const teamMap = Object.fromEntries(teams.map(t => [t.id, t.name]));
|
|
|
|
const incoming = {};
|
|
rawMatches.forEach(m => {
|
|
const num = m.match_number;
|
|
if (m.winner_next_match_id) {
|
|
(incoming[m.winner_next_match_id] = incoming[m.winner_next_match_id] || []).push({ label: `Winner of #${num}`, id: m.id });
|
|
}
|
|
if (m.loser_next_match_id) {
|
|
(incoming[m.loser_next_match_id] = incoming[m.loser_next_match_id] || []).push({ label: `Loser of #${num}`, id: m.id });
|
|
}
|
|
});
|
|
|
|
return rawMatches.map(m => {
|
|
const sources = incoming[m.id] || [];
|
|
|
|
let sourceIndex = 0;
|
|
const p1 = m.p1_team_id ? teamMap[m.p1_team_id] : (sources[sourceIndex++]?.label || 'TBD');
|
|
const p2 = m.p2_team_id ? teamMap[m.p2_team_id] : (sources[sourceIndex++]?.label || 'TBD');
|
|
|
|
const hasTeams = !!(m.p1_team_id && m.p2_team_id);
|
|
const isFinished = m.status === "Finished";
|
|
const isReady = hasTeams && !isFinished;
|
|
|
|
return {
|
|
...m,
|
|
bracket: m.bracket_type,
|
|
round: m.round_number,
|
|
number: m.match_number,
|
|
p1,
|
|
p2,
|
|
p1_is_real: !!m.p1_team_id,
|
|
p2_is_real: !!m.p2_team_id,
|
|
isReady,
|
|
court: courtMap[m.court_id] || 'TBD',
|
|
time: m.start_time ? new Date(m.start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '',
|
|
p1_sets: m.sets?.filter(s => s.p1 > s.p2).length || 0,
|
|
p2_sets: m.sets?.filter(s => s.p2 > s.p1).length || 0,
|
|
hasTeams,
|
|
isFinished
|
|
};
|
|
});
|
|
};
|
|
|
|
const fetchData = async () => {
|
|
try {
|
|
const res = await api.get(`/tournaments/${id}`);
|
|
setNavTitle(res.name);
|
|
setNavSubtitle(new Date(res.timestamp).toLocaleDateString());
|
|
setMatches(processMatches(res.matches, res.courts, res.teams));
|
|
} catch (err) { console.error(err); } finally { setLoading(false); }
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
if (wsRef.current) return;
|
|
const connect = () => {
|
|
const ws = new WebSocket(WS_URL);
|
|
wsRef.current = ws;
|
|
ws.onmessage = (e) => {
|
|
const msg = JSON.parse(e.data);
|
|
if (msg.type === 'tournament_update' && msg.id === id) fetchData();
|
|
};
|
|
ws.onclose = () => { wsRef.current = null; };
|
|
};
|
|
connect();
|
|
return () => { if (wsRef.current?.readyState === 1) wsRef.current.close(); wsRef.current = null; };
|
|
}, [id]);
|
|
|
|
const handleDeleteTournament = async (tId) => {
|
|
if (window.confirm("Purge this tournament?")) {
|
|
await api.delete(`/tournaments/${tId}`);
|
|
window.location.href = '/';
|
|
}
|
|
};
|
|
|
|
if (loading) return <div className="flex h-full items-center justify-center"><Loader2 className="animate-spin text-orange-600" size={48} /></div>;
|
|
|
|
return (
|
|
<div className="h-full flex flex-col">
|
|
<div className="border-b border-zinc-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-900/50 backdrop-blur px-6 py-3 flex justify-between items-center shrink-0 z-20">
|
|
<div className="flex gap-2">
|
|
<button onClick={() => handleViewChange('bracket')} className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider transition ${view === 'bracket' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'}`}>
|
|
<Network size={16} /> Bracket
|
|
</button>
|
|
<button onClick={() => handleViewChange('schedule')} className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider transition ${view === 'schedule' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'}`}>
|
|
<CalendarDays size={16} /> Schedule
|
|
</button>
|
|
</div>
|
|
{isAdmin && <button onClick={() => setShowSettings(true)} className="p-2 text-zinc-400 hover:text-orange-600 transition rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800"><Settings size={20} /></button>}
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-hidden relative">
|
|
{view === 'bracket'
|
|
? <BracketView matches={matches} onMatchClick={setScoreMatch} />
|
|
: <ScheduleView schedule={matches} onMatchClick={setScoreMatch} />
|
|
}
|
|
</div>
|
|
|
|
{scoreMatch && (
|
|
<ScoreModal
|
|
isOpen={!!scoreMatch} onClose={() => setScoreMatch(null)} match={scoreMatch} isAdmin={isAdmin}
|
|
onClear={async (mid, c) => { await api.delete(`/tournaments/${id}/matches/${mid}/score?code=${encodeURIComponent(c || '')}`); setScoreMatch(null); }}
|
|
onSubmit={async (mid, s, c) => { await api.post(`/tournaments/${id}/matches/${mid}/score`, { sets: s, code: c }); setScoreMatch(null); }}
|
|
/>
|
|
)}
|
|
<Modal isOpen={showSettings} onClose={() => setShowSettings(false)} title="Edit Tournament">
|
|
<TournamentForm
|
|
tournamentId={id}
|
|
onSuccess={() => { setShowSettings(false); fetchData(); }}
|
|
onDelete={handleDeleteTournament}
|
|
/>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
} |