Switched to typescript instead of javascript

This commit is contained in:
2026-03-11 00:17:15 +01:00 Verified
parent 4751f18ee6
commit 368dc41172
20 changed files with 550 additions and 263 deletions
@@ -1,18 +1,24 @@
// frontend/src/components/Bracket/BracketView.jsx // frontend/src/components/Bracket/BracketView.tsx
import { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import MatchCard from "./MatchCard"; import { MatchData } from '../../types';
import Podium from "../Tournament/Podium"; import Podium from "../Tournament/Podium";
import MatchCard from "./MatchCard";
export default function BracketView({ matches, onMatchClick }) { interface BracketViewProps {
const containerRef = useRef(null); matches: MatchData[];
const [lines, setLines] = useState([]); onMatchClick: (match: MatchData) => void;
}
export default function BracketView({ matches, onMatchClick }: BracketViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [lines, setLines] = useState<React.ReactElement[]>([]);
useEffect(() => { useEffect(() => {
const draw = () => { const draw = () => {
if (!containerRef.current) return; if (!containerRef.current) return;
const container = containerRef.current.getBoundingClientRect(); const container = containerRef.current.getBoundingClientRect();
const newLines = []; const newLines: React.ReactElement[] = [];
matches.forEach(m => { matches.forEach(m => {
if (m.winner_next_match_id) { if (m.winner_next_match_id) {
@@ -24,7 +30,7 @@ export default function BracketView({ matches, onMatchClick }) {
const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top; const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top;
const c1 = sx + (ex - sx) / 2; const c1 = sx + (ex - sx) / 2;
newLines.push( newLines.push(
<path key={`w-${m.id}`} d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-400/70 dark:stroke-zinc-700/70 print:!stroke-zinc-400 fill-none stroke-[2px]" /> <path key={`w-${m.id}`} d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-400/70 dark:stroke-zinc-700/70 print:stroke-zinc-400! fill-none stroke-[2px]" />
); );
} }
} }
@@ -40,7 +46,7 @@ export default function BracketView({ matches, onMatchClick }) {
const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top; const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top;
const c1 = sx + (ex - sx) / 2; const c1 = sx + (ex - sx) / 2;
newLines.push( newLines.push(
<path key={`l-${m.id}`} strokeDasharray="6 6" d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-300 dark:stroke-zinc-700 print:!stroke-zinc-400 fill-none stroke-[1.5px]" /> <path key={`l-${m.id}`} strokeDasharray="6 6" d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-300 dark:stroke-zinc-700 print:stroke-zinc-400! fill-none stroke-[1.5px]" />
); );
} }
} }
@@ -67,10 +73,12 @@ export default function BracketView({ matches, onMatchClick }) {
}; };
}, [matches]); }, [matches]);
const renderRound = (list) => { const renderRound = (list: MatchData[]) => {
const rounds = {}; const rounds: Record<number, MatchData[]> = {};
list.forEach(m => (rounds[m.round] = rounds[m.round] || []).push(m)); list.forEach(m => (rounds[m.round] = rounds[m.round] || []).push(m));
const roundKeys = Object.keys(rounds).sort((a, b) => Number(a) - Number(b));
const roundKeys = Object.keys(rounds).map(Number).sort((a, b) => a - b);
return roundKeys.map((r) => { return roundKeys.map((r) => {
let matchesInRound = rounds[r]; let matchesInRound = rounds[r];
matchesInRound.sort((a, b) => a.number - b.number); matchesInRound.sort((a, b) => a.number - b.number);
@@ -101,7 +109,7 @@ export default function BracketView({ matches, onMatchClick }) {
} }
return ( return (
<div className="transition-colors w-full h-full overflow-auto print:overflow-visible print:h-auto print:w-auto bg-zinc-50 dark:bg-zinc-950 bg-[radial-gradient(theme(colors.zinc.300)_1px,transparent_1px)] dark:bg-[radial-gradient(theme(colors.zinc.800)_1px,transparent_1px)] [background-size:20px_20px] print:!bg-white print:!bg-none"> <div className="transition-colors w-full h-full overflow-auto print:overflow-visible print:h-auto print:w-auto bg-zinc-50 dark:bg-zinc-950 bg-[radial-gradient(var(--color-zinc-300)_1px,transparent_1px)] dark:bg-[radial-gradient(var(--color-zinc-800)_1px,transparent_1px)] bg-size-[20px_20px] print:bg-white! print:bg-none!">
<style> <style>
{`@media print { {`@media print {
@page { size: landscape; margin: 0.5cm; } @page { size: landscape; margin: 0.5cm; }
@@ -116,12 +124,12 @@ export default function BracketView({ matches, onMatchClick }) {
<div className="flex flex-col gap-24"> <div className="flex flex-col gap-24">
<div className="relative"> <div className="relative">
<div className="absolute -top-8 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 print:!text-black">Winners Bracket</div> <div className="absolute -top-8 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 print:text-black!">Winners Bracket</div>
<div className="flex gap-20">{renderRound(displayWb)}</div> <div className="flex gap-20">{renderRound(displayWb)}</div>
</div> </div>
{isDoubleElim && ( {isDoubleElim && (
<div className="relative pt-8 border-t border-dashed border-zinc-300 dark:border-zinc-800 print:!border-zinc-400"> <div className="relative pt-8 border-t border-dashed border-zinc-300 dark:border-zinc-800 print:border-zinc-400!">
<div className="absolute top-4 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 print:!text-black">Losers Bracket</div> <div className="absolute top-4 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 print:text-black!">Losers Bracket</div>
<div className="flex gap-20 mt-4">{renderRound(lb)}</div> <div className="flex gap-20 mt-4">{renderRound(lb)}</div>
</div> </div>
)} )}
@@ -130,7 +138,7 @@ export default function BracketView({ matches, onMatchClick }) {
<div className="flex flex-col justify-center gap-6 z-10"> <div className="flex flex-col justify-center gap-6 z-10">
{displayFinals.length > 0 && ( {displayFinals.length > 0 && (
<div className="relative flex flex-col gap-6"> <div className="relative flex flex-col gap-6">
<div className="absolute -top-10 left-1/2 -translate-x-1/2 text-[10px] font-black uppercase bg-orange-100 dark:bg-orange-900/30 text-orange-600 print:!bg-transparent print:!border-black print:!text-black px-4 py-1.5 rounded-full border border-orange-200 dark:border-orange-800 shadow-sm whitespace-nowrap"> <div className="absolute -top-10 left-1/2 -translate-x-1/2 text-[10px] font-black uppercase bg-orange-100 dark:bg-orange-900/30 text-orange-600 print:bg-transparent! print:border-black! print:text-black! px-4 py-1.5 rounded-full border border-orange-200 dark:border-orange-800 shadow-sm whitespace-nowrap">
Championship Championship
</div> </div>
{displayFinals.map(m => <MatchCard key={m.id} match={m} onClick={onMatchClick} />)} {displayFinals.map(m => <MatchCard key={m.id} match={m} onClick={onMatchClick} />)}
@@ -138,6 +146,7 @@ export default function BracketView({ matches, onMatchClick }) {
)} )}
</div> </div>
<div className="flex flex-col justify-center gap-6 z-10"> <div className="flex flex-col justify-center gap-6 z-10">
{/* @ts-ignore - Assuming Podium was typed earlier or needs its own MatchData import */}
<Podium matches={matches} /> <Podium matches={matches} />
</div> </div>
</div> </div>
@@ -1,11 +1,17 @@
// frontend/src/components/Bracket/MatchCard.jsx // frontend/src/components/Bracket/MatchCard.tsx
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';
import { MatchData } from '../../types';
import { printName, stringToColor } from '../../utils/helpers'; import { printName, stringToColor } from '../../utils/helpers';
export default function MatchCard({ match, onClick }) { interface MatchCardProps {
match: MatchData;
onClick: (match: MatchData) => void;
}
export default function MatchCard({ match, onClick }: MatchCardProps) {
const isFinished = match.status === "Finished"; const isFinished = match.status === "Finished";
const badgeColor = match.time ? stringToColor(match.court) : null; const badgeColor = match.time ? stringToColor(match.court) : undefined;
let borderClass = 'border-zinc-300 dark:border-zinc-700'; let borderClass = 'border-zinc-300 dark:border-zinc-700';
if (isFinished) borderClass = 'border-orange-500 ring-2 ring-orange-500/10'; if (isFinished) borderClass = 'border-orange-500 ring-2 ring-orange-500/10';
@@ -19,13 +25,13 @@ export default function MatchCard({ match, onClick }) {
<div <div
id={`match-${match.id}`} id={`match-${match.id}`}
onClick={() => canInteract && onClick(match)} onClick={() => canInteract && onClick(match)}
className={`transition-colors w-64 bg-white dark:bg-zinc-900 print:!bg-white rounded-lg border ${borderClass} print:!border-zinc-400 print:!shadow-none shadow-sm transition-all duration-200 print:transition-none relative z-10 flex flex-col ${cursorClass}`} className={`transition-colors w-64 bg-white dark:bg-zinc-900 print:bg-white! rounded-lg border ${borderClass} print:border-zinc-400! print:shadow-none! shadow-sm transition-all duration-200 print:transition-none relative z-10 flex flex-col ${cursorClass}`}
> >
<div className="transition-colors bg-zinc-50 dark:bg-zinc-900/50 print:!bg-transparent px-3 py-1.5 flex justify-between items-center border-b border-zinc-200 dark:border-zinc-800 print:!border-zinc-400 rounded-t-lg"> <div className="transition-colors bg-zinc-50 dark:bg-zinc-900/50 print:bg-transparent! px-3 py-1.5 flex justify-between items-center border-b border-zinc-200 dark:border-zinc-800 print:border-zinc-400! rounded-t-lg">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono text-[10px] font-bold text-zinc-400 print:!text-zinc-600"># {match.number}</span> <span className="font-mono text-[10px] font-bold text-zinc-400 print:text-zinc-600!"># {match.number}</span>
{match.time && ( {match.time && (
<span className="text-[9px] font-black text-white print:!text-zinc-800 px-1.5 py-0.5 rounded-sm uppercase print:!border print:!border-zinc-400 print:!bg-transparent" style={{ background: badgeColor }}> <span className="text-[9px] font-black text-white print:text-zinc-800! px-1.5 py-0.5 rounded-sm uppercase print:border! print:border-zinc-400! print:bg-transparent!" style={{ background: badgeColor }}>
{match.court} {match.court}
</span> </span>
)} )}
@@ -33,7 +39,7 @@ export default function MatchCard({ match, onClick }) {
{isFinished ? ( {isFinished ? (
<Check className="text-orange-500 print:hidden" size={14} strokeWidth={3} /> <Check className="text-orange-500 print:hidden" size={14} strokeWidth={3} />
) : ( ) : (
<span className="text-[10px] font-bold text-zinc-500 print:!text-zinc-600 font-mono print:hidden">{match.time || 'TBD'}</span> <span className="text-[10px] font-bold text-zinc-500 print:text-zinc-600! font-mono print:hidden">{match.time || 'TBD'}</span>
)} )}
</div> </div>
@@ -42,7 +48,7 @@ export default function MatchCard({ match, onClick }) {
{ n: match.p1, s: match.p1_sets, win: match.winner_team_id !== null && match.winner_team_id === match.p1_team_id, real: match.p1_is_real }, { n: match.p1, s: match.p1_sets, win: match.winner_team_id !== null && match.winner_team_id === match.p1_team_id, real: match.p1_is_real },
{ n: match.p2, s: match.p2_sets, win: match.winner_team_id !== null && match.winner_team_id === match.p2_team_id, real: match.p2_is_real } { n: match.p2, s: match.p2_sets, win: match.winner_team_id !== null && match.winner_team_id === match.p2_team_id, real: match.p2_is_real }
].map((p, i) => ( ].map((p, i) => (
<div key={i} className={`flex justify-between items-center ${p.win ? 'text-zinc-800 dark:text-zinc-50 print:!text-black font-black' : p.real ? 'text-zinc-500 dark:text-zinc-400 print:!text-black' : 'text-zinc-400 print:!text-zinc-600 italic'}`}> <div key={i} className={`flex justify-between items-center ${p.win ? 'text-zinc-800 dark:text-zinc-50 print:text-black! font-black' : p.real ? 'text-zinc-500 dark:text-zinc-400 print:text-black!' : 'text-zinc-400 print:text-zinc-600! italic'}`}>
<span className={`truncate text-xs tracking-tight pr-2 print:hidden ${p.win ? ' font-black text-orange-500' : 'font-bold'}`}>{p.n}</span> <span className={`truncate text-xs tracking-tight pr-2 print:hidden ${p.win ? ' font-black text-orange-500' : 'font-bold'}`}>{p.n}</span>
@@ -1,8 +1,24 @@
// frontend/src/components/Dashboard/DashCard.jsx // frontend/src/components/Dashboard/DashCard.tsx
import { MapPin, SlidersHorizontal, Users } from 'lucide-react'; import { MapPin, SlidersHorizontal, Users } from 'lucide-react';
export default function DashCard({ t, isAdmin, onSelect, onEdit }) { interface TournamentSummary {
id: string | number;
name: string;
timestamp: string;
type: string;
team_count: number;
court_count: number;
}
interface DashCardProps {
t: TournamentSummary;
isAdmin: boolean;
onSelect: (id: string | number) => void;
onEdit: (t: TournamentSummary) => void;
}
export default function DashCard({ t, isAdmin, onSelect, onEdit }: DashCardProps) {
return ( return (
<div <div
onClick={() => onSelect(t.id)} onClick={() => onSelect(t.id)}
@@ -1,14 +1,31 @@
// frontend/src/components/Forms/TournamentForm.jsx // frontend/src/components/Forms/TournamentForm.tsx
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import api from '../../services/api'; import api from '../../services/api';
export default function TournamentForm({ tournamentId, onSuccess, onDelete }) { interface TournamentSettings {
const [initialData, setInitialData] = useState(null); id: string | number;
const [isLoading, setIsLoading] = useState(!!tournamentId); name: string;
const [error, setError] = useState(null); code: string;
const [isSubmitting, setIsSubmitting] = useState(false); type: string;
timestamp: string;
duration: number;
courts: any[];
teams: any[];
}
interface TournamentFormProps {
tournamentId?: string | number | null;
onSuccess: () => void;
onDelete?: (id: string | number) => void;
}
export default function TournamentForm({ tournamentId, onSuccess, onDelete }: TournamentFormProps) {
const [initialData, setInitialData] = useState<TournamentSettings | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(!!tournamentId);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
if (!tournamentId) { if (!tournamentId) {
@@ -19,7 +36,7 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }) {
const fetchSettings = async () => { const fetchSettings = async () => {
try { try {
const data = await api.get(`/tournaments/${tournamentId}/settings`); const data = await api.get<TournamentSettings>(`/tournaments/${tournamentId}/settings`);
setInitialData(data); setInitialData(data);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -32,21 +49,21 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }) {
fetchSettings(); fetchSettings();
}, [tournamentId]); }, [tournamentId]);
const handleSubmit = async (e) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setIsSubmitting(true); setIsSubmitting(true);
setError(null); setError(null);
const formData = new FormData(e.target); const formData = new FormData(e.currentTarget);
const rawTeams = formData.get('teams'); const rawTeams = formData.get('teams') as string;
const rawCourts = formData.get('courts'); const rawCourts = formData.get('courts') as string;
const date = formData.get('date'); const date = formData.get('date') as string;
const startTime = formData.get('start_time'); const startTime = formData.get('start_time') as string;
const typeRaw = formData.get('type'); const typeRaw = formData.get('type') as string;
const duration = formData.get('duration'); const duration = formData.get('duration') as string;
const name = formData.get('name'); const name = formData.get('name') as string;
const code = formData.get('code'); const code = formData.get('code') as string;
const teams = rawTeams.split('\n').map(t => t.trim()).filter(t => t.length > 0); 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); const courts = rawCourts.split(',').map(c => c.trim()).filter(c => c.length > 0);
@@ -59,7 +76,7 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }) {
const timestamp = `${date}T${startTime}:00`; const timestamp = `${date}T${startTime}:00`;
const formattedType = typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1); const formattedType = typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1);
const parsedDuration = parseInt(duration); const parsedDuration = parseInt(duration, 10);
try { try {
if (tournamentId) { if (tournamentId) {
@@ -89,10 +106,10 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }) {
await api.post('/tournaments', fullPayload); await api.post('/tournaments', fullPayload);
} }
onSuccess(); onSuccess();
} catch (err) { } catch (err: any) {
console.error(err); console.error(err);
if (Array.isArray(err.detail)) { if (Array.isArray(err.detail)) {
setError(err.detail.map(e => `${e.loc.join('.')}: ${e.msg}`).join(', ')); setError(err.detail.map((e: any) => `${e.loc.join('.')}: ${e.msg}`).join(', '));
} else { } else {
setError(typeof err.detail === 'string' ? err.detail : "Error saving tournament"); setError(typeof err.detail === 'string' ? err.detail : "Error saving tournament");
} }
@@ -1,16 +1,29 @@
// frontend/src/components/Layout/Layout.jsx // frontend/src/components/Layout/Layout.tsx
import { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { getToken } from '../../services/api'; import { getToken } from '../../services/api';
import ThemeButton from "../UI/ThemeButton"; import ThemeButton from "../UI/ThemeButton";
import Navbar from './Navbar'; import Navbar from './Navbar';
export default function Layout({ darkMode, setDarkMode }) { interface LayoutProps {
const [isAdmin, setIsAdmin] = useState(!!getToken()); darkMode: boolean;
const [navTitle, setNavTitle] = useState(''); setDarkMode: (value: boolean) => void;
const [navSubtitle, setNavSubtitle] = useState(''); }
const [showSettings, setShowSettings] = useState(false);
export interface OutletContextType {
setNavTitle: React.Dispatch<React.SetStateAction<string>>;
setNavSubtitle: React.Dispatch<React.SetStateAction<string>>;
isAdmin: boolean;
showSettings: boolean;
setShowSettings: React.Dispatch<React.SetStateAction<boolean>>;
}
export default function Layout({ darkMode, setDarkMode }: LayoutProps) {
const [isAdmin, setIsAdmin] = useState<boolean>(!!getToken());
const [navTitle, setNavTitle] = useState<string>('');
const [navSubtitle, setNavSubtitle] = useState<string>('');
const [showSettings, setShowSettings] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
if (navTitle) { if (navTitle) {
@@ -25,6 +38,14 @@ export default function Layout({ darkMode, setDarkMode }) {
window.location.reload(); window.location.reload();
}; };
const contextValue: OutletContextType = {
setNavTitle,
setNavSubtitle,
isAdmin,
showSettings,
setShowSettings
};
return ( return (
<div className="fixed inset-0 min-h-screen bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 transition-colors flex flex-col overflow-hidden print:static print:overflow-visible print:h-auto print:bg-white print:text-black"> <div className="fixed inset-0 min-h-screen bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 transition-colors flex flex-col overflow-hidden print:static print:overflow-visible print:h-auto print:bg-white print:text-black">
<div className="print:hidden shrink-0"> <div className="print:hidden shrink-0">
@@ -37,7 +58,7 @@ export default function Layout({ darkMode, setDarkMode }) {
</div> </div>
<main className="flex-1 overflow-hidden relative flex flex-col print:overflow-visible print:h-auto print:block"> <main className="flex-1 overflow-hidden relative flex flex-col print:overflow-visible print:h-auto print:block">
<Outlet context={{ setNavTitle, setNavSubtitle, isAdmin, showSettings, setShowSettings }} /> <Outlet context={contextValue} />
</main> </main>
<ThemeButton darkMode={darkMode} setDarkMode={setDarkMode} /> <ThemeButton darkMode={darkMode} setDarkMode={setDarkMode} />
@@ -1,11 +1,18 @@
// frontend/src/components/Layout/Navbar.jsx // frontend/src/components/Layout/Navbar.tsx
import { Lock, LogOut, Volleyball } from 'lucide-react'; import { Lock, LogOut, Volleyball } from 'lucide-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
export default function Navbar({ title, subtitle, isAdmin, onLogout }) { interface NavbarProps {
title: string;
subtitle: string;
isAdmin: boolean;
onLogout: () => void;
}
export default function Navbar({ title, subtitle, isAdmin, onLogout }: NavbarProps) {
return ( 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"> <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"> <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"> <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} /> <Volleyball className="text-white" size={20} />
@@ -16,8 +23,8 @@ export default function Navbar({ title, subtitle, isAdmin, onLogout }) {
</div> </div>
</Link> </Link>
<div className="transition-colors absolute left-1/2 -translate-x-1/2 text-center pointer-events-none w-full max-w-[140px] xs:max-w-[180px] sm:max-w-[400px]"> <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-[0.1em] sm:tracking-[0.3em] text-zinc-900 dark:text-white truncate leading-none mb-1"> <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">
{title || 'Dashboard'} {title || 'Dashboard'}
</div> </div>
{subtitle && ( {subtitle && (
@@ -31,12 +38,12 @@ export default function Navbar({ title, subtitle, isAdmin, onLogout }) {
{isAdmin ? ( {isAdmin ? (
<> <>
<button onClick={onLogout} title="Sign Out" className="text-zinc-400 hover:text-red-500 transition active:scale-90 shrink-0"> <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-[22px]" /> <LogOut size={18} className="sm:size-5.5" />
</button> </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"> <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-[14px] group-hover:-translate-y-0.5 transition-transform" /> <span className="hidden xs:inline">Login</span> <Lock size={12} className="sm:size-3.5 group-hover:-translate-y-0.5 transition-transform" /> <span className="hidden xs:inline">Login</span>
</Link> </Link>
)} )}
</div> </div>
@@ -1,22 +1,29 @@
// frontend/src/components/Schedule/ScheduleRow.jsx // frontend/src/components/Schedule/ScheduleRow.tsx
import { CheckCircle, Pencil, Plus, Trophy } from 'lucide-react'; import { CheckCircle, Pencil, Plus, Trophy } from 'lucide-react';
import React from 'react'; import React from 'react';
import { MatchData } from '../../types';
import { printName, stringToColor } from '../../utils/helpers'; import { printName, stringToColor } from '../../utils/helpers';
export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }) { interface ScheduleRowProps {
match: MatchData;
onMatchClick: (match: MatchData) => void;
badgeWidth: number;
}
export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }: ScheduleRowProps) {
const courtColor = stringToColor(m.court); const courtColor = stringToColor(m.court);
const isFinished = m.isFinished; const isFinished = m.isFinished;
return ( return (
<div className="transition-colors bg-white dark:bg-zinc-900 print:!bg-white p-4 print:p-3 rounded-2xl print:rounded-none border border-zinc-300 dark:border-zinc-800 print:!border-b print:!border-x-0 print:!border-t-0 print:!border-zinc-300 shadow-sm print:!shadow-none flex items-center justify-between transition-all hover:border-orange-500/30"> <div className="transition-colors bg-white dark:bg-zinc-900 print:bg-white! p-4 print:p-3 rounded-2xl print:rounded-none border border-zinc-300 dark:border-zinc-800 print:border-b! print:border-x-0! print:border-t-0! print:border-zinc-300! shadow-sm print:shadow-none! flex items-center justify-between hover:border-orange-500/30">
<div className="flex gap-4 md:gap-6 print:gap-6 flex-1 min-w-0"> <div className="flex gap-4 md:gap-6 print:gap-6 flex-1 min-w-0">
<div className="flex flex-col gap-1 items-center shrink-0" style={{ minWidth: badgeWidth }}> <div className="flex flex-col gap-1 items-center shrink-0" style={{ minWidth: badgeWidth }}>
<div className="text-lg md:text-xl print:text-xl font-black font-mono text-zinc-900 dark:text-white print:!text-black">{m.time}</div> <div className="text-lg md:text-xl print:text-xl font-black font-mono text-zinc-900 dark:text-white print:text-black!">{m.time}</div>
<div className="text-tiny font-black text-white print:!text-zinc-800 px-2 py-1 rounded uppercase w-full truncate text-center print:!border print:!border-zinc-400 print:!bg-transparent" style={{ background: courtColor }}> <div className="text-tiny font-black text-white print:text-zinc-800! px-2 py-1 rounded uppercase w-full truncate text-center print:border! print:border-zinc-400! print:bg-transparent!" style={{ background: courtColor }}>
{m.court} {m.court}
</div> </div>
<div className="transition-colors block md:hidden print:hidden text-tiny font-black bg-zinc-100 dark:bg-zinc-800 w-full truncate text-center print:!bg-transparent text-zinc-400 print:!text-zinc-500 px-2 py-1 rounded w-fit">Match #{m.number}</div> <div className="transition-colors block md:hidden print:hidden text-tiny font-black bg-zinc-100 dark:bg-zinc-800 w-full truncate text-center print:bg-transparent! text-zinc-400 print:text-zinc-500! px-2 py-1 rounded">Match #{m.number}</div>
</div> </div>
<div className="flex-1 flex flex-col gap-0.5 min-w-0 justify-center md:justify-between"> <div className="flex-1 flex flex-col gap-0.5 min-w-0 justify-center md:justify-between">
@@ -27,25 +34,24 @@ export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }) {
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
{p.win && <Trophy size={14} className="text-orange-500 shrink-0 print:hidden" />} {p.win && <Trophy size={14} className="text-orange-500 shrink-0 print:hidden" />}
<span className={`block print:hidden truncate text-sm md:text-base font-bold ${p.win ? 'text-orange-600' : p.real ? 'text-zinc-900 dark:text-zinc-100' : 'text-zinc-400 italic font-normal'}`}>{p.n}</span> <span className={`block print:hidden truncate text-sm md:text-base font-bold ${p.win ? 'text-orange-600' : p.real ? 'text-zinc-900 dark:text-zinc-100' : 'text-zinc-400 italic font-normal'}`}>{p.n}</span>
<span className={`hidden print:inline-block print:whitespace-normal print:overflow-visible print:text-base font-bold ${p.real ? 'print:!text-black' : 'print:!text-zinc-600 italic font-normal'}`}> <span className={`hidden print:inline-block print:whitespace-normal print:overflow-visible print:text-base font-bold ${p.real ? 'print:text-black!' : 'print:text-zinc-600! italic font-normal'}`}>
{printName(p.n)} {printName(p.n)}
</span> </span>
</div> </div>
{i === 0 && <span className="block print:block text-zinc-300 dark:text-zinc-700 print:!text-zinc-400 text-xs font-black md:pb-0.5">VS</span>} {i === 0 && <span className="block print:block text-zinc-300 dark:text-zinc-700 print:text-zinc-400! text-xs font-black md:pb-0.5">VS</span>}
</React.Fragment> </React.Fragment>
))} ))}
</div> </div>
<div className="transition-colors hidden md:block print:block text-tiny font-black bg-zinc-100 dark:bg-zinc-800 print:!bg-transparent text-zinc-400 print:!text-zinc-500 px-2 py-1 rounded w-fit">Match #{m.number}</div> <div className="transition-colors hidden md:block print:block text-tiny font-black bg-zinc-100 dark:bg-zinc-800 print:bg-transparent! text-zinc-400 print:text-zinc-500! px-2 py-1 rounded w-fit">Match #{m.number}</div>
</div> </div>
</div> </div>
{/* ACTION BUTTON AREA */} {/* ACTION BUTTON AREA */}
{/* items-stretch ensures the button fills the height on mobile to push the scores apart */}
<div className="ml-3 flex items-stretch shrink-0 print:hidden py-0.5"> <div className="ml-3 flex items-stretch shrink-0 print:hidden py-0.5">
{isFinished ? ( {isFinished ? (
<button <button
onClick={() => onMatchClick(m)} onClick={() => onMatchClick(m)}
className="flex flex-col justify-between items-center md:justify-center md:items-end hover:bg-zinc-50 dark:hover:bg-zinc-800 p-1.5 md:p-2 rounded-xl transition group/btn min-w-[32px] md:min-w-[80px] border border-transparent hover:border-zinc-200 dark:hover:border-zinc-700" className="flex flex-col justify-between items-center md:justify-center md:items-end hover:bg-zinc-50 dark:hover:bg-zinc-800 p-1.5 md:p-2 rounded-xl transition group/btn min-w-8 md:min-w-20 border border-transparent hover:border-zinc-200 dark:hover:border-zinc-700"
title="Edit Score" title="Edit Score"
> >
{/* --- MOBILE VERTICAL STACK --- */} {/* --- MOBILE VERTICAL STACK --- */}
@@ -54,7 +60,7 @@ export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }) {
</div> </div>
{/* Clean minimal vertical line for mobile */} {/* Clean minimal vertical line for mobile */}
<div className="w-[2px] h-3 bg-zinc-200 dark:bg-zinc-700 rounded-full md:hidden my-1" /> <div className="w-0.5 h-3 bg-zinc-200 dark:bg-zinc-700 rounded-full md:hidden my-1" />
<div className={`${m.winnerName === m.p2 ? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-50'} px-2 py-0.5 rounded text-[10px] font-black font-mono border border-zinc-200 dark:border-zinc-700 md:hidden`}> <div className={`${m.winnerName === m.p2 ? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-50'} px-2 py-0.5 rounded text-[10px] font-black font-mono border border-zinc-200 dark:border-zinc-700 md:hidden`}>
{m.p2_sets} {m.p2_sets}
@@ -96,6 +102,6 @@ export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }) {
{isFinished ? m.p2_sets : ''} {isFinished ? m.p2_sets : ''}
</div> </div>
</div> </div>
</div > </div>
); );
} }
@@ -1,11 +1,17 @@
// frontend/src/components/Schedule/ScheduleView.jsx // frontend/src/components/Schedule/ScheduleView.tsx
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { MatchData } from '../../types';
import ScheduleRow from './ScheduleRow'; import ScheduleRow from './ScheduleRow';
export default function ScheduleView({ schedule, onMatchClick }) { interface ScheduleViewProps {
const [filter, setFilter] = useState(""); schedule: MatchData[];
onMatchClick: (match: MatchData) => void;
}
export default function ScheduleView({ schedule, onMatchClick }: ScheduleViewProps) {
const [filter, setFilter] = useState<string>("");
const longestCourt = schedule.reduce((max, m) => { const longestCourt = schedule.reduce((max, m) => {
const c = m.court || "Court"; const c = m.court || "Court";
@@ -46,7 +52,7 @@ export default function ScheduleView({ schedule, onMatchClick }) {
</div> </div>
</div> </div>
<div className="transition-colors absolute top-0 left-0 right-3 h-32 bg-gradient-to-b from-zinc-50 via-zinc-50/95 to-transparent dark:from-zinc-950 dark:via-zinc-950/95 dark:to-transparent pointer-events-none z-20 print:hidden" /> <div className="transition-colors absolute top-0 left-0 right-3 h-32 bg-linear-to-b from-zinc-50 via-zinc-50/95 to-transparent dark:from-zinc-950 dark:via-zinc-950/95 dark:to-transparent pointer-events-none z-20 print:hidden" />
<div className="flex-1 overflow-y-auto print:overflow-visible p-6 pt-28 pb-32 print:p-0 print:pt-12 relative z-10"> <div className="flex-1 overflow-y-auto print:overflow-visible p-6 pt-28 pb-32 print:p-0 print:pt-12 relative z-10">
<div className="max-w-4xl mx-auto w-full space-y-3 print:space-y-0"> <div className="max-w-4xl mx-auto w-full space-y-3 print:space-y-0">
@@ -1,44 +1,57 @@
// frontend/src/components/Tournament/Podium.jsx // frontend/src/components/Tournament/Podium.tsx
import { Trophy } from 'lucide-react'; import { Trophy } from 'lucide-react';
import { printName } from '../../utils/helpers'; import { printName } from '../../utils/helpers';
export default function Podium({ matches }) { interface MatchInfo {
bracket: string;
round: number;
isFinished: boolean;
winnerName: string | null;
p1: string;
p2: string;
number: number;
hasTeams: boolean;
}
interface PodiumProps {
matches: MatchInfo[];
}
export default function Podium({ matches }: PodiumProps) {
const isDoubleElim = matches.some(m => m.bracket === 'Loser'); const isDoubleElim = matches.some(m => m.bracket === 'Loser');
const wb = matches.filter(m => m.bracket === 'Winner').sort((a, b) => a.round - b.round); const wb = matches.filter(m => m.bracket === 'Winner').sort((a, b) => a.round - b.round);
const lb = matches.filter(m => m.bracket === 'Loser').sort((a, b) => a.round - b.round); const lb = matches.filter(m => m.bracket === 'Loser').sort((a, b) => a.round - b.round);
const finals = matches.filter(m => m.bracket === 'Finals').sort((a, b) => a.round - b.round); const finals = matches.filter(m => m.bracket === 'Finals').sort((a, b) => a.round - b.round);
let gfMatch = null; let gfMatch: MatchInfo | null = null;
let resetMatch = null; let resetMatch: MatchInfo | null = null;
let tpMatch = null; let tpMatch: MatchInfo | null = null;
if (isDoubleElim) { if (isDoubleElim) {
tpMatch = lb[lb.length - 1]; tpMatch = lb[lb.length - 1] || null;
gfMatch = finals[0]; gfMatch = finals[0] || null;
resetMatch = finals[1]; resetMatch = finals[1] || null;
} else { } else {
gfMatch = wb[wb.length - 1]; gfMatch = wb[wb.length - 1] || null;
tpMatch = finals[0]; tpMatch = finals[0] || null;
} }
const podium = [ const podium = [
{ rank: 1, label: "1st", team: "TBD", isReal: false, color: "bg-yellow-400 text-yellow-900 dark:text-yellow-950 shadow-yellow-400/50" }, { rank: 1, label: "1st", team: "TBD", isReal: false, color: "bg-yellow-400 text-yellow-900 dark:text-yellow-950 shadow-yellow-400/50", hidden: false },
{ rank: 2, label: "2nd", team: "TBD", isReal: false, color: "bg-zinc-300 dark:bg-zinc-400 text-zinc-800 dark:text-zinc-900 shadow-zinc-400/50" }, { rank: 2, label: "2nd", team: "TBD", isReal: false, color: "bg-zinc-300 dark:bg-zinc-400 text-zinc-800 dark:text-zinc-900 shadow-zinc-400/50", hidden: false },
{ rank: 3, label: "3rd", team: "TBD", isReal: false, color: "bg-amber-600 text-amber-50 dark:text-amber-50 shadow-amber-600/50", hidden: false } { rank: 3, label: "3rd", team: "TBD", isReal: false, color: "bg-amber-600 text-amber-50 dark:text-amber-50 shadow-amber-600/50", hidden: false }
]; ];
// --- CALCULATE 3RD PLACE --- // --- CALCULATE 3RD PLACE ---
if (tpMatch) { if (tpMatch) {
if (tpMatch.isFinished && tpMatch.winnerName) { if (tpMatch.isFinished && tpMatch.winnerName) {
// If match is done, grab the actual team name
podium[2].team = isDoubleElim podium[2].team = isDoubleElim
? (tpMatch.winnerName === tpMatch.p1 ? tpMatch.p2 : tpMatch.p1) // DE: Loser of LB Final ? (tpMatch.winnerName === tpMatch.p1 ? tpMatch.p2 : tpMatch.p1)
: tpMatch.winnerName; // SE: Winner of 3rd Place Match : tpMatch.winnerName;
podium[2].isReal = true; podium[2].isReal = true;
} else { } else {
// Set the exact string expected by the print formatter
podium[2].team = isDoubleElim ? `Loser of #${tpMatch.number}` : `Winner of #${tpMatch.number}`; podium[2].team = isDoubleElim ? `Loser of #${tpMatch.number}` : `Winner of #${tpMatch.number}`;
} }
} else { } else {
@@ -53,46 +66,40 @@ export default function Podium({ matches }) {
podium[1].isReal = true; podium[1].isReal = true;
} else if (gfMatch) { } else if (gfMatch) {
if (gfMatch.isFinished && gfMatch.winnerName) { if (gfMatch.isFinished && gfMatch.winnerName) {
// Has the loser bracket champ won, forcing a reset?
const isResetForced = resetMatch && resetMatch.hasTeams; const isResetForced = resetMatch && resetMatch.hasTeams;
if (!isResetForced) { if (!isResetForced) {
// GF Winner is 1st, GF Loser is 2nd
podium[0].team = gfMatch.winnerName; podium[0].team = gfMatch.winnerName;
podium[0].isReal = true; podium[0].isReal = true;
podium[1].team = gfMatch.winnerName === gfMatch.p1 ? gfMatch.p2 : gfMatch.p1; podium[1].team = gfMatch.winnerName === gfMatch.p1 ? gfMatch.p2 : gfMatch.p1;
podium[1].isReal = true; podium[1].isReal = true;
} else { } else {
// Reset forced, wait for the final match podium[0].team = `Winner of #${resetMatch?.number || 'TBD'}`;
podium[0].team = `Winner of #${resetMatch.number}`; podium[1].team = `Loser of #${resetMatch?.number || 'TBD'}`;
podium[1].team = `Loser of #${resetMatch.number}`;
} }
} else { } else {
// GF not finished yet
podium[0].team = `Winner of #${gfMatch.number}`; podium[0].team = `Winner of #${gfMatch.number}`;
podium[1].team = `Loser of #${gfMatch.number}`; podium[1].team = `Loser of #${gfMatch.number}`;
} }
} }
return ( return (
<div className="bg-white dark:bg-zinc-900 print:!bg-white border border-zinc-200 dark:border-zinc-800 print:!border-zinc-400 rounded-xl shadow-sm w-64 overflow-hidden z-10 print:!shadow-none"> <div className="bg-white dark:bg-zinc-900 print:bg-white! border border-zinc-200 dark:border-zinc-800 print:border-zinc-400! rounded-xl shadow-sm w-64 overflow-hidden z-10 print:shadow-none!">
<div className="bg-zinc-50 dark:bg-zinc-900/50 print:!bg-transparent p-3 border-b border-zinc-200 dark:border-zinc-800 print:!border-zinc-400"> <div className="bg-zinc-50 dark:bg-zinc-900/50 print:bg-transparent! p-3 border-b border-zinc-200 dark:border-zinc-800 print:border-zinc-400!">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500 print:!text-zinc-600 text-center flex items-center justify-center gap-2"> <h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500 print:text-zinc-600! text-center flex items-center justify-center gap-2">
<Trophy size={14} className="text-orange-500 print:!text-black" /> Final Standings <Trophy size={14} className="text-orange-500 print:text-black!" /> Final Standings
</h3> </h3>
</div> </div>
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
{podium.filter(p => !p.hidden).map(p => ( {podium.filter(p => !p.hidden).map(p => (
<div key={p.rank} className="flex items-center gap-3"> <div key={p.rank} className="flex items-center gap-3">
<div className={`w-7 h-7 rounded-full flex items-center justify-center font-black text-xs shrink-0 shadow-sm print:!shadow-none print:!bg-white print:!border print:!border-zinc-400 print:!text-black ${p.color}`}> <div className={`w-7 h-7 rounded-full flex items-center justify-center font-black text-xs shrink-0 shadow-sm print:shadow-none! print:bg-white! print:border! print:border-zinc-400! print:text-black! ${p.color}`}>
{p.rank} {p.rank}
</div> </div>
{/* Web Label */} <div className={`text-sm truncate print:hidden ${p.isReal ? 'font-bold text-zinc-900 dark:text-white print:text-black!' : 'font-medium italic text-zinc-400 print:text-zinc-600!'}`} title={p.team}>
<div className={`text-sm truncate print:hidden ${p.isReal ? 'font-bold text-zinc-900 dark:text-white print:!text-black' : 'font-medium italic text-zinc-400 print:!text-zinc-600'}`} title={p.team}>
{p.team} {p.team}
</div> </div>
{/* Print Label */} <div className={`hidden print:block text-sm print:whitespace-normal print:overflow-visible ${p.isReal ? 'font-bold print:text-black!' : 'font-medium italic print:text-zinc-600!'}`}>
<div className={`hidden print:block text-sm print:whitespace-normal print:overflow-visible ${p.isReal ? 'font-bold print:!text-black' : 'font-medium italic print:!text-zinc-600'}`}>
{printName(p.team)} {printName(p.team)}
</div> </div>
</div> </div>
@@ -1,25 +1,52 @@
// frontend/src/components/Tournament/ScoreModal.jsx // frontend/src/components/Tournament/ScoreModal.tsx
import { Clock, Eraser, MapPin, Trophy } from 'lucide-react'; import { Clock, Eraser, MapPin, Trophy } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import Modal from '../UI/Modal'; import Modal from '../UI/Modal';
const ScoreForm = ({ match, isAdmin, onSubmit, onClear }) => { interface SetData {
// Safe init of sets p1: number | string;
const [sets, setSets] = useState(match.sets && match.sets.length ? match.sets : [{ p1: '', p2: '' }]); p2: number | string;
const [code, setCode] = useState(''); }
const [error, setError] = useState(null);
interface MatchData {
id: string | number;
number: number;
time: string;
court: string;
p1?: string;
p1_label?: string;
p2?: string;
p2_label?: string;
isFinished: boolean;
sets?: SetData[];
}
interface ScoreFormProps {
match: MatchData;
isAdmin: boolean;
onSubmit: (id: string | number, sets: SetData[], code: string) => Promise<void>;
onClear: (id: string | number, code: string) => Promise<void>;
}
const ScoreForm = ({ match, isAdmin, onSubmit, onClear }: ScoreFormProps) => {
const [sets, setSets] = useState<SetData[]>(match.sets && match.sets.length ? match.sets : [{ p1: '', p2: '' }]);
const [code, setCode] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => { const handleSubmit = async () => {
try { await onSubmit(match.id, sets, code); } try {
catch (err) { setError(typeof err.detail === 'string' ? err.detail : "Check code or scores"); } await onSubmit(match.id, sets, code);
} catch (err: any) {
setError(typeof err?.detail === 'string' ? err.detail : "Check code or scores");
}
}; };
const removeSet = (idx) => { const removeSet = (idx: number) => {
if (sets.length > 1) setSets(sets.filter((_, i) => i !== idx)); if (sets.length > 1) setSets(sets.filter((_, i) => i !== idx));
}; };
const updateSet = (idx, field, val) => { const updateSet = (idx: number, field: keyof SetData, val: string) => {
const n = [...sets]; const n = [...sets];
n[idx][field] = parseInt(val) || 0; n[idx][field] = parseInt(val) || 0;
setSets(n); setSets(n);
@@ -61,9 +88,9 @@ const ScoreForm = ({ match, isAdmin, onSubmit, onClear }) => {
)} )}
<div className="grid grid-cols-3 gap-2 text-center font-bold text-zinc-700 dark:text-zinc-200 items-center px-2"> <div className="grid grid-cols-3 gap-2 text-center font-bold text-zinc-700 dark:text-zinc-200 items-center px-2">
<div className="break-words text-sm leading-tight uppercase">{match.p1 || match.p1_label}</div> <div className="wrap-break-word text-sm leading-tight uppercase">{match.p1 || match.p1_label}</div>
<div className="text-zinc-400 dark:text-zinc-600 text-[10px] font-bold uppercase bg-zinc-100 dark:bg-zinc-950 px-3 py-1 rounded-full w-fit mx-auto shadow-sm">VS</div> <div className="text-zinc-400 dark:text-zinc-600 text-[10px] font-bold uppercase bg-zinc-100 dark:bg-zinc-950 px-3 py-1 rounded-full w-fit mx-auto shadow-sm">VS</div>
<div className="break-words text-sm leading-tight uppercase">{match.p2 || match.p2_label}</div> <div className="wrap-break-word text-sm leading-tight uppercase">{match.p2 || match.p2_label}</div>
</div> </div>
<div className="space-y-2 max-h-48 overflow-y-auto pr-1"> <div className="space-y-2 max-h-48 overflow-y-auto pr-1">
@@ -134,7 +161,16 @@ const ScoreForm = ({ match, isAdmin, onSubmit, onClear }) => {
); );
}; };
export default function ScoreModal({ isOpen, onClose, match, isAdmin, onSubmit, onClear }) { interface ScoreModalProps {
isOpen: boolean;
onClose: () => void;
match: MatchData | null;
isAdmin: boolean;
onSubmit: (id: string | number, sets: SetData[], code: string) => Promise<void>;
onClear: (id: string | number, code: string) => Promise<void>;
}
export default function ScoreModal({ isOpen, onClose, match, isAdmin, onSubmit, onClear }: ScoreModalProps) {
if (!isOpen || !match) return null; if (!isOpen || !match) return null;
return ( return (
@@ -1,15 +1,25 @@
// frontend/src/components/UI/Modal.jsx // frontend/src/components/UI/Modal.tsx
import { X } from 'lucide-react'; import { LucideIcon, X } from 'lucide-react';
import React from 'react';
export default function Modal({ isOpen, onClose, title, icon: Icon, children }) { interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
icon?: LucideIcon;
children: React.ReactNode;
}
export default function Modal({ isOpen, onClose, title, icon: Icon, children }: ModalProps) {
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/75 backdrop-blur-sm animate-in fade-in duration-200"> <div className="fixed inset-0 z-200 flex items-center justify-center 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-lg border border-zinc-300 dark:border-zinc-800 max-h-[90vh] overflow-y-auto"> <div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-lg border border-zinc-300 dark:border-zinc-800 max-h-[90vh] overflow-y-auto">
<div className="p-6"> <div className="p-6">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-black text-zinc-900 dark:text-white flex items-start gap-2"> <h2 className="text-xl font-black text-zinc-900 dark:text-white flex items-start gap-2">
{/* @ts-ignore - 'weight' is a specific prop if using Phosphor icons, but Lucide doesn't natively use it. Kept for compatibility. */}
{Icon && <Icon weight="duotone" className="text-orange-500 mt-1 shrink-0" size={24} />} {Icon && <Icon weight="duotone" className="text-orange-500 mt-1 shrink-0" size={24} />}
<span>{title}</span> <span>{title}</span>
</h2> </h2>
@@ -1,14 +1,19 @@
// frontend/src/components/UI/ThemeButton.jsx // frontend/src/components/UI/ThemeButton.tsx
import { Moon, Sun } from 'lucide-react'; import { Moon, Sun } from 'lucide-react';
export default function ThemeButton({ darkMode, setDarkMode }) { interface ThemeButtonProps {
darkMode: boolean;
setDarkMode: (value: boolean) => void;
}
export default function ThemeButton({ darkMode, setDarkMode }: ThemeButtonProps) {
return ( return (
<div className="fixed bottom-6 sm:bottom-8 right-6 sm:right-8 z-40 print:hidden"> <div className="fixed bottom-6 sm:bottom-8 right-6 sm:right-8 z-40 print:hidden">
<button <button
onClick={() => setDarkMode(!darkMode)} onClick={() => setDarkMode(!darkMode)}
title="Toggle Theme" title="Toggle Theme"
className="p-4 sm:p-5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-[1.5rem] sm:rounded-[2rem] shadow-2xl transition hover:scale-110 active:scale-95 border-2 border-zinc-700 dark:border-zinc-300 group" className="p-4 sm:p-5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-3xl sm:rounded-4xl shadow-2xl transition hover:scale-110 active:scale-95 border-2 border-zinc-700 dark:border-zinc-300 group"
> >
{darkMode ? <Sun size={24} strokeWidth={2.5} className="sm:size-7" /> : <Moon size={24} strokeWidth={2.5} className="sm:size-7" />} {darkMode ? <Sun size={24} strokeWidth={2.5} className="sm:size-7" /> : <Moon size={24} strokeWidth={2.5} className="sm:size-7" />}
</button> </button>
@@ -1,29 +1,45 @@
// frontend/src/pages/Dashboard.jsx // frontend/src/pages/Dashboard.tsx
import { Calendar, ChevronDown, ChevronUp, History, Plus } from 'lucide-react'; import { Calendar, ChevronDown, ChevronUp, History, Plus, PlusCircle, SlidersHorizontal } from 'lucide-react';
import { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom'; import { useNavigate, useOutletContext } from 'react-router-dom';
import DashCard from '../components/Dashboard/DashCard'; import DashCard from '../components/Dashboard/DashCard';
import TournamentForm from '../components/Forms/TournamentForm'; import TournamentForm from '../components/Forms/TournamentForm';
import Modal from '../components/UI/Modal'; import Modal from '../components/UI/Modal';
import api, { WS_URL } from '../services/api'; import api, { WS_URL } from '../services/api';
import { SlidersHorizontal, PlusCircle } from 'lucide-react';
export interface TournamentType {
id: string | number;
timestamp: string;
[key: string]: any;
}
interface OutletContextType {
setNavTitle: (title: string) => void;
setNavSubtitle: (subtitle: string) => void;
isAdmin: boolean;
showSettings: boolean;
setShowSettings: (show: boolean) => void;
}
export default function Dashboard() { export default function Dashboard() {
const { setNavTitle, setNavSubtitle, isAdmin, showSettings, setShowSettings } = useOutletContext(); const { setNavTitle, setNavSubtitle, isAdmin, showSettings, setShowSettings } = useOutletContext<OutletContextType>();
const [tournaments, setTournaments] = useState([]); const [tournaments, setTournaments] = useState<TournamentType[]>([]);
const [editTarget, setEditTarget] = useState(null); const [editTarget, setEditTarget] = useState<TournamentType | null>(null);
const [showPast, setShowPast] = useState(false); const [showPast, setShowPast] = useState<boolean>(false);
const [showAllFuture, setShowAllFuture] = useState(false); const [showAllFuture, setShowAllFuture] = useState<boolean>(false);
const navigate = useNavigate(); const navigate = useNavigate();
const loadDashboard = async () => { const loadDashboard = async () => {
try { try {
const res = await api.get('/tournaments'); const res: any = await api.get('/tournaments');
const list = Array.isArray(res) ? res : (res.items || []); const list = Array.isArray(res) ? res : (res.items || []);
setTournaments(list); setTournaments(list);
} catch (e) { console.error(e); } } catch (e) {
console.error(e);
}
}; };
useEffect(() => { useEffect(() => {
@@ -32,21 +48,24 @@ export default function Dashboard() {
loadDashboard(); loadDashboard();
localStorage.removeItem('volley_view'); localStorage.removeItem('volley_view');
let ws; let ws: WebSocket;
const connect = () => { const connect = () => {
try { try {
ws = new WebSocket(WS_URL); ws = new WebSocket(WS_URL);
ws.onmessage = (e) => { ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if (msg.type === 'dashboard_update') loadDashboard(); if (msg.type === 'dashboard_update') loadDashboard();
}; };
} catch (err) { } } catch (err) {
console.error("WebSocket connection failed", err);
}
}; };
connect(); connect();
return () => { if (ws) ws.close(); };
}, []);
const handleEdit = (t) => { return () => { if (ws) ws.close(); };
}, [setNavTitle, setNavSubtitle]);
const handleEdit = (t: TournamentType) => {
setEditTarget(t); setEditTarget(t);
setShowSettings(true); setShowSettings(true);
}; };
@@ -57,28 +76,32 @@ export default function Dashboard() {
loadDashboard(); loadDashboard();
}; };
const handleDelete = async (id) => { const handleDelete = async (id: string | number) => {
if (window.confirm("Purge this tournament and all its history?")) { if (window.confirm("Purge this tournament and all its history?")) {
await api.delete(`/tournaments/${id}`); await api.delete(`/tournaments/${id}`);
handleSuccess(); handleSuccess();
} }
}; };
// Grouping Logic
const now = new Date(); const now = new Date();
const groups = { live: [], future: [], past: [] }; const groups: { live: TournamentType[]; future: TournamentType[]; past: TournamentType[] } = {
live: [],
future: [],
past: []
};
tournaments.forEach(t => { tournaments.forEach(t => {
const tDate = new Date(t.timestamp); const tDate = new Date(t.timestamp);
const isToday = tDate.toDateString() === now.toDateString(); const isToday = tDate.toDateString() === now.toDateString();
if (tDate > now && !isToday) groups.future.push(t); if (tDate > now && !isToday) groups.future.push(t);
else if (isToday) groups.live.push(t); else if (isToday) groups.live.push(t);
else groups.past.push(t); else groups.past.push(t);
}); });
groups.future.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); groups.future.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
groups.live.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); groups.live.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
groups.past.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); groups.past.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
const futureShow = showAllFuture ? groups.future : groups.future.slice(0, 4); const futureShow = showAllFuture ? groups.future : groups.future.slice(0, 4);
@@ -86,7 +109,6 @@ export default function Dashboard() {
<div className="h-full overflow-y-auto pt-8 sm:pt-12 pb-32"> <div className="h-full overflow-y-auto pt-8 sm:pt-12 pb-32">
<div className="container mx-auto max-w-5xl px-4 space-y-12"> <div className="container mx-auto max-w-5xl px-4 space-y-12">
{/* Create Button */}
{isAdmin && ( {isAdmin && (
<div className="flex justify-center md:justify-end"> <div className="flex justify-center md:justify-end">
<button onClick={() => { setEditTarget(null); setShowSettings(true); }} className="bg-orange-600 hover:bg-orange-500 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 text-[10px] font-black uppercase tracking-wider shadow-xl shadow-orange-600/20 active:scale-95 transition"> <button onClick={() => { setEditTarget(null); setShowSettings(true); }} className="bg-orange-600 hover:bg-orange-500 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 text-[10px] font-black uppercase tracking-wider shadow-xl shadow-orange-600/20 active:scale-95 transition">
@@ -98,13 +120,13 @@ export default function Dashboard() {
{groups.live.length > 0 && ( {groups.live.length > 0 && (
<section> <section>
<h2 className="text-xs font-black text-green-500 uppercase tracking-[0.2em] mb-6 flex items-center gap-3"> <h2 className="text-xs font-black text-green-500 uppercase tracking-[0.2em] mb-6 flex items-center gap-3">
<span class="relative flex size-3"> <span className="relative flex size-3">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex size-3 rounded-full bg-green-500"></span> <span className="relative inline-flex size-3 rounded-full bg-green-500"></span>
</span> Live Events </span> Live Events
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{groups.live.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onSelect={(id) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)} {groups.live.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onSelect={(id: string) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)}
</div> </div>
</section> </section>
)} )}
@@ -116,11 +138,11 @@ export default function Dashboard() {
{groups.future.length > 0 ? ( {groups.future.length > 0 ? (
<> <>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{futureShow.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onSelect={(id) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)} {futureShow.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onSelect={(id: string) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)}
</div> </div>
{groups.future.length > 4 && ( {groups.future.length > 4 && (
<div className="mt-8 text-center"> <div className="mt-8 text-center">
<button onClick={() => setShowAllFuture(!showAllFuture)} className="text-xs font-black uppercase tracking-[0.1em] text-zinc-500 hover:text-orange-500 transition border-b-2 border-transparent hover:border-orange-500 pb-1 flex items-center justify-center gap-1 mx-auto"> <button onClick={() => setShowAllFuture(!showAllFuture)} className="text-xs font-black uppercase tracking-widest text-zinc-500 hover:text-orange-500 transition border-b-2 border-transparent hover:border-orange-500 pb-1 flex items-center justify-center gap-1 mx-auto">
{showAllFuture ? 'Show Less' : `Show All (${groups.future.length})`} {showAllFuture ? 'Show Less' : `Show All (${groups.future.length})`}
{showAllFuture ? <ChevronUp size={14} /> : <ChevronDown size={14} />} {showAllFuture ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button> </button>
@@ -138,7 +160,7 @@ export default function Dashboard() {
</button> </button>
{showPast && ( {showPast && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 opacity-75 hover:opacity-100 transition-opacity"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6 opacity-75 hover:opacity-100 transition-opacity">
{groups.past.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onSelect={(id) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)} {groups.past.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onSelect={(id: string) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)}
</div> </div>
)} )}
</section> </section>
@@ -1,23 +1,29 @@
// frontend/src/pages/Login.jsx // frontend/src/pages/Login.tsx
import { Loader2, Volleyball } from 'lucide-react'; import { Loader2, Volleyball } from 'lucide-react';
import { useState } from 'react'; import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import api from '../services/api'; import api from '../services/api';
interface TokenResponse {
access_token: string;
token_type?: string;
}
export default function Login() { export default function Login() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState(null); const [error, setError] = useState<string | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const handleSubmit = async (e) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setError(null); setError(null);
const formData = new FormData(e.target);
const formData = new FormData(e.currentTarget);
try { try {
const res = await api.postForm('/auth/token', formData); const res = await api.postForm<TokenResponse>('/auth/token', formData);
localStorage.setItem('volleyToken', res.access_token); localStorage.setItem('volleyToken', res.access_token);
navigate('/'); navigate('/');
} catch (err) { } catch (err) {
@@ -1,7 +1,7 @@
// frontend/src/pages/Tournament.jsx // frontend/src/pages/Tournament.tsx
import { CalendarDays, Loader2, Network, SlidersHorizontal } from 'lucide-react'; import { CalendarDays, Loader2, Network, SlidersHorizontal } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { useOutletContext, useParams } from 'react-router-dom'; import { useOutletContext, useParams } from 'react-router-dom';
import BracketView from '../components/Bracket/BracketView'; import BracketView from '../components/Bracket/BracketView';
import TournamentForm from '../components/Forms/TournamentForm'; import TournamentForm from '../components/Forms/TournamentForm';
@@ -10,23 +10,67 @@ import ScoreModal from '../components/Tournament/ScoreModal';
import Modal from '../components/UI/Modal'; import Modal from '../components/UI/Modal';
import api, { WS_URL } from '../services/api'; import api, { WS_URL } from '../services/api';
// --- Type Definitions ---
interface Team { id: string | number; name: string; }
interface Court { id: string | number; name: string; }
interface RawMatch {
id: string | number;
bracket_type: string;
round_number: number;
match_number: number;
winner_next_match_id?: string | number | null;
loser_next_match_id?: string | number | null;
p1_team_id?: string | number | null;
p2_team_id?: string | number | null;
winner_team_id?: string | number | null;
status: string;
court_id: string | number;
start_time?: string;
sets?: { p1: number; p2: number }[];
}
export interface ProcessedMatch extends RawMatch {
bracket: string;
round: number;
number: number;
p1: string;
p2: string;
winnerName: string | null;
p1_is_real: boolean;
p2_is_real: boolean;
isReady: boolean;
court: string;
time: string;
p1_sets: number;
p2_sets: number;
hasTeams: boolean;
isFinished: boolean;
}
interface OutletContextType {
setNavTitle: (title: string) => void;
setNavSubtitle: (subtitle: string) => void;
isAdmin: boolean;
}
export default function Tournament() { export default function Tournament() {
const { id } = useParams(); const { id } = useParams<{ id: string }>();
const { setNavTitle, setNavSubtitle, isAdmin } = useOutletContext(); const { setNavTitle, setNavSubtitle, isAdmin } = useOutletContext<OutletContextType>();
const [matches, setMatches] = useState([]); const [matches, setMatches] = useState<ProcessedMatch[]>([]);
const [view, setView] = useState(() => localStorage.getItem('volley_view') || 'bracket'); const [view, setView] = useState<string>(() => localStorage.getItem('volley_view') || 'bracket');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState<boolean>(true);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState<boolean>(false);
const [scoreMatch, setScoreMatch] = useState(null); const [scoreMatch, setScoreMatch] = useState<ProcessedMatch | null>(null);
const wsRef = useRef(null); const wsRef = useRef<WebSocket | null>(null);
const handleViewChange = (newView) => { const handleViewChange = (newView: string) => {
setView(newView); setView(newView);
localStorage.setItem('volley_view', newView); localStorage.setItem('volley_view', newView);
}; };
const processMatches = (rawMatches, courts, teams) => { const processMatches = (rawMatches: RawMatch[], courts: Court[], teams: Team[]): ProcessedMatch[] => {
if (!rawMatches) return []; if (!rawMatches) return [];
const gf1 = rawMatches.find(m => const gf1 = rawMatches.find(m =>
@@ -35,15 +79,16 @@ export default function Tournament() {
m.winner_next_match_id === m.loser_next_match_id m.winner_next_match_id === m.loser_next_match_id
); );
let skippedResetMatchId = null; let skippedResetMatchId: string | number | null = null;
if (gf1 && gf1.status === 'Finished' && gf1.winner_team_id === gf1.p1_team_id) { if (gf1 && gf1.status === 'Finished' && gf1.winner_team_id === gf1.p1_team_id) {
skippedResetMatchId = gf1.winner_next_match_id; skippedResetMatchId = gf1.winner_next_match_id || null;
} }
const courtMap = Object.fromEntries(courts.map(c => [c.id, c.name])); const courtMap: Record<string | number, string> = Object.fromEntries(courts.map(c => [c.id, c.name]));
const teamMap = Object.fromEntries(teams.map(t => [t.id, t.name])); const teamMap: Record<string | number, string> = Object.fromEntries(teams.map(t => [t.id, t.name]));
const incoming: Record<string | number, { label: string; id: string | number }[]> = {};
const incoming = {};
rawMatches.forEach(m => { rawMatches.forEach(m => {
const num = m.match_number; const num = m.match_number;
if (m.winner_next_match_id) { if (m.winner_next_match_id) {
@@ -91,30 +136,40 @@ export default function Tournament() {
const fetchData = async () => { const fetchData = async () => {
try { try {
const res = await api.get(`/tournaments/${id}`); const res: any = await api.get(`/tournaments/${id}`);
setNavTitle(res.name); setNavTitle(res.name);
setNavSubtitle(new Date(res.timestamp).toLocaleDateString()); setNavSubtitle(new Date(res.timestamp).toLocaleDateString());
setMatches(processMatches(res.matches, res.courts, res.teams)); setMatches(processMatches(res.matches, res.courts, res.teams));
} catch (err) { console.error(err); } finally { setLoading(false); } } catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}; };
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
if (wsRef.current) return; if (wsRef.current) return;
const connect = () => { const connect = () => {
const ws = new WebSocket(WS_URL); const ws = new WebSocket(WS_URL);
wsRef.current = ws; wsRef.current = ws;
ws.onmessage = (e) => { ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if (msg.type === 'tournament_update' && msg.id === id) fetchData(); if (msg.type === 'tournament_update' && msg.id === id) fetchData();
}; };
ws.onclose = () => { wsRef.current = null; }; ws.onclose = () => { wsRef.current = null; };
}; };
connect();
return () => { if (wsRef.current?.readyState === 1) wsRef.current.close(); wsRef.current = null; };
}, [id]);
const handleDeleteTournament = async (tId) => { connect();
return () => {
if (wsRef.current?.readyState === 1) wsRef.current.close();
wsRef.current = null;
};
}, [id, setNavTitle, setNavSubtitle]);
const handleDeleteTournament = async (tId: string | number) => {
if (window.confirm("Purge this tournament?")) { if (window.confirm("Purge this tournament?")) {
await api.delete(`/tournaments/${tId}`); await api.delete(`/tournaments/${tId}`);
window.location.href = '/'; window.location.href = '/';
@@ -150,12 +205,13 @@ export default function Tournament() {
onClose={() => setScoreMatch(null)} onClose={() => setScoreMatch(null)}
match={scoreMatch} match={scoreMatch}
isAdmin={isAdmin} isAdmin={isAdmin}
onClear={async (mid, c) => { onClear={async (mid: string | number, c: string) => {
await api.delete(`/tournaments/${id}/matches/${mid}/score?code=${encodeURIComponent(c || '')}`); await api.delete(`/tournaments/${id}/matches/${mid}/score?code=${encodeURIComponent(c || '')}`);
setScoreMatch(null); setScoreMatch(null);
}} }}
onSubmit={async (mid, s, c) => { onSubmit={async (mid: string | number, s: any, c: string) => {
const method = scoreMatch.isFinished ? 'patch' : 'post'; const method = scoreMatch.isFinished ? 'patch' : 'post';
// @ts-ignore - Indexing api dynamically
await api[method](`/tournaments/${id}/matches/${mid}/score`, { sets: s, code: c }); await api[method](`/tournaments/${id}/matches/${mid}/score`, { sets: s, code: c });
setScoreMatch(null); setScoreMatch(null);
}} }}
-48
View File
@@ -1,48 +0,0 @@
// frontend/src/services/api.js
const getBackendHost = () => {
const host = window.location.hostname || 'localhost';
return window.location.protocol === 'https:' ? host : `${host}:8000`;
};
export const API_BASE = `${window.location.protocol}//${getBackendHost()}/api`;
export const WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${getBackendHost()}/api/ws`;
export const getToken = () => localStorage.getItem('volleyToken');
const api = {
request: async (method, url, data = null, isFormData = false) => {
const headers = {};
const token = getToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
if (!isFormData) headers['Content-Type'] = 'application/json';
const opts = { method, headers };
if (data) opts.body = isFormData ? data : JSON.stringify(data);
// Ensure clean URL concatenation
const baseUrl = API_BASE.replace(/\/$/, '');
const endpoint = url.startsWith('/') ? url : `/${url}`;
const res = await fetch(`${baseUrl}${endpoint}`, opts);
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem('volleyToken');
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
const errorData = await res.json().catch(() => ({ detail: 'An error occurred' }));
throw errorData;
}
return res.json();
},
get: (url) => api.request('GET', url),
post: (url, data) => api.request('POST', url, data),
postForm: (url, data) => api.request('POST', url, data, true),
put: (url, data) => api.request('PUT', url, data),
patch: (url, data) => api.request('PATCH', url, data),
delete: (url) => api.request('DELETE', url)
};
export default api;
+71
View File
@@ -0,0 +1,71 @@
// frontend/src/services/api.ts
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
const getBackendHost = (): string => {
const host: string = window.location.hostname || 'localhost';
return window.location.protocol === 'https:' ? host : `${host}:8000`;
};
export const API_BASE: string = `${window.location.protocol}//${getBackendHost()}/api`;
export const WS_URL: string = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${getBackendHost()}/api/ws`;
export const getToken = (): string | null => localStorage.getItem('volleyToken');
const api = {
request: async <T>(
method: HttpMethod,
url: string,
data: any = null,
isFormData: boolean = false
): Promise<T> => {
const headers: Record<string, string> = {};
const token = getToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
if (!isFormData) headers['Content-Type'] = 'application/json';
const opts: RequestInit = {
method,
headers
};
if (data) {
opts.body = isFormData ? data : JSON.stringify(data);
}
// Ensure clean URL concatenation
const baseUrl = API_BASE.replace(/\/$/, '');
const endpoint = url.startsWith('/') ? url : `/${url}`;
const res = await fetch(`${baseUrl}${endpoint}`, opts);
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem('volleyToken');
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
// Attempt to parse error detail, fallback to generic message
const errorData = await res.json().catch(() => ({ detail: 'An error occurred' }));
throw errorData;
}
return res.json() as Promise<T>;
},
get: <T>(url: string) => api.request<T>('GET', url),
post: <T>(url: string, data?: any) => api.request<T>('POST', url, data),
postForm: <T>(url: string, data: FormData) => api.request<T>('POST', url, data, true),
put: <T>(url: string, data?: any) => api.request<T>('PUT', url, data),
patch: <T>(url: string, data?: any) => api.request<T>('PATCH', url, data),
delete: <T>(url: string) => api.request<T>('DELETE', url)
};
export default api;
+28
View File
@@ -0,0 +1,28 @@
// frontend/src/types.ts
export interface MatchData {
id: string | number;
number: number;
round: number;
bracket: string;
time: string;
court: string;
status: string;
hasTeams: boolean;
isReady: boolean;
isFinished: boolean;
p1: string;
p2: string;
p1_sets: number;
p2_sets: number;
p1_is_real: boolean;
p2_is_real: boolean;
winnerName: string | null;
winner_team_id?: string | number | null;
p1_team_id?: string | number | null;
p2_team_id?: string | number | null;
winner_next_match_id?: string | number | null;
loser_next_match_id?: string | number | null;
timestamp?: string;
start_time?: string;
}
-19
View File
@@ -1,19 +0,0 @@
// 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];
};
export const printName = (name) => {
if (!name) return '';
if (name.startsWith('Winner of #')) return name.replace('Winner of #', 'W') + ': _______________';
if (name.startsWith('Loser of #')) return name.replace('Loser of #', 'L') + ': _______________';
return name;
};
+25
View File
@@ -0,0 +1,25 @@
// frontend/src/utils/helpers.ts
export const stringToColor = (str: string | null | undefined): string => {
if (!str) return '#71717a';
const normalized: string = str.trim().toLowerCase();
const salt: string = 'volley-standard-salt-v5';
const COURT_COLORS: string[] = ['#ea580c', '#0284c7', '#059669', '#ca8a04', '#dc2626', '#0891b2', '#e11d48', '#65a30d'];
let hash: number = 0;
const combined: string = 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];
};
export const printName = (name: string | null | undefined): string => {
if (!name) return '';
if (name.startsWith('Winner of #')) return name.replace('Winner of #', 'W') + ': _______________';
if (name.startsWith('Loser of #')) return name.replace('Loser of #', 'L') + ': _______________';
return name;
};