Frontend v.0.1
This commit is contained in:
@@ -5,7 +5,6 @@ from enum import Enum
|
|||||||
class TournamentTypes(str, Enum):
|
class TournamentTypes(str, Enum):
|
||||||
SINGLE = "Single"
|
SINGLE = "Single"
|
||||||
DOUBLE = "Double"
|
DOUBLE = "Double"
|
||||||
ROUND_ROBIN = "Round_Robin"
|
|
||||||
|
|
||||||
|
|
||||||
class BracketType(str, Enum):
|
class BracketType(str, Enum):
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ async def lifespan(app: FastAPI):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Tournament Bracket API", lifespan=lifespan)
|
app = FastAPI(title="Tournament Bracket API", lifespan=lifespan, root_path="/api")
|
||||||
app.include_router(tournaments.router)
|
app.include_router(tournaments.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(websocket.router)
|
app.include_router(websocket.router)
|
||||||
|
|||||||
Generated
+994
-70
File diff suppressed because it is too large
Load Diff
+11
-2
@@ -4,15 +4,21 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest"
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"axios": "^1.13.5",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
@@ -21,11 +27,14 @@
|
|||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"autoprefixer": "^10.4.24",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.0.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-31
@@ -1,36 +1,39 @@
|
|||||||
// frontend/src/App.jsx
|
// frontend/src/App.jsx
|
||||||
import { useState } from 'react'
|
|
||||||
import reactLogo from './assets/react.svg'
|
|
||||||
import viteLogo from './assets/vite.svg'
|
|
||||||
import './App.css'
|
|
||||||
|
|
||||||
function App() {
|
import React, { useState, useEffect } from 'react';
|
||||||
const [count, setCount] = useState(0)
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import Layout from './components/Layout/Layout';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import TournamentPage from './pages/TournamentPage';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [darkMode, setDarkMode] = useState(() => localStorage.theme === 'dark');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
if (darkMode) {
|
||||||
|
root.classList.add('dark');
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark');
|
||||||
|
localStorage.setItem('theme', 'light');
|
||||||
|
}
|
||||||
|
}, [darkMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<BrowserRouter>
|
||||||
<div>
|
<Routes>
|
||||||
<a href="https://vite.dev" target="_blank">
|
<Route path="/login" element={<Login />} />
|
||||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
|
||||||
</a>
|
|
||||||
<a href="https://react.dev" target="_blank">
|
|
||||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<h1>Vite + React</h1>
|
|
||||||
<div className="card">
|
|
||||||
<button onClick={() => setCount((count) => count + 1)}>
|
|
||||||
count is {count}
|
|
||||||
</button>
|
|
||||||
<p>
|
|
||||||
Edit <code>src/App.jsx</code> and save to test HMR
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="read-the-docs">
|
|
||||||
Click on the Vite and React logos to learn more
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
{/* Main App Layout */}
|
||||||
|
<Route element={<Layout darkMode={darkMode} setDarkMode={setDarkMode} />}>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/tournaments/:id" element={<TournamentPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// frontend/src/components/Bracket/BracketNode.jsx
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import ScoreModal from '../Tournament/ScoreModal';
|
||||||
|
|
||||||
|
export default function BracketNode({ node, tournamentId, isAdmin, isFinal }) {
|
||||||
|
const [showScore, setShowScore] = useState(false);
|
||||||
|
const match = node.match;
|
||||||
|
|
||||||
|
// Display Logic
|
||||||
|
const p1 = node.p1_team;
|
||||||
|
const p2 = node.p2_team;
|
||||||
|
const isBye = !p2 && node.round_number === 1; // Simplistic BYE detection
|
||||||
|
|
||||||
|
// Don't show score modal if it's not a real match
|
||||||
|
const canInteract = match && !isBye;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"relative w-64 bg-white dark:bg-zinc-900 rounded-lg border shadow-sm transition-all overflow-hidden group",
|
||||||
|
match?.winner_team_id ? "border-orange-500/50 dark:border-orange-500/50" : "border-zinc-300 dark:border-zinc-800",
|
||||||
|
canInteract ? "cursor-pointer hover:border-orange-500 hover:shadow-md" : "opacity-80"
|
||||||
|
)}
|
||||||
|
onClick={() => canInteract && setShowScore(true)}
|
||||||
|
>
|
||||||
|
{/* Anchors for Lines */}
|
||||||
|
<div id={`node-left-${node.id}`} className="absolute top-1/2 -left-1 w-1 h-1" />
|
||||||
|
<div id={`node-right-${node.id}`} className="absolute top-1/2 -right-1 w-1 h-1" />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center px-3 py-1.5 bg-zinc-50 dark:bg-zinc-950/50 border-b border-zinc-200 dark:border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[9px] font-black text-zinc-400">#{node.display_number}</span>
|
||||||
|
{node.match?.court && (
|
||||||
|
<span className="text-[8px] font-bold text-white px-1.5 py-0.5 rounded bg-[#0891b2] uppercase tracking-wider">
|
||||||
|
{node.match.court.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] font-bold text-zinc-500 font-mono">
|
||||||
|
{new Date(node.match?.start_time || node.planned_start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Teams */}
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
{/* P1 Row */}
|
||||||
|
<div className="flex justify-between items-center px-1 rounded hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition">
|
||||||
|
<span className={clsx(
|
||||||
|
"text-xs font-bold uppercase truncate max-w-[180px]",
|
||||||
|
match?.winner_team_id && match.winner_team_id === p1?.id ? "text-orange-600 dark:text-orange-500" : "text-zinc-700 dark:text-zinc-300",
|
||||||
|
!p1 && "text-zinc-400 italic font-medium"
|
||||||
|
)}>
|
||||||
|
{p1 ? p1.name : (node.source_p1_type ? 'TBD' : 'Bye')}
|
||||||
|
</span>
|
||||||
|
{match && <span className="text-[10px] font-mono font-black text-zinc-400 dark:text-zinc-600 bg-zinc-100 dark:bg-zinc-800 px-1.5 rounded">{getWinCount(match, p1?.id)}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* P2 Row */}
|
||||||
|
<div className="flex justify-between items-center px-1 rounded hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition">
|
||||||
|
<span className={clsx(
|
||||||
|
"text-xs font-bold uppercase truncate max-w-[180px]",
|
||||||
|
match?.winner_team_id && match.winner_team_id === p2?.id ? "text-orange-600 dark:text-orange-500" : "text-zinc-700 dark:text-zinc-300",
|
||||||
|
!p2 && "text-zinc-400 italic font-medium"
|
||||||
|
)}>
|
||||||
|
{p2 ? p2.name : (node.source_p2_type ? 'TBD' : 'Bye')}
|
||||||
|
</span>
|
||||||
|
{match && <span className="text-[10px] font-mono font-black text-zinc-400 dark:text-zinc-600 bg-zinc-100 dark:bg-zinc-800 px-1.5 rounded">{getWinCount(match, p2?.id)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{match && (
|
||||||
|
<ScoreModal
|
||||||
|
isOpen={showScore}
|
||||||
|
onClose={() => setShowScore(false)}
|
||||||
|
match={match}
|
||||||
|
tournamentId={tournamentId}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
p1Name={p1?.name || 'TBD'}
|
||||||
|
p2Name={p2?.name || 'TBD'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWinCount(match, teamId) {
|
||||||
|
if (!match.sets || !teamId) return 0;
|
||||||
|
let wins = 0;
|
||||||
|
match.sets.forEach(s => {
|
||||||
|
if (teamId === match.p1_team_id && s.p1 > s.p2) wins++;
|
||||||
|
if (teamId === match.p2_team_id && s.p2 > s.p1) wins++;
|
||||||
|
});
|
||||||
|
return wins;
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// frontend/src/components/Bracket/BracketView.jsx
|
||||||
|
|
||||||
|
import { Check, Trophy } from 'lucide-react';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { stringToColor } from '../../utils/helpers';
|
||||||
|
|
||||||
|
// --- EXACT COPY OF YOUR OLD MATCHCARD (Adapted data props) ---
|
||||||
|
const MatchCard = ({ node, onClick }) => {
|
||||||
|
// GHOST NODE LOGIC: If no display number, it's a structural/hidden node
|
||||||
|
if (!node.display_number) return <div id={`node-${node.id}`} className="hidden" />;
|
||||||
|
|
||||||
|
const match = node.match;
|
||||||
|
const p1 = node.p1_team;
|
||||||
|
const p2 = node.p2_team;
|
||||||
|
|
||||||
|
// Old logic: "isPending" means we don't have two players yet
|
||||||
|
const isPending = !p1 || !p2;
|
||||||
|
const badgeColor = match?.court ? stringToColor(match.court.name) : null;
|
||||||
|
const timeDisplay = match?.start_time || node.planned_start_time;
|
||||||
|
|
||||||
|
// Calculate wins for display
|
||||||
|
const getWins = (teamId) => {
|
||||||
|
if (!match?.sets) return 0;
|
||||||
|
return match.sets.reduce((acc, s) => acc + (s.p1 > s.p2 && match.p1_team_id === teamId ? 1 : (s.p2 > s.p1 && match.p2_team_id === teamId ? 1 : 0)), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const p1Wins = p1 ? getWins(p1.id) : 0;
|
||||||
|
const p2Wins = p2 ? getWins(p2.id) : 0;
|
||||||
|
const winnerId = match?.winner_team_id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={`node-${node.id}`}
|
||||||
|
onClick={() => !isPending && onClick(node)}
|
||||||
|
className={`w-64 bg-white dark:bg-zinc-900 rounded-xl border-2 ${winnerId ? 'border-orange-500 ring-4 ring-orange-500/10' : 'border-zinc-300 dark:border-zinc-800'} shadow-sm ${!isPending ? 'cursor-pointer hover:-translate-y-1 transition duration-200 group' : 'opacity-80 cursor-default'} overflow-hidden transition-all`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-zinc-50 dark:bg-zinc-950/50 px-3 py-2 flex justify-between items-center border-b border-zinc-200 dark:border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-[10px] font-black text-zinc-500 dark:text-zinc-500 uppercase"># {node.display_number}</span>
|
||||||
|
{match?.court && <span className="text-[9px] font-black text-white px-1.5 py-0.5 rounded uppercase" style={{ background: badgeColor }}>{match.court.name}</span>}
|
||||||
|
</div>
|
||||||
|
{winnerId ? <Check className="text-orange-500" size={14} strokeWidth={4} /> : <span className="text-[10px] font-black text-zinc-800 dark:text-zinc-300 font-mono">{timeDisplay ? new Date(timeDisplay).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : 'TBD'}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-3 space-y-1.5">
|
||||||
|
{/* P1 */}
|
||||||
|
<div className={`flex justify-between items-center ${winnerId === p1?.id ? 'text-orange-600 dark:text-orange-500 font-black' : 'text-zinc-900 dark:text-zinc-400 font-bold'}`}>
|
||||||
|
<span className="truncate text-xs uppercase tracking-tight font-bold">{p1 ? p1.name : (node.source_p1_type ? 'TBD' : 'Bye')}</span>
|
||||||
|
<span className="bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 rounded text-[10px] font-black">{p1Wins}</span>
|
||||||
|
</div>
|
||||||
|
{/* P2 */}
|
||||||
|
<div className={`flex justify-between items-center ${winnerId === p2?.id ? 'text-orange-600 dark:text-orange-500 font-black' : 'text-zinc-900 dark:text-zinc-400 font-bold'}`}>
|
||||||
|
<span className="truncate text-xs uppercase tracking-tight font-bold">{p2 ? p2.name : (node.source_p2_type ? 'TBD' : 'Bye')}</span>
|
||||||
|
<span className="bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 rounded text-[10px] font-black">{p2Wins}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BracketView({ nodes, onMatchClick }) {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const svgRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// --- EXACT COPY OF YOUR SVG LOGIC ---
|
||||||
|
if (!containerRef.current || !svgRef.current) return;
|
||||||
|
const container = containerRef.current.getBoundingClientRect();
|
||||||
|
const svg = svgRef.current;
|
||||||
|
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
||||||
|
|
||||||
|
nodes.forEach(node => {
|
||||||
|
// Logic: Find DOM elements by ID
|
||||||
|
const sEl = document.getElementById(`node-${node.id}`);
|
||||||
|
const eEl = document.getElementById(`node-${node.winner_next_node_id}`);
|
||||||
|
|
||||||
|
// Only draw if both exist and are visible
|
||||||
|
if (sEl && eEl && sEl.offsetParent !== null && eEl.offsetParent !== null) {
|
||||||
|
const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
|
||||||
|
// Calculate connection points
|
||||||
|
const sx = r1.right - container.left, sy = r1.top + r1.height / 2 - container.top;
|
||||||
|
const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top;
|
||||||
|
|
||||||
|
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
const c1 = sx + (ex - sx) / 2; // Control point X
|
||||||
|
path.setAttribute("d", `M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`);
|
||||||
|
path.setAttribute("class", "stroke-zinc-300 dark:stroke-zinc-800 fill-none stroke-[2px] opacity-40");
|
||||||
|
svg.appendChild(path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [nodes]); // Re-run when nodes change
|
||||||
|
|
||||||
|
const renderTree = (list, align = 'justify-center') => {
|
||||||
|
const rounds = {};
|
||||||
|
list.forEach(n => { if (!rounds[n.round_number]) rounds[n.round_number] = []; rounds[n.round_number].push(n); });
|
||||||
|
|
||||||
|
return Object.keys(rounds)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.filter(r => rounds[r].some(n => n.display_number)) // Filter empty rounds
|
||||||
|
.map(r => (
|
||||||
|
<div key={r} className={`flex flex-col ${align} gap-12 min-w-[280px] z-10`}>
|
||||||
|
{rounds[r].sort((a, b) => a.display_number - b.display_number).map(n => (
|
||||||
|
<MatchCard key={n.id} node={n} onClick={onMatchClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const wb = nodes.filter(n => n.bracket_type === 'Winners');
|
||||||
|
const lb = nodes.filter(n => n.bracket_type === 'Losers');
|
||||||
|
const finals = nodes.filter(n => n.bracket_type === 'Finals');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full overflow-auto p-8 bg-[radial-gradient(#d1d5db_1px,transparent_1px)] dark:bg-[radial-gradient(#18181b_1px,transparent_1px)] [background-size:20px_20px]">
|
||||||
|
<div ref={containerRef} className="relative min-w-max p-4 flex gap-24">
|
||||||
|
<svg ref={svgRef} className="absolute inset-0 w-full h-full pointer-events-none z-0" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-24">
|
||||||
|
<div className="relative flex gap-16">
|
||||||
|
<div className="absolute -top-10 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500 dark:text-zinc-600">Winners Bracket</div>
|
||||||
|
{renderTree(wb)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lb.length > 0 && (
|
||||||
|
<div className="relative flex flex-col gap-12 pt-16 border-t border-zinc-300 dark:border-zinc-800 w-full">
|
||||||
|
<div className="absolute top-6 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500 dark:text-zinc-600">Losers Bracket</div>
|
||||||
|
<div className="flex gap-16 justify-start">{renderTree(lb, 'justify-start')}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{finals.length > 0 && (
|
||||||
|
<div className="flex flex-col justify-center items-center gap-4 relative z-10 min-w-[280px]">
|
||||||
|
<div className="absolute top-1/2 -translate-y-[calc(50%+140px)] flex items-center gap-2 bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest border border-orange-200 dark:border-orange-800 shadow-sm">
|
||||||
|
<Trophy size={14} /> Championship
|
||||||
|
</div>
|
||||||
|
{finals.map(n => <MatchCard key={n.id} node={n} onClick={onMatchClick} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// frontend/src/components/Forms/TournamentForm.jsx
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
export default function TournamentForm({ initialData, onSuccess }) {
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const data = Object.fromEntries(formData.entries());
|
||||||
|
|
||||||
|
// Format Arrays
|
||||||
|
const payload = {
|
||||||
|
...data,
|
||||||
|
duration: parseInt(data.duration),
|
||||||
|
timestamp: `${data.date}T${data.time}:00`,
|
||||||
|
courts: data.courts.split(',').map(s => s.trim()).filter(Boolean),
|
||||||
|
teams: data.teams.split('\n').map(s => s.trim()).filter(Boolean)
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (initialData) await api.patch(`/tournaments/${initialData.id}`, payload);
|
||||||
|
else await api.post('/tournaments', payload);
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
setError(typeof err.detail === 'string' ? err.detail : "Error saving");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!window.confirm("Purge this tournament and all its history?")) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/tournaments/${initialData.id}`);
|
||||||
|
window.location.href = '/';
|
||||||
|
} catch (err) { alert("Error deleting"); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Defaults
|
||||||
|
const defaultDate = initialData?.timestamp ? new Date(initialData.timestamp).toISOString().split('T')[0] : new Date().toISOString().split('T')[0];
|
||||||
|
const defaultTime = initialData?.timestamp ? new Date(initialData.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) : "09:00";
|
||||||
|
const defaultTeams = initialData?.teams?.map(t => t.name).join('\n');
|
||||||
|
const defaultCourts = initialData?.courts?.map(c => c.name).join(', ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm font-bold border border-red-100">{error}</div>}
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-500">Name</label>
|
||||||
|
<input name="name" defaultValue={initialData?.name} required className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-3 rounded-xl dark:text-white outline-none focus:border-orange-500 font-bold" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-500">Access Code</label>
|
||||||
|
<input name="code" defaultValue={initialData?.code} required className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-3 rounded-xl text-center font-mono dark:text-white font-bold" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-500">Type</label>
|
||||||
|
<select name="type" defaultValue={initialData?.type || "Double"} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-3 rounded-xl dark:text-white outline-none font-bold">
|
||||||
|
<option value="Double">Double Elimination</option>
|
||||||
|
<option value="Single">Single Elimination</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<input type="number" name="duration" placeholder="Min" defaultValue={initialData?.duration || 30} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-3 rounded-xl dark:text-white font-bold" />
|
||||||
|
<input type="time" name="time" defaultValue={defaultTime} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-3 rounded-xl dark:text-white font-bold" />
|
||||||
|
<input type="date" name="date" defaultValue={defaultDate} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-3 rounded-xl dark:text-white font-bold" />
|
||||||
|
</div>
|
||||||
|
<input name="courts" placeholder="Courts (e.g. Center, Court 1)" defaultValue={defaultCourts} required className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-3 rounded-xl dark:text-white font-bold" />
|
||||||
|
<textarea name="teams" placeholder="Teams (one per line)" defaultValue={defaultTeams} rows={5} required className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-3 rounded-xl font-mono text-sm dark:text-white font-bold" />
|
||||||
|
<div className="flex justify-between pt-4 border-t border-zinc-200 dark:border-zinc-800">
|
||||||
|
{initialData && <button type="button" onClick={handleDelete} className="text-red-500 text-sm font-black uppercase tracking-widest hover:underline">Delete Tournament</button>}
|
||||||
|
<button disabled={isSubmitting} type="submit" className="bg-orange-600 hover:bg-orange-500 text-white px-8 py-3 rounded-xl font-black uppercase tracking-widest text-xs transition active:scale-95 ml-auto shadow-lg shadow-orange-600/20">
|
||||||
|
{isSubmitting ? 'Saving...' : (initialData ? 'Save Changes' : 'Create Tournament')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// frontend/src/components/Layout/Layout.jsx
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Outlet, useOutletContext } from 'react-router-dom';
|
||||||
|
import Navbar from './Navbar';
|
||||||
|
import { Moon, Sun } from 'lucide-react';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
export default function Layout({ darkMode, setDarkMode }) {
|
||||||
|
const [isAdmin, setIsAdmin] = React.useState(false);
|
||||||
|
|
||||||
|
// Shared state for the navbar title, settable by child pages
|
||||||
|
const [navTitle, setNavTitle] = React.useState('');
|
||||||
|
const [navSubtitle, setNavSubtitle] = React.useState('');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/auth/check');
|
||||||
|
setIsAdmin(true); // Endpoint returns 200 OK if token valid
|
||||||
|
} catch {
|
||||||
|
setIsAdmin(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (localStorage.getItem('volleyToken')) checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 flex flex-col">
|
||||||
|
<Navbar title={navTitle} subtitle={navSubtitle} isAdmin={isAdmin} />
|
||||||
|
|
||||||
|
<main className="flex-1 relative overflow-hidden flex flex-col">
|
||||||
|
<Outlet context={{ setNavTitle, setNavSubtitle, isAdmin }} />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div className="fixed bottom-8 right-8 z-40">
|
||||||
|
<button
|
||||||
|
onClick={() => setDarkMode(!darkMode)}
|
||||||
|
className="p-4 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-full shadow-2xl transition hover:scale-110 active:scale-95 border-2 border-zinc-700 dark:border-zinc-300"
|
||||||
|
>
|
||||||
|
{darkMode ? <Sun size={24} /> : <Moon size={24} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// frontend/src/components/Layout/Navbar.jsx
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { Volleyball, LogOut, Lock } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Navbar({ title, subtitle, isAdmin }) {
|
||||||
|
const location = useLocation();
|
||||||
|
const isDashboard = location.pathname === '/';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur-lg border-b border-zinc-300 dark:border-zinc-800 sticky top-0 z-[100] px-6 py-4 flex justify-between items-center shadow-sm">
|
||||||
|
<Link to="/" className="flex items-center gap-4 cursor-pointer group select-none shrink-0">
|
||||||
|
<div className="p-2.5 bg-orange-600 rounded-xl group-hover:rotate-12 transition-transform shadow-lg shadow-orange-600/30">
|
||||||
|
<Volleyball className="text-white" size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<h1 className="text-2xl font-black tracking-tighter leading-none text-zinc-900 dark:text-white">VolleyManager</h1>
|
||||||
|
<p className="text-[10px] font-black text-zinc-500 dark:text-zinc-400 uppercase tracking-widest mt-0.5">Tournament Ops</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="absolute left-1/2 -translate-x-1/2 text-center pointer-events-none">
|
||||||
|
<div className="font-black uppercase text-sm tracking-[0.3em] text-zinc-900 dark:text-white truncate leading-none mb-1">
|
||||||
|
{title || 'Dashboard'}
|
||||||
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div className="text-[10px] font-black text-zinc-400 dark:text-zinc-500 uppercase tracking-widest leading-none">
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
{isAdmin ? (
|
||||||
|
<button
|
||||||
|
onClick={() => { localStorage.removeItem('volleyToken'); window.location.reload(); }}
|
||||||
|
className="text-zinc-400 hover:text-red-500 transition active:scale-90"
|
||||||
|
title="Logout"
|
||||||
|
>
|
||||||
|
<LogOut size={22} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link to="/login" className="text-orange-600 font-black flex items-center gap-2 text-[10px] uppercase tracking-widest hover:text-orange-500 transition group p-2 rounded-xl hover:bg-orange-50 dark:hover:bg-orange-950/20">
|
||||||
|
<Lock size={14} className="group-hover:-translate-y-0.5 transition-transform" /> <span>Login</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// frontend/src/components/Schedule/ScheduleView.jsx
|
||||||
|
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { stringToColor } from '../../utils/helpers';
|
||||||
|
|
||||||
|
export default function ScheduleView({ nodes, onMatchClick }) {
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
|
||||||
|
// Flatten and prepare matches
|
||||||
|
const schedule = nodes
|
||||||
|
.filter(n => n.match) // Only real matches
|
||||||
|
.map(n => {
|
||||||
|
const m = n.match;
|
||||||
|
return {
|
||||||
|
id: m.id, // For API calls
|
||||||
|
node: n, // Pass full node for context if needed
|
||||||
|
number: n.display_number,
|
||||||
|
time: new Date(m.start_time || n.planned_start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
timestamp: new Date(m.start_time || n.planned_start_time),
|
||||||
|
court: m.court ? m.court.name : 'TBD',
|
||||||
|
p1: n.p1_team?.name,
|
||||||
|
p2: n.p2_team?.name,
|
||||||
|
p1_label: n.source_p1_type ? `Winner of #${n.source_p1_node_id}` : 'TBD', // Simplified label logic
|
||||||
|
p2_label: n.source_p2_type ? `Winner of #${n.source_p2_node_id}` : 'TBD',
|
||||||
|
bracket: n.bracket_type,
|
||||||
|
round: n.round_number,
|
||||||
|
winner: m.winner_team_id,
|
||||||
|
p1_sets: m.sets.filter(s => s.p1 > s.p2).length,
|
||||||
|
p2_sets: m.sets.filter(s => s.p2 > s.p1).length
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
||||||
|
const filtered = schedule.filter(m =>
|
||||||
|
(m.p1 || "").toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
|
(m.p2 || "").toLowerCase().includes(filter.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto overflow-x-hidden relative flex flex-col">
|
||||||
|
{/* STICKY SEARCH BAR */}
|
||||||
|
<div className="sticky top-0 z-20 bg-zinc-50 dark:bg-zinc-950 p-6 pb-2">
|
||||||
|
<div className="relative group max-w-3xl mx-auto w-full">
|
||||||
|
<input
|
||||||
|
placeholder="Search teams..."
|
||||||
|
className="w-full bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-800 rounded-2xl p-4 pl-12 outline-none focus:ring-2 focus:ring-orange-500 transition shadow-sm text-zinc-900 dark:text-white font-bold"
|
||||||
|
value={filter}
|
||||||
|
onChange={e => setFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-400 group-focus-within:text-orange-500 transition" size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 pt-2 max-w-4xl mx-auto w-full space-y-3 pb-32">
|
||||||
|
{filtered.map(m => (
|
||||||
|
<div key={m.id} className="bg-white dark:bg-zinc-900 p-4 rounded-2xl border border-zinc-300 dark:border-zinc-800 shadow-sm flex items-center justify-between group transition-all hover:border-orange-500/30">
|
||||||
|
<div className="flex gap-6 items-center">
|
||||||
|
<div className="text-center min-w-[70px]">
|
||||||
|
<div className="text-xl font-black font-mono text-zinc-900 dark:text-white leading-none mb-1">{m.time}</div>
|
||||||
|
<div className="text-[9px] font-black text-white px-2 py-0.5 rounded uppercase tracking-wider" style={{ background: stringToColor(m.court) }}>{m.court}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-black text-base uppercase tracking-tight text-zinc-900 dark:text-zinc-100">
|
||||||
|
{m.p1 || <span className="text-zinc-400 italic lowercase font-medium">{m.p1_label}</span>}
|
||||||
|
<span className="text-zinc-300 dark:text-zinc-700 mx-2 text-xs font-black">VS</span>
|
||||||
|
{m.p2 || <span className="text-zinc-400 italic lowercase font-medium">{m.p2_label}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-black text-zinc-400 uppercase tracking-widest mt-1">Match #{m.number} • {m.bracket} Round {m.round}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{m.winner ? (
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<div className="text-orange-500 font-black text-[10px] uppercase tracking-wider mb-0.5">Finished</div>
|
||||||
|
<div className="text-sm font-black font-mono text-zinc-900 dark:text-zinc-300">{m.p1_sets} - {m.p2_sets}</div>
|
||||||
|
</div>
|
||||||
|
) : (m.p1 && m.p2) && (
|
||||||
|
<button onClick={() => onMatchClick(m.node)} className="bg-orange-600 hover:bg-orange-500 text-white text-[10px] font-black uppercase px-5 py-2.5 rounded-xl transition shadow-lg shadow-orange-600/20 active:scale-95 shrink-0">Report</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && <div className="text-center py-20 text-zinc-400 font-black uppercase tracking-widest text-xs">No matching matches found</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// frontend/src/components/Tournament/ScoreModal.jsx
|
||||||
|
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { stringToColor } from '../../utils/helpers';
|
||||||
|
import Modal from '../UI/Modal';
|
||||||
|
|
||||||
|
export default function ScoreModal({ isOpen, onClose, node, tournamentId, isAdmin }) {
|
||||||
|
const match = node.match;
|
||||||
|
|
||||||
|
const initialSets = match.sets?.length ? match.sets : [{ p1: '', p2: '' }];
|
||||||
|
const [sets, setSets] = useState(initialSets);
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await api.post(`/tournaments/${tournamentId}/matches/${match.id}/score`, {
|
||||||
|
code: code || undefined,
|
||||||
|
sets: sets.map(s => ({ p1: Number(s.p1), p2: Number(s.p2) }))
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError("Check code or scores");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = async () => {
|
||||||
|
if (!confirm("Clear match?")) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/tournaments/${tournamentId}/matches/${match.id}/score`, { params: { code: code || undefined } });
|
||||||
|
onClose();
|
||||||
|
} catch (err) { setError("Error clearing"); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSet = (idx) => {
|
||||||
|
if (sets.length > 1) setSets(sets.filter((_, i) => i !== idx));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSet = (idx, field, val) => {
|
||||||
|
const n = [...sets];
|
||||||
|
n[idx][field] = parseInt(val) || 0;
|
||||||
|
setSets(n);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeDisplay = match.start_time || node.planned_start_time;
|
||||||
|
const courtName = match.court?.name || 'TBD';
|
||||||
|
const p1Name = node.p1_team?.name || 'TBD';
|
||||||
|
const p2Name = node.p2_team?.name || 'TBD';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={`Match Protocol #${node.display_number}`}>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm font-bold border border-red-100">{error}</div>}
|
||||||
|
|
||||||
|
<div className="flex justify-around items-center bg-zinc-50 dark:bg-zinc-950 p-5 rounded-2xl border border-zinc-200 dark:border-zinc-800 shadow-inner">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 mb-1">Time</div>
|
||||||
|
<div className="text-xl font-black font-mono text-zinc-900 dark:text-white">{new Date(timeDisplay).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-10 bg-zinc-200 dark:bg-zinc-800" />
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 mb-1">Court</div>
|
||||||
|
<div className="text-xl font-black uppercase text-zinc-900 dark:text-white tracking-tighter flex items-center gap-2">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full" style={{ background: stringToColor(courtName) }} />
|
||||||
|
{courtName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isAdmin && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400">Authorization</label>
|
||||||
|
<input type="password" value={code} onChange={e => setCode(e.target.value)} placeholder="•••••" className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-3 rounded-xl text-center tracking-[0.5em] dark:text-white font-bold outline-none focus:border-orange-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-center font-black text-zinc-800 dark:text-zinc-200 items-center">
|
||||||
|
<div className="text-sm truncate uppercase tracking-tight">{p1Name}</div>
|
||||||
|
<div className="text-[10px] bg-orange-600 text-white px-3 py-1.5 rounded-full w-fit mx-auto shadow-lg shadow-orange-600/20">VS</div>
|
||||||
|
<div className="text-sm truncate uppercase tracking-tight">{p2Name}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sets.map((s, i) => (
|
||||||
|
<div key={i} className="animate-in slide-in-from-top-1 px-1">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-1 flex items-center gap-3">
|
||||||
|
<input type="number" value={s.p1} onChange={e => updateSet(i, 'p1', e.target.value)} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-4 rounded-xl text-center dark:text-white font-black text-xl outline-none focus:border-orange-500 shadow-sm" />
|
||||||
|
<div className="w-4 h-0.5 bg-zinc-300 dark:bg-zinc-700 rounded-full shrink-0" />
|
||||||
|
<input type="number" value={s.p2} onChange={e => updateSet(i, 'p2', e.target.value)} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-4 rounded-xl text-center dark:text-white font-black text-xl outline-none focus:border-orange-500 shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<button onClick={() => removeSet(i)} title="Remove Set" className="p-3 text-zinc-300 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-xl transition shrink-0 group">
|
||||||
|
<Trash2 size={20} className="group-hover:scale-110 transition-transform" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={() => setSets([...sets, { p1: '', p2: '' }])} className="w-full py-4 border-2 border-dashed border-zinc-300 dark:border-zinc-800 text-zinc-500 rounded-xl text-[10px] font-black uppercase tracking-[0.2em] hover:border-orange-500 hover:text-orange-500 transition active:bg-orange-50 dark:active:bg-orange-900/10">+ Add Set</button>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4 border-t border-zinc-100 dark:border-zinc-800">
|
||||||
|
{match.winner_team_id && <button onClick={handleClear} className="w-1/3 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded-xl font-black uppercase tracking-widest text-[10px] transition active:scale-95 border border-red-200 dark:border-red-900/50">Clear Match</button>}
|
||||||
|
<button onClick={handleSubmit} className="flex-1 bg-orange-600 hover:bg-orange-500 text-white py-4 rounded-xl font-black uppercase tracking-widest text-sm shadow-xl shadow-orange-600/20 transition active:scale-95">Submit Result</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// frontend/src/components/UI/Modal.jsx
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Modal({ isOpen, onClose, title, children }) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-md border border-zinc-200 dark:border-zinc-800 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-lg font-black text-zinc-900 dark:text-white uppercase tracking-tight">{title}</h2>
|
||||||
|
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition">
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-72
@@ -1,74 +1,4 @@
|
|||||||
/* frontend/src/index.css */
|
/* frontend/src/index.css */
|
||||||
:root {
|
@import "tailwindcss";
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
color-scheme: light dark;
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
color: rgba(255, 255, 255, 0.87);
|
|
||||||
background-color: #242424;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #646cff;
|
|
||||||
text-decoration: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 3.2em;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.25s;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:focus,
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
:root {
|
|
||||||
color: #213547;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
color: #747bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// frontend/src/pages/Dashboard.jsx
|
||||||
|
|
||||||
|
import { Calendar, ChevronDown, ChevronUp, History, Loader2, Plus, SlidersHorizontal, Users } 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';
|
||||||
|
|
||||||
|
// --- 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>
|
||||||
|
<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>
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={18} strokeWidth={2.5} />
|
||||||
|
</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 [tournaments, setTournaments] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [editTarget, setEditTarget] = useState(null);
|
||||||
|
const [showPast, setShowPast] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNavTitle('Dashboard');
|
||||||
|
loadTournaments();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTournaments = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get('/tournaments');
|
||||||
|
setTournaments(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- REPLICATED GROUPING LOGIC ---
|
||||||
|
const now = new Date();
|
||||||
|
const groups = { live: [], future: [], past: [] };
|
||||||
|
|
||||||
|
tournaments.forEach(t => {
|
||||||
|
const tDate = new Date(t.timestamp);
|
||||||
|
const isToday = tDate.toDateString() === now.toDateString();
|
||||||
|
if (tDate > now && !isToday) groups.future.push(t);
|
||||||
|
else if (isToday) groups.live.push(t);
|
||||||
|
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>;
|
||||||
|
|
||||||
|
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">
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// frontend/src/pages/Login.jsx
|
||||||
|
|
||||||
|
import React, { 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() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
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.');
|
||||||
|
} 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="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">
|
||||||
|
<div className="p-4 bg-orange-600 rounded-2xl shadow-lg shadow-orange-600/30 rotate-12">
|
||||||
|
<Volleyball className="text-white" size={40} />
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{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">
|
||||||
|
{error}
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="animate-spin" size={18} /> : <>Sign In <ArrowRight size={18} /></>}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-zinc-50 dark:bg-zinc-950/50 p-4 text-center border-t border-zinc-200 dark:border-zinc-800">
|
||||||
|
<a href="/" className="text-[10px] font-black uppercase tracking-widest text-zinc-400 hover:text-orange-600 transition">Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// frontend/src/services/api.js
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const getBackendHost = () => {
|
||||||
|
const host = window.location.hostname || 'localhost';
|
||||||
|
return host;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Updated to include /api prefix
|
||||||
|
export const API_BASE = `${window.location.protocol}//${getBackendHost()}:8000/api`;
|
||||||
|
export const WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${getBackendHost()}:8000/ws`;
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request Interceptor for Auth
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('volleyToken');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response Interceptor for Errors
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response.data,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('volleyToken');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error.response?.data || error.message);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// frontend/src/utils/helpers.js
|
||||||
|
|
||||||
|
export const stringToColor = (str) => {
|
||||||
|
if (!str) return '#71717a';
|
||||||
|
const normalized = str.trim().toLowerCase();
|
||||||
|
const salt = 'volley-standard-salt-v5';
|
||||||
|
const COURT_COLORS = ['#ea580c', '#0284c7', '#059669', '#ca8a04', '#dc2626', '#0891b2', '#e11d48', '#65a30d'];
|
||||||
|
let hash = 0;
|
||||||
|
const combined = normalized + salt;
|
||||||
|
for (let i = 0; i < combined.length; i++) hash = combined.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
return COURT_COLORS[Math.abs(hash) % COURT_COLORS.length];
|
||||||
|
};
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
// frontend/vite.config.js
|
// frontend/vite.config.js
|
||||||
import { defineConfig } from 'vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), tailwindcss()],
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
@@ -14,7 +14,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
// This ensures that /vite.svg points to the public folder correctly
|
|
||||||
"/": path.resolve(__dirname, "./public"),
|
"/": path.resolve(__dirname, "./public"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user