Added seperate courts
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user