Added seperate courts

This commit is contained in:
2026-05-24 15:53:54 +02:00 Verified
parent b2d341642e
commit 33e623b337
18 changed files with 1787 additions and 1282 deletions
+2
View File
@@ -5,6 +5,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import Layout from './components/Layout/Layout';
import Dashboard from './pages/Dashboard';
import Login from './pages/Login';
import Courts from './pages/Courts';
import Tournament from './pages/Tournament';
export default function App() {
@@ -34,6 +35,7 @@ export default function App() {
{/* Main App Layout */}
<Route element={<Layout darkMode={darkMode} setDarkMode={setDarkMode} />}>
<Route path="/" element={<Dashboard />} />
<Route path="/courts" element={<Courts />} />
<Route path="/tournaments/:id" element={<Tournament />} />
</Route>
+5
View File
@@ -0,0 +1,5 @@
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M431.789,47.747H272.387V32.384c0-9.043-7.331-16.383-16.383-16.383s-16.383,7.34-16.383,16.383v15.362H80.211 C35.988,47.747,0,83.734,0,127.967v256.067c0,44.232,35.988,80.22,80.211,80.22h159.41v15.354 c0,9.052,7.331,16.392,16.383,16.392c9.043,0,16.374-7.339,16.383-16.392v-15.354h159.401c44.232,0,80.211-35.988,80.211-80.22 V127.967C512,83.734,476.012,47.747,431.789,47.747z M80.211,431.496c-26.172,0-47.454-21.291-47.454-47.454V127.976 c0-26.172,21.291-47.454,47.454-47.454v-0.009h159.41v350.983H80.211z M479.243,384.042c0,26.172-21.291,47.454-47.454,47.454 H272.387V80.513h159.401c26.172,0,47.454,21.291,47.454,47.454V384.042z"/>
<path d="M351.738,93.994c-9.052,0-16.383,7.331-16.383,16.383v291.238c0,9.052,7.331,16.392,16.383,16.392 c9.044,0,16.383-7.34,16.383-16.392V110.377C368.121,101.333,360.79,93.994,351.738,93.994z"/>
<path d="M160.271,93.994c-9.052,0-16.383,7.331-16.383,16.383v291.238c0,9.052,7.331,16.392,16.383,16.392 c9.043,0,16.374-7.34,16.383-16.392V110.377C176.654,101.333,169.324,93.994,160.271,93.994z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,6 +1,7 @@
// frontend/src/components/Dashboard/DashCard.tsx
import { MapPin, SlidersHorizontal, Users } from 'lucide-react';
import { SlidersHorizontal, Users } from 'lucide-react';
import CourtIcon from '../../assets/court.svg?react';
interface TournamentSummary {
id: string | number;
@@ -45,7 +46,7 @@ export default function DashCard({ t, isAdmin, onSelect, onEdit }: DashCardProps
{t.team_count} Teams
</div>
<div className="flex items-center gap-1.5 font-medium">
<MapPin size={16} className="text-orange-500" />
<CourtIcon width={16} height={16} className="text-orange-500" />
{t.court_count} Courts
</div>
@@ -1,9 +1,14 @@
// frontend/src/components/Forms/TournamentForm.tsx
import { Loader2 } from 'lucide-react';
import { Loader2, Plus } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import api from '../../services/api';
interface Court {
id: number;
name: string;
}
interface TournamentSettings {
id: string | number;
name: string;
@@ -11,7 +16,7 @@ interface TournamentSettings {
type: string;
timestamp: string;
duration: number;
courts: { name?: string }[];
courts: Court[];
teams: { name?: string }[];
}
@@ -23,33 +28,55 @@ interface TournamentFormProps {
export default function TournamentForm({ tournamentId, onSuccess, onDelete }: TournamentFormProps) {
const [initialData, setInitialData] = useState<TournamentSettings | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(!!tournamentId);
const [globalCourts, setGlobalCourts] = useState<Court[]>([]);
const [selectedCourts, setSelectedCourts] = useState<number[]>([]);
const [newCourtName, setNewCourtName] = useState('');
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
useEffect(() => {
if (!tournamentId) {
setInitialData(null);
setIsLoading(false);
return;
}
const fetchSettings = async () => {
const fetchDependencies = async () => {
try {
const data = await api.get<TournamentSettings>(`/tournaments/${tournamentId}/settings`);
setInitialData(data);
const courtsRes = await api.get<Court[]>('/courts');
setGlobalCourts(courtsRes);
if (tournamentId) {
const data = await api.get<TournamentSettings>(`/tournaments/${tournamentId}/settings`);
setInitialData(data);
setSelectedCourts(data.courts.map(c => c.id));
}
} catch (err) {
console.error(err);
setError("Failed to load tournament settings.");
setError("Failed to load tournament data.");
} finally {
setIsLoading(false);
}
};
fetchSettings();
void fetchDependencies();
}, [tournamentId]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
const handleAddCourt = async () => {
if (!newCourtName.trim()) return;
try {
const added = await api.post<Court>('/courts', { name: newCourtName.trim() });
setGlobalCourts([...globalCourts, added]);
setSelectedCourts([...selectedCourts, added.id]);
setNewCourtName('');
} catch {
setError("Failed to create new court.");
}
};
const toggleCourt = (id: number) => {
setSelectedCourts(prev =>
prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id]
);
};
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
@@ -57,7 +84,6 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
const formData = new FormData(e.currentTarget);
const rawTeams = formData.get('teams') as string;
const rawCourts = formData.get('courts') as string;
const date = formData.get('date') as string;
const startTime = formData.get('start_time') as string;
const typeRaw = formData.get('type') as string;
@@ -66,7 +92,6 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
const code = formData.get('code') as string;
const teams = rawTeams.split('\n').map(t => t.trim()).filter(t => t.length > 0);
const courts = rawCourts.split(',').map(c => c.trim()).filter(c => c.length > 0);
if (teams.length < 2) {
setError("At least 2 teams required.");
@@ -74,6 +99,12 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
return;
}
if (selectedCourts.length === 0) {
setError("Please select at least one court.");
setIsSubmitting(false);
return;
}
const timestamp = `${date}T${startTime}:00`;
const formattedType = typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1);
const parsedDuration = parseInt(duration, 10);
@@ -81,39 +112,25 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
try {
if (tournamentId) {
const basePayload = {
name,
code,
type: formattedType,
timestamp,
duration: parsedDuration
name, code, type: formattedType, timestamp, duration: parsedDuration
};
await Promise.all([
api.patch(`/tournaments/${tournamentId}`, basePayload),
api.patch(`/tournaments/${tournamentId}/teams`, teams),
api.patch(`/tournaments/${tournamentId}/courts`, courts)
api.patch(`/tournaments/${tournamentId}/courts`, selectedCourts)
]);
} else {
const fullPayload = {
name,
code,
type: formattedType,
timestamp,
duration: parsedDuration,
teams,
courts
name, code, type: formattedType, timestamp, duration: parsedDuration,
teams, courts: selectedCourts
};
await api.post('/tournaments', fullPayload);
}
onSuccess();
} catch (err: unknown) {
console.error(err);
const error = err as { detail?: string | Array<{ loc: string[]; msg: string }> };
if (Array.isArray(error.detail)) {
setError(error.detail.map((e) => `${e.loc.join('.')}: ${e.msg}`).join(', '));
} else {
setError(typeof error.detail === 'string' ? error.detail : "Error saving tournament");
}
setError("Error saving tournament");
} finally {
setIsSubmitting(false);
}
@@ -147,35 +164,17 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
<div className="space-y-4">
<div>
<label className="text-xs font-bold text-zinc-500 uppercase">Name</label>
<input
name="name"
defaultValue={initialData?.name}
required
placeholder="My Awesome Tournament"
autoFocus
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
/>
<input name="name" defaultValue={initialData?.name} required placeholder="My Awesome Tournament" autoFocus className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="font-bold text-zinc-500 text-xs uppercase">Code</label>
<input
name="code"
defaultValue={initialData?.code}
required
placeholder="••••"
autoComplete="off"
className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 text-center font-mono focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
/>
<input name="code" defaultValue={initialData?.code} required placeholder="••••" autoComplete="off" className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 text-center font-mono focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
</div>
<div>
<label className="font-bold text-zinc-500 text-xs uppercase">Type</label>
<select
name="type"
defaultValue={initialData?.type?.toLowerCase() || "double"}
className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
>
<select name="type" defaultValue={initialData?.type?.toLowerCase() || "double"} className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white">
<option value="double">Double Elimination</option>
<option value="single">Single Elimination</option>
</select>
@@ -185,43 +184,50 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
<div className="grid grid-cols-7 gap-4">
<div className="col-span-2">
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Duration</label>
<input
type="number"
name="duration"
defaultValue={initialData?.duration || 30}
min="0"
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
/>
<input type="number" name="duration" defaultValue={initialData?.duration || 30} min="0" className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
</div>
<div className="col-span-2">
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Start Time</label>
<input
type="time"
name="start_time"
defaultValue={defaultTime}
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
/>
<input type="time" name="start_time" defaultValue={defaultTime} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
</div>
<div className="col-span-3">
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Date</label>
<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 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
/>
<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 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
</div>
</div>
<div>
<label className="text-xs font-bold text-zinc-500 uppercase">Courts</label>
<input
name="courts"
placeholder="Center Court, Court 1"
defaultValue={initialData?.courts?.map(c => c.name || c).join(', ')}
required
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
/>
{/* --- NEW: GLOBAL COURT SELECTOR --- */}
<div className="p-3 bg-zinc-50 dark:bg-zinc-900/50 rounded-lg border border-zinc-200 dark:border-zinc-800">
<label className="text-xs font-bold text-zinc-500 uppercase mb-2 block">Venue Courts</label>
<div className="flex flex-wrap gap-2 mb-3">
{globalCourts.map(c => (
<button
key={c.id}
type="button"
onClick={() => toggleCourt(c.id)}
className={`px-3 py-1.5 rounded-full text-xs font-bold uppercase tracking-wider transition ${selectedCourts.includes(c.id)
? 'bg-orange-500 text-white shadow-md'
: 'bg-zinc-200 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-300 dark:hover:bg-zinc-700'
}`}
>
{c.name}
</button>
))}
{globalCourts.length === 0 && <span className="text-xs italic text-zinc-400">No courts registered yet.</span>}
</div>
<div className="flex gap-2">
<input
type="text"
value={newCourtName}
onChange={(e) => setNewCourtName(e.target.value)}
placeholder="Add new court..."
className="flex-1 bg-white dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-700 rounded px-3 py-1 text-sm focus:border-orange-500 outline-none text-zinc-900 dark:text-white"
/>
<button type="button" onClick={handleAddCourt} className="bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300 px-3 py-1 rounded transition flex items-center">
<Plus size={16} />
</button>
</div>
</div>
<div>
@@ -239,21 +245,13 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }: To
<div className="flex justify-between mt-4 pt-4 border-t border-zinc-200 dark:border-zinc-800 flex-wrap gap-y-4">
{tournamentId && onDelete && (
<button
type="button"
onClick={() => onDelete(tournamentId)}
className="text-red-500 text-sm hover:underline h-5 self-end"
>
<button type="button" onClick={() => onDelete(tournamentId)} className="text-red-500 text-sm hover:underline h-5 self-end">
Delete Tournament
</button>
)}
{!tournamentId && <div className="hidden"></div>}
<button
disabled={isSubmitting}
type="submit"
className="bg-orange-600 hover:bg-orange-500 text-white px-6 py-2 rounded font-bold shadow-lg shadow-orange-900/20 ml-auto transition active:scale-95 disabled:opacity-50"
>
<button disabled={isSubmitting} type="submit" className="bg-orange-600 hover:bg-orange-500 text-white px-6 py-2 rounded font-bold shadow-lg shadow-orange-900/20 ml-auto transition active:scale-95 disabled:opacity-50">
{isSubmitting ? 'Saving...' : (tournamentId ? 'Save Changes' : 'Create')}
</button>
</div>
+41 -23
View File
@@ -1,7 +1,8 @@
// frontend/src/components/Layout/Navbar.tsx
import { Lock, LogOut, Volleyball } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import CourtIcon from '../../assets/court.svg?react';
interface NavbarProps {
title: string;
@@ -11,39 +12,56 @@ interface NavbarProps {
}
export default function Navbar({ title, subtitle, isAuthenticated, onLogout }: NavbarProps) {
return (
<nav className="transition-colors bg-zinc-50 dark:bg-zinc-900 border-b border-zinc-300 dark:border-zinc-800 sticky top-0 z-100 px-3 sm:px-6 py-3 sm:py-4 flex justify-between items-center shadow-md shrink-0">
<Link to="/" className="flex items-center gap-2 sm:gap-4 cursor-pointer group select-none shrink-0">
<div className="p-1.5 sm:p-2.5 bg-orange-600 rounded-xl group-hover:rotate-12 transition-transform shadow-lg shadow-orange-600/30 active:scale-90">
<Volleyball className="text-white" size={20} />
</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-[9px] font-black text-zinc-500 dark:text-zinc-400 uppercase tracking-widest mt-0.5">Tournament Ops</p>
</div>
</Link>
const location = useLocation();
<div className="transition-colors absolute left-1/2 -translate-x-1/2 text-center pointer-events-none w-full max-w-35 xs:max-w-[180px] sm:max-w-100">
<div className="font-black uppercase text-[10px] sm:text-sm tracking-widest sm:tracking-[0.3em] text-zinc-900 dark:text-white truncate leading-none mb-1">
return (
<nav className="bg-white dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-800 px-4 py-3 flex items-center justify-between shadow-sm sticky top-0 z-50">
{/* Left: Logo & Main Navigation */}
<div className="flex items-center gap-6">
<Link to="/" className="flex items-center gap-2 shrink-0">
<div className="p-2 bg-orange-600 rounded-xl shadow-lg shadow-orange-600/20">
<Volleyball className="text-white" size={20} />
</div>
<div className="hidden lg:block">
<h1 className="text-lg font-black text-zinc-900 dark:text-white leading-none">VolleyManager</h1>
</div>
</Link>
<Link to="/courts" className="md:hidden p-2 text-zinc-600 dark:text-zinc-400 bg-zinc-100 hover:bg-zinc-300 dark:hover:bg-zinc-800 rounded-lg">
<CourtIcon width={20} height={20} />
</Link>
<div className="hidden md:flex items-center gap-1 bg-zinc-100 dark:bg-zinc-800 p-1 rounded-lg">
<Link to="/" className={`px-4 py-1.5 rounded-md text-xs font-black uppercase tracking-wider transition ${location.pathname === '/' ? 'bg-white dark:bg-zinc-700 shadow-sm text-orange-600' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-200'}`}>
Tournaments
</Link>
<Link to="/courts" className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-xs font-black uppercase tracking-wider transition ${location.pathname === '/courts' ? 'bg-orange-600 text-white shadow-md' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-200'}`}>
Courts
</Link>
</div>
</div>
{/* Center: Title (Responsive truncation) */}
<div className="absolute left-1/2 -translate-x-1/2 text-center hidden sm:block pointer-events-none">
<div className="font-black uppercase text-xs tracking-[0.2em] text-zinc-900 dark:text-white truncate max-w-37.5 md:max-w-62.5">
{title || 'Dashboard'}
</div>
{subtitle && (
<div className="text-[8px] sm:text-[10px] font-black text-zinc-400 dark:text-zinc-500 uppercase tracking-widest leading-none">
<div className="text-[9px] font-bold text-zinc-400 uppercase tracking-widest mt-0.5">
{subtitle}
</div>
)}
</div>
<div className="flex gap-2 sm:gap-4 items-center shrink-0">
{/* Right: Actions */}
<div className="flex items-center gap-3">
{isAuthenticated ? (
<>
<button onClick={onLogout} title="Sign Out" className="text-zinc-400 hover:text-red-500 transition active:scale-90 shrink-0">
<LogOut size={18} className="sm:size-5.5" />
</button>
</>
<button onClick={onLogout} className="p-2 text-zinc-400 hover:text-red-500 transition rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800">
<LogOut size={20} />
</button>
) : (
<Link to="/login" className="text-orange-600 font-black flex items-center gap-1.5 text-[9px] sm:text-[10px] uppercase tracking-widest hover:text-orange-500 transition group p-1.5 sm:p-2 rounded-xl hover:bg-orange-50 dark:hover:bg-orange-950/20">
<Lock size={12} className="sm:size-3.5 group-hover:-translate-y-0.5 transition-transform" /> <span className="hidden xs:inline">Login</span>
<Link to="/login" className="flex items-center gap-2 px-4 py-2 bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400 rounded-lg text-xs font-black uppercase tracking-wider hover:bg-orange-100 transition">
<Lock size={14} /> Login
</Link>
)}
</div>
@@ -1,10 +1,11 @@
// frontend/src/components/Tournament/ScoreModal.tsx
import { type SetData } from '../../types';
import { Clock, Eraser, MapPin, Trophy } from 'lucide-react';
import { Clock, Eraser, Trophy } from 'lucide-react';
import { useState } from 'react';
import Modal from '../UI/Modal';
import WhistleIcon from "../../assets/whistle.svg?react"
import CourtIcon from '../../assets/court.svg?react';
interface MatchData {
id: string | number;
@@ -64,7 +65,7 @@ const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps
</div>
<div className="h-4 w-px bg-zinc-300 dark:bg-zinc-800" />
<div className="flex items-center gap-2 text-sm font-mono text-zinc-900 dark:text-white">
<MapPin className="text-orange-500" size={16} />
<CourtIcon className="text-orange-500" width={16} height={16} />
<span>{match.court || "TBD"}</span>
</div>
</div>
+281
View File
@@ -0,0 +1,281 @@
// frontend/src/pages/Courts.tsx
import React, { useEffect, useState, useRef } from 'react';
import { Loader2, Trash, Plus } from 'lucide-react';
import { useOutletContext, Link } from 'react-router-dom';
import api from '../services/api';
import { printName } from '../utils/helpers';
import WhistleIcon from '../assets/whistle.svg?react';
import CourtIcon from '../assets/court.svg?react';
interface CourtMatch {
id: string;
tournament_id: string;
tournament_name: string;
time: string;
status: string;
match_number: number;
p1: string;
p2: string;
p1_sets: number;
p2_sets: number;
ref_name?: string;
}
interface CourtSchedule {
court: string;
matches: CourtMatch[];
}
interface GlobalCourt {
id: number;
name: string;
}
const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (id: number) => void, role: string | null }) => {
const [data, setData] = useState<CourtSchedule | null>(null);
const [currentTime, setCurrentTime] = useState<string>('');
// Auto-scroll refs
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [hasScrolled, setHasScrolled] = useState(false);
// Keep the current time perfectly updated
useEffect(() => {
const updateTime = () => {
const now = new Date();
const hours = now.getHours().toString().padStart(2, '0');
const minutes = now.getMinutes().toString().padStart(2, '0');
setCurrentTime(`${hours}:${minutes}`);
};
updateTime();
const interval = setInterval(updateTime, 60000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
const fetchSchedule = async () => {
try {
const res = await api.get<CourtSchedule>(`/courts/${courtId}/schedule`);
setData(res);
} catch (err) {
console.error(err);
}
};
void fetchSchedule();
}, [courtId]);
// AUTO-SCROLL LOGIC
useEffect(() => {
if (data && currentTime && !hasScrolled && scrollContainerRef.current) {
setTimeout(() => {
const line = scrollContainerRef.current?.querySelector('.now-line');
if (line) {
line.scrollIntoView({ behavior: 'smooth', block: 'start' });
setHasScrolled(true);
}
}, 500);
}
}, [data, currentTime, hasScrolled]);
if (!data) return <div className="w-80 shrink-0 bg-zinc-50 dark:bg-zinc-900/50 rounded-2xl flex items-center justify-center border border-zinc-200 dark:border-zinc-800"><Loader2 className="animate-spin text-orange-500" /></div>;
// --- ACTIVE INDEX LOGIC ---
let activeIndex = -1;
if (currentTime) {
// Find the index of the most recently started match
for (let i = 0; i < data.matches.length; i++) {
if (data.matches[i].time <= currentTime) {
activeIndex = i;
}
}
}
return (
<div className="w-72 md:w-80 shrink-0 flex flex-col h-full bg-zinc-100/50 dark:bg-zinc-900/20 rounded-2xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
<div className="p-3 md:p-4 bg-zinc-100 dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-800 flex justify-between items-center gap-3 shrink-0">
<div className="flex items-center gap-3 min-w-0">
<CourtIcon className="text-orange-500 shrink-0" width={20} height={20} />
<h3 className="font-black text-base md:text-lg text-zinc-900 dark:text-white uppercase tracking-wider truncate">{data.court}</h3>
</div>
{role === 'admin' && (
<button onClick={() => onDelete(courtId)} className="text-zinc-400 hover:text-red-500 transition p-1.5 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30 active:scale-90" title="Delete Court">
<Trash size={16} />
</button>
)}
</div>
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-3 space-y-2.5 pb-32">
{data.matches.length === 0 && (
<div className="text-center text-sm font-bold uppercase text-zinc-400 dark:text-zinc-600 mt-10">No matches</div>
)}
{data.matches.map((m, index) => {
const isFinished = m.status === 'Finished';
const isPastSlot = index < activeIndex; // Strictly before the active match
const isLive = index === activeIndex && !isFinished; // Currently active and incomplete
// The line is drawn right above the active index (or index 0 if the day hasn't started)
const showNowLine = (activeIndex !== -1 && index === activeIndex) || (activeIndex === -1 && index === 0);
// Styling configurations based on match state
let borderStyle = 'border-zinc-200 dark:border-zinc-800 hover:-translate-y-1 hover:shadow-md';
let textOpacity = 'text-zinc-900 dark:text-white';
let pOpacity = 'text-zinc-800 dark:text-zinc-200';
if (isFinished) {
borderStyle = 'border-orange-500/30 opacity-60 grayscale hover:opacity-100 hover:grayscale-0';
textOpacity = 'text-zinc-500';
} else if (isPastSlot) {
borderStyle = 'border-zinc-300 dark:border-zinc-700 opacity-50 grayscale hover:opacity-100 hover:grayscale-0';
textOpacity = 'text-zinc-500';
pOpacity = 'text-zinc-500';
}
if (isLive) {
borderStyle = 'border-orange-500 ring-1 ring-orange-500/20 shadow-sm hover:-translate-y-1 hover:shadow-md';
}
return (
<React.Fragment key={m.id}>
{/* --- THE --NOW-- LINE --- */}
{showNowLine && (
<div className="now-line relative flex items-center py-3 animate-in fade-in">
<div className="flex-1 border-t-2 border-red-500 rounded-full"></div>
<div className="mx-2 text-[10px] font-black text-red-600 uppercase tracking-widest bg-red-100 dark:bg-red-900/30 px-3 py-1 rounded-full border border-red-200 dark:border-red-900/50 shadow-sm">Now</div>
<div className="flex-1 border-t-2 border-red-500 rounded-full"></div>
</div>
)}
<Link
to={`/tournaments/${m.tournament_id}`}
className={`match-card block bg-white dark:bg-zinc-950 p-2.5 rounded-lg border transition-all ${borderStyle}`}
>
<div className="flex justify-between items-center mb-1.5">
<div className="flex items-center gap-2">
<div className={`text-base font-black font-mono leading-none ${textOpacity}`}>{m.time}</div>
{isLive && (
<div className="flex items-center gap-1 bg-orange-100 dark:bg-orange-900/40 text-orange-600 dark:text-orange-500 px-1.5 py-0.5 rounded-full border border-orange-200 dark:border-orange-800/50">
<span className="w-1.5 h-1.5 bg-orange-500 rounded-full animate-pulse"></span>
<span className="text-[7px] font-black uppercase tracking-widest">Live</span>
</div>
)}
</div>
<div className="text-[7px] font-black uppercase text-zinc-500 bg-zinc-100 dark:bg-zinc-900 px-1.5 py-0.5 rounded truncate max-w-22.5">
{m.tournament_name}
</div>
</div>
<div className="space-y-0.5 mb-1.5">
<div className="flex justify-between items-center text-xs leading-tight">
<span className={`font-bold truncate pr-2 ${isFinished && m.p1_sets > m.p2_sets ? 'text-orange-500' : pOpacity}`}>{printName(m.p1)}</span>
{isFinished && <span className="text-[10px] font-black font-mono bg-zinc-100 dark:bg-zinc-900 px-1.5 py-0.5 rounded text-zinc-600 dark:text-zinc-400">{m.p1_sets}</span>}
</div>
<div className="flex justify-between items-center text-xs leading-tight">
<span className={`font-bold truncate pr-2 ${isFinished && m.p2_sets > m.p1_sets ? 'text-orange-500' : pOpacity}`}>{printName(m.p2)}</span>
{isFinished && <span className="text-[10px] font-black font-mono bg-zinc-100 dark:bg-zinc-900 px-1.5 py-0.5 rounded text-zinc-600 dark:text-zinc-400">{m.p2_sets}</span>}
</div>
</div>
{m.ref_name && !isFinished && (
<div className="mt-1.5 pt-1.5 border-t border-zinc-100 dark:border-zinc-800 flex items-center gap-1 text-[8px] font-bold text-zinc-500 dark:text-zinc-400">
<WhistleIcon className="text-orange-500" width={10} height={10} />
<span className="uppercase tracking-widest truncate">{m.ref_name}</span>
</div>
)}
</Link>
</React.Fragment>
)
})}
</div>
</div>
);
};
export default function Courts() {
const { setNavTitle, setNavSubtitle, role } = useOutletContext<any>();
const [courts, setCourts] = useState<GlobalCourt[]>([]);
const [loading, setLoading] = useState(true);
const [newCourtName, setNewCourtName] = useState('');
useEffect(() => {
setNavTitle('Courts');
setNavSubtitle('');
const fetchCourts = async () => {
try {
const res = await api.get<GlobalCourt[]>('/courts');
setCourts(res);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
void fetchCourts();
}, [setNavTitle, setNavSubtitle]);
const handleDelete = async (id: number) => {
if (!window.confirm("Are you sure you want to delete this court? It will remove it from all tournaments and auto-reschedule them.")) return;
try {
await api.delete(`/courts/${id}`);
setCourts(courts.filter(c => c.id !== id));
} catch (err) {
console.error("Failed to delete court", err);
}
};
const handleAddCourt = async () => {
if (!newCourtName.trim()) return;
try {
const added = await api.post<GlobalCourt>('/courts', { name: newCourtName.trim() });
setCourts([...courts, added]);
setNewCourtName('');
} catch (err) {
console.error("Failed to create new court", err);
}
};
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 w-full overflow-hidden flex flex-col bg-white dark:bg-zinc-950 pt-8 pb-8">
<div className="flex-1 overflow-x-auto overflow-y-hidden px-6 md:px-12">
<div className="flex h-full gap-6 pb-4 min-w-max">
{courts.map(c => (
<CourtColumn key={c.id} courtId={c.id} onDelete={handleDelete} role={role} />
))}
{courts.length === 0 && role !== 'admin' && (
<div className="text-center text-zinc-500 font-bold uppercase mt-20 w-full">
No physical courts registered yet.
</div>
)}
{/* --- ADMIN PANEL: Add New Court Inline --- */}
{role === 'admin' && (
<div className="w-72 md:w-80 shrink-0 flex flex-col h-full bg-zinc-100/30 dark:bg-zinc-900/10 rounded-2xl border-2 border-dashed border-zinc-300 dark:border-zinc-800 p-6 items-center justify-center gap-4">
<div className="w-14 h-14 rounded-full bg-zinc-200 dark:bg-zinc-800 flex items-center justify-center text-zinc-400">
<CourtIcon width={32} height={32} />
</div>
<div className="text-sm font-bold text-zinc-500 uppercase text-center">Add New Court</div>
<div className="flex flex-col w-full gap-3 mt-2">
<input
value={newCourtName}
onChange={(e) => setNewCourtName(e.target.value)}
placeholder="Court name..."
className="w-full bg-white dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-700 rounded-xl px-4 py-3 text-sm focus:border-orange-500 outline-none text-zinc-900 dark:text-white shadow-sm"
/>
<button onClick={handleAddCourt} className="w-full bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300 py-3 rounded-xl font-bold uppercase text-xs tracking-wider transition flex items-center justify-center gap-2 active:scale-95">
<Plus size={16} /> Add Court
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -15,7 +15,7 @@ export default function Login() {
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);