Better bracket logic
This commit is contained in:
@@ -1,72 +1,112 @@
|
||||
// frontend/src/pages/Dashboard.jsx
|
||||
|
||||
import { Calendar, ChevronDown, ChevronUp, History, Loader2, Plus, SlidersHorizontal, Users } from 'lucide-react';
|
||||
import { Calendar, ChevronDown, ChevronUp, History, Plus, SlidersHorizontal, Users, MapPin, Clock } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import TournamentForm from '../components/Forms/TournamentForm';
|
||||
import Modal from '../components/UI/Modal';
|
||||
import api from '../services/api';
|
||||
import api, { WS_URL } from '../services/api';
|
||||
|
||||
// --- EXACT COPY OF YOUR DASHCARD ---
|
||||
const DashCard = ({ t, isAdmin, onClick, onEdit }) => (
|
||||
<div onClick={onClick} className="bg-white dark:bg-zinc-900 rounded-3xl p-5 shadow-sm border border-zinc-200 dark:border-zinc-800 cursor-pointer hover:shadow-2xl hover:-translate-y-1.5 transition-all relative overflow-hidden group">
|
||||
<div className="absolute top-0 left-0 w-2 h-full bg-orange-600 group-hover:w-3 transition-all"></div>
|
||||
// --- RESTORED DASHCARD DESIGN ---
|
||||
const DashCard = ({ t, isAdmin, onSelect, onEdit }) => (
|
||||
<div
|
||||
onClick={() => onSelect(t.id)}
|
||||
className="block bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-sm border border-zinc-200 dark:border-zinc-800 relative group hover:shadow-md hover:scale-[1.02] transition-all duration-200 will-change-transform transform-gpu cursor-pointer"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="font-black text-xl text-zinc-900 dark:text-white truncate pr-4 leading-tight">{t.name}</h3>
|
||||
<div className="min-w-0 pr-4">
|
||||
<h2 className="text-xl font-bold truncate text-zinc-900 dark:text-white group-hover:text-orange-500 dark:group-hover:text-orange-400 transition">
|
||||
{t.name}
|
||||
</h2>
|
||||
<div className="text-xs text-zinc-400 dark:text-zinc-500 mt-1 font-mono flex items-center gap-2">
|
||||
{/* Parse date safely */}
|
||||
<span>{t.timestamp ? new Date(t.timestamp).toLocaleDateString() : 'TBD'}</span>
|
||||
<span>{t.timestamp ? new Date(t.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300 text-[10px] px-2 py-1 rounded font-mono border border-orange-200 dark:border-orange-800 uppercase tracking-tight shrink-0">
|
||||
{t.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 text-sm text-zinc-500 dark:text-zinc-400 items-center">
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<Users size={16} className="text-orange-500" />
|
||||
{t.team_count} Teams
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<MapPin size={16} className="text-orange-500" />
|
||||
{t.court_count} Courts
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(t); }}
|
||||
className="text-zinc-300 hover:text-orange-500 transition p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-2xl shrink-0"
|
||||
className="ml-auto hover:text-orange-500 transition z-10 h-8 w-8 flex items-center justify-center rounded-full text-zinc-500 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
title="Tournament Settings"
|
||||
>
|
||||
<SlidersHorizontal size={18} strokeWidth={2.5} />
|
||||
<SlidersHorizontal size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center gap-2.5 text-zinc-700 dark:text-zinc-300 font-bold text-sm tracking-tight">
|
||||
<Calendar size={16} className="text-orange-600 shrink-0" />
|
||||
{/* Use safe date parsing */}
|
||||
<span>{t.timestamp ? new Date(t.timestamp).toLocaleDateString() : 'TBD'} <span className="text-zinc-300 dark:text-zinc-700 mx-1">/</span> {t.timestamp ? new Date(t.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5 text-zinc-700 dark:text-zinc-300 font-bold text-sm tracking-tight">
|
||||
<Users size={16} className="text-orange-600 shrink-0" />
|
||||
<span>{t.team_count} Teams</span>
|
||||
</div>
|
||||
<span className="bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 rounded-lg text-[10px] font-black uppercase tracking-widest border border-zinc-200 dark:border-zinc-700 text-zinc-500">
|
||||
{t.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function Dashboard() {
|
||||
const { setNavTitle, isAdmin } = useOutletContext();
|
||||
const { setNavTitle, setNavSubtitle, isAdmin, showSettings, setShowSettings } = useOutletContext();
|
||||
|
||||
const [tournaments, setTournaments] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState(null);
|
||||
const [showPast, setShowPast] = useState(false);
|
||||
const [showAllFuture, setShowAllFuture] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const loadDashboard = async () => {
|
||||
try {
|
||||
const res = await api.get('/tournaments');
|
||||
const list = Array.isArray(res) ? res : (res.items || []);
|
||||
setTournaments(list);
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setNavTitle('Dashboard');
|
||||
loadTournaments();
|
||||
setNavSubtitle('');
|
||||
loadDashboard();
|
||||
|
||||
let ws;
|
||||
const connect = () => {
|
||||
try {
|
||||
ws = new WebSocket(WS_URL);
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'dashboard_update') loadDashboard();
|
||||
};
|
||||
} catch (err) { }
|
||||
};
|
||||
connect();
|
||||
return () => { if (ws) ws.close(); };
|
||||
}, []);
|
||||
|
||||
const loadTournaments = async () => {
|
||||
try {
|
||||
const data = await api.get('/tournaments');
|
||||
setTournaments(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const handleEdit = (t) => {
|
||||
setEditTarget(t);
|
||||
setShowSettings(true);
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
setShowSettings(false);
|
||||
setEditTarget(null);
|
||||
loadDashboard();
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (window.confirm("Purge this tournament and all its history?")) {
|
||||
await api.delete(`/tournaments/${id}`);
|
||||
handleSuccess();
|
||||
}
|
||||
};
|
||||
|
||||
// --- REPLICATED GROUPING LOGIC ---
|
||||
// Grouping Logic
|
||||
const now = new Date();
|
||||
const groups = { live: [], future: [], past: [] };
|
||||
|
||||
@@ -78,65 +118,78 @@ export default function Dashboard() {
|
||||
else groups.past.push(t);
|
||||
});
|
||||
|
||||
// Sort
|
||||
groups.future.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||
groups.live.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||
groups.past.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||
|
||||
if (loading) return <div className="flex h-full items-center justify-center"><Loader2 className="animate-spin text-orange-600" size={48} /></div>;
|
||||
const futureShow = showAllFuture ? groups.future : groups.future.slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-5xl p-6 pb-24 space-y-16 animate-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="h-full overflow-y-auto pt-8 sm:pt-12 pb-32">
|
||||
<div className="container mx-auto max-w-5xl px-4 space-y-12">
|
||||
|
||||
{/* Create Button only if Admin */}
|
||||
<div className="flex justify-end">
|
||||
{isAdmin && (
|
||||
<button onClick={() => { setEditTarget(null); setShowCreate(true); }} className="bg-orange-600 hover:bg-orange-500 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 text-[10px] font-black uppercase tracking-wider shadow-xl shadow-orange-600/20 active:scale-95 transition">
|
||||
<Plus size={16} strokeWidth={4} /> Create
|
||||
</button>
|
||||
{/* Create Button */}
|
||||
<div className="flex justify-end">
|
||||
{isAdmin && (
|
||||
<button onClick={() => { setEditTarget(null); setShowSettings(true); }} className="bg-orange-600 hover:bg-orange-500 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 text-[10px] font-black uppercase tracking-wider shadow-xl shadow-orange-600/20 active:scale-95 transition">
|
||||
<Plus size={16} strokeWidth={4} /> Create
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{groups.live.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-[10px] font-black text-green-500 uppercase tracking-[0.4em] mb-6 flex items-center gap-3">
|
||||
<div className="w-2.5 h-2.5 bg-green-500 rounded-full animate-ping shadow-lg shadow-green-500/50" /> Live Events
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{groups.live.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onSelect={(id) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="text-[10px] font-black text-zinc-400 dark:text-zinc-600 uppercase tracking-[0.4em] mb-6 flex items-center gap-3">
|
||||
<Calendar size={18} /> Upcoming
|
||||
</h2>
|
||||
{groups.future.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{futureShow.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onSelect={(id) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)}
|
||||
</div>
|
||||
{groups.future.length > 4 && (
|
||||
<div className="mt-8 text-center">
|
||||
<button onClick={() => setShowAllFuture(!showAllFuture)} className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500 hover:text-orange-500 transition border-b-2 border-transparent hover:border-orange-500 pb-1 flex items-center justify-center gap-1 mx-auto">
|
||||
{showAllFuture ? 'Show Less' : `Show All (${groups.future.length})`}
|
||||
{showAllFuture ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : <div className="p-16 text-center rounded-3xl border-2 border-dashed border-zinc-300 dark:border-zinc-800 text-zinc-400 text-xs font-black uppercase tracking-[0.3em]">No Upcoming Events</div>}
|
||||
</section>
|
||||
|
||||
{groups.past.length > 0 && (
|
||||
<section>
|
||||
<button onClick={() => setShowPast(!showPast)} className="w-full flex items-center justify-between group py-6 border-t border-zinc-300 dark:border-zinc-800 transition-colors hover:border-zinc-400">
|
||||
<h2 className="text-[10px] font-black text-zinc-400 dark:text-zinc-600 uppercase tracking-[0.4em] mb-6 flex items-center gap-3"><History size={18} />Archive</h2>
|
||||
{showPast ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
||||
</button>
|
||||
{showPast && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4 opacity-75 hover:opacity-100 transition-opacity">
|
||||
{groups.past.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onSelect={(id) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{groups.live.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-[10px] font-black text-green-500 uppercase tracking-[0.4em] mb-6 flex items-center gap-3">
|
||||
<div className="w-2.5 h-2.5 bg-green-500 rounded-full animate-ping shadow-lg shadow-green-500/50" /> Live Events
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{groups.live.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onClick={() => navigate(`/tournaments/${t.id}`)} onEdit={(item) => { setEditTarget(item); setShowCreate(true); }} />)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="text-[10px] font-black text-zinc-400 dark:text-zinc-600 uppercase tracking-[0.4em] mb-6 flex items-center gap-3">
|
||||
<Calendar size={18} /> Upcoming
|
||||
</h2>
|
||||
{groups.future.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{groups.future.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onClick={() => navigate(`/tournaments/${t.id}`)} onEdit={(item) => { setEditTarget(item); setShowCreate(true); }} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-16 text-center rounded-3xl border-2 border-dashed border-zinc-300 dark:border-zinc-800 text-zinc-400 text-xs font-black uppercase tracking-[0.3em]">No Upcoming Events</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{groups.past.length > 0 && (
|
||||
<section>
|
||||
<button onClick={() => setShowPast(!showPast)} className="w-full flex items-center justify-between group py-6 border-t border-zinc-300 dark:border-zinc-800 transition-colors hover:border-zinc-400">
|
||||
<h2 className="text-[10px] font-black text-zinc-400 dark:text-zinc-600 uppercase tracking-[0.4em] mb-6 flex items-center gap-3"><History size={18} />Archive</h2>
|
||||
{showPast ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
||||
</button>
|
||||
{showPast && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4 opacity-75 hover:opacity-100 transition-opacity">
|
||||
{groups.past.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onClick={() => navigate(`/tournaments/${t.id}`)} onEdit={(item) => { setEditTarget(item); setShowCreate(true); }} />)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title={editTarget ? "Modify Event" : "New Tournament"}>
|
||||
<TournamentForm initialData={editTarget} isEdit={!!editTarget} onSuccess={(newT) => { setShowCreate(false); loadTournaments(); }} />
|
||||
<Modal isOpen={showSettings} onClose={() => { setShowSettings(false); setEditTarget(null); }} title={editTarget ? 'Modify Event' : 'Initialize Event'}>
|
||||
<TournamentForm
|
||||
tournament={editTarget}
|
||||
onSuccess={handleSuccess}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// frontend/src/pages/Login.jsx
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Loader2, Volleyball } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Volleyball, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
|
||||
export default function Login() {
|
||||
@@ -14,23 +14,21 @@ export default function Login() {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
// The backend expects x-www-form-urlencoded for OAuth2
|
||||
const res = await api.postForm('/auth/token', formData);
|
||||
localStorage.setItem('volleyToken', res.access_token);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError('Invalid credentials. Please try again.');
|
||||
setError('Invalid credentials.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 p-4">
|
||||
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 p-4 transition-colors">
|
||||
<div className="w-full max-w-md bg-white dark:bg-zinc-900 rounded-3xl shadow-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
|
||||
<div className="p-8">
|
||||
<div className="flex justify-center mb-8">
|
||||
@@ -39,8 +37,8 @@ export default function Login() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-black text-center text-zinc-900 dark:text-white tracking-tight mb-2">Admin Access</h1>
|
||||
<p className="text-center text-zinc-500 text-sm font-medium mb-8">Enter your credentials to manage events</p>
|
||||
<h1 className="text-2xl font-black text-center text-zinc-900 dark:text-white tracking-tight mb-2">System Access</h1>
|
||||
<p className="text-center text-zinc-500 text-sm font-medium mb-8">Enter administrative credentials</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-bold uppercase tracking-wide rounded-xl text-center border border-red-100 dark:border-red-900/50">
|
||||
@@ -48,34 +46,23 @@ export default function Login() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400 ml-1">Username</label>
|
||||
<input
|
||||
name="username"
|
||||
placeholder="admin"
|
||||
required
|
||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 p-4 rounded-xl font-bold dark:text-white outline-none focus:border-orange-500 focus:ring-4 focus:ring-orange-500/10 transition"
|
||||
/>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500">Identity</label>
|
||||
<input name="username" placeholder="Admin UID" required className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-4 rounded-2xl dark:text-white outline-none focus:border-orange-500 transition font-bold" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400 ml-1">Password</label>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 p-4 rounded-xl font-bold dark:text-white outline-none focus:border-orange-500 focus:ring-4 focus:ring-orange-500/10 transition"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500">Secret Key</label>
|
||||
<input name="password" type="password" placeholder="••••••••" required className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-4 rounded-2xl dark:text-white outline-none focus:border-orange-500 transition font-bold" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-orange-600 hover:bg-orange-500 text-white p-4 rounded-xl font-black uppercase tracking-widest text-xs shadow-xl shadow-orange-600/20 transition active:scale-95 flex items-center justify-center gap-2 mt-4"
|
||||
className="w-full bg-orange-600 hover:bg-orange-500 text-white py-5 rounded-2xl font-black uppercase tracking-[0.2em] text-xs shadow-2xl shadow-orange-600/30 transition active:scale-95 mt-4 flex justify-center items-center gap-2"
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" size={18} /> : <>Sign In <ArrowRight size={18} /></>}
|
||||
{loading ? <Loader2 className="animate-spin" size={18} /> : 'Authenticate'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// frontend/src/pages/Tournament.jsx
|
||||
|
||||
import { CalendarDays, Loader2, Network, Settings } from 'lucide-react';
|
||||
import { useEffect, useState, useRef } 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 [details, setDetails] = useState(null);
|
||||
const [matches, setMatches] = useState([]);
|
||||
const [view, setView] = useState('bracket');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [scoreMatch, setScoreMatch] = useState(null);
|
||||
const wsRef = useRef(null);
|
||||
|
||||
// --- DATA PROCESSOR ---
|
||||
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] || [];
|
||||
|
||||
const p1 = m.p1_team_id ? teamMap[m.p1_team_id] : (sources[0]?.label || 'TBD');
|
||||
const p2 = m.p2_team_id ? teamMap[m.p2_team_id] : (sources[1]?.label || 'TBD');
|
||||
|
||||
const winnerName = m.winner_team_id ? teamMap[m.winner_team_id] : null;
|
||||
|
||||
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,
|
||||
winnerName,
|
||||
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,
|
||||
isReady,
|
||||
isFinished
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.get(`/tournaments/${id}`);
|
||||
setDetails(res);
|
||||
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={() => setView('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={() => setView('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 tournament={details} onSuccess={() => { setShowSettings(false); fetchData(); }} onDelete={handleDeleteTournament} />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
// frontend/src/pages/TournamentPage.jsx
|
||||
|
||||
import { CalendarDays, Loader2, Network, Settings } from 'lucide-react';
|
||||
import { useEffect, 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 TournamentPage() {
|
||||
const { id } = useParams();
|
||||
const { setNavTitle, setNavSubtitle, isAdmin } = useOutletContext();
|
||||
|
||||
const [details, setDetails] = useState(null);
|
||||
const [nodes, setNodes] = useState([]);
|
||||
const [view, setView] = useState('bracket'); // 'bracket' | 'schedule'
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [scoreMatchNode, setScoreMatchNode] = useState(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// 1. Fetch Metadata
|
||||
const meta = await api.get(`/tournaments/${id}`);
|
||||
setDetails(meta);
|
||||
setNavTitle(meta.name);
|
||||
setNavSubtitle(new Date(meta.timestamp).toLocaleDateString());
|
||||
|
||||
// 2. Fetch Structure (Nodes)
|
||||
const bracketData = await api.get(`/tournaments/${id}/bracket`);
|
||||
setNodes(bracketData);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
|
||||
// WebSocket for Live Updates
|
||||
const ws = new WebSocket(WS_URL);
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'tournament_update' && msg.id === id) {
|
||||
fetchData();
|
||||
}
|
||||
};
|
||||
return () => ws.close();
|
||||
}, [id]);
|
||||
|
||||
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">
|
||||
{/* Toolbar */}
|
||||
<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">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setView('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={() => setView('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>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
{view === 'bracket'
|
||||
? <BracketView nodes={nodes} onMatchClick={setScoreMatchNode} />
|
||||
: <ScheduleView nodes={nodes} onMatchClick={setScoreMatchNode} />
|
||||
}
|
||||
</div>
|
||||
|
||||
{scoreMatchNode && (
|
||||
<ScoreModal
|
||||
isOpen={!!scoreMatchNode}
|
||||
onClose={() => setScoreMatchNode(null)}
|
||||
node={scoreMatchNode}
|
||||
tournamentId={id}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal isOpen={showSettings} onClose={() => setShowSettings(false)} title="Edit Tournament">
|
||||
<TournamentForm initialData={details} onSuccess={() => { setShowSettings(false); fetchData(); }} isEdit />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user