Complete main functionallity of backend and frontend.

This commit is contained in:
2026-02-21 18:29:22 +01:00 Verified
parent 3a795418d3
commit 7ce44979b9
16 changed files with 383 additions and 168 deletions
+1 -2
View File
@@ -1,12 +1,11 @@
# backend/app/core/config.py
import os
import secrets
from pwdlib import PasswordHash
DB_PATH = os.getenv("DB_PATH", "./tournaments.db")
# Security Config
SECRET_KEY = os.getenv("SECRET_KEY", secrets.token_hex(32))
SECRET_KEY = os.getenv("SECRET_KEY", "SUPER-SECRET-TOKEN")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 hours
+34 -22
View File
@@ -8,7 +8,6 @@ from sqlalchemy.orm import Session
from . import models
from .constants import MatchStatus, TournamentTypes
from .core.brackets import BracketGenerator
from .core import structures
def generate_bracket(db: Session, t: models.Tournament):
@@ -21,18 +20,26 @@ def generate_bracket(db: Session, t: models.Tournament):
abstract_matches = gen.generate(len(teams), double_elimination=is_double)
id_map = {m.id: str(uuid4()) for m in abstract_matches}
def resolve_target(match_node: structures.Match | None):
curr = match_node
while curr and curr.is_bye:
def resolve_target(match_node, is_winner_path):
if is_winner_path:
curr = match_node.next_win
slot = getattr(match_node, "next_win_slot", None)
else:
curr = match_node.next_loss
slot = getattr(match_node, "next_loss_slot", None)
while curr and getattr(curr, "is_bye", False):
slot = getattr(curr, "next_win_slot", None)
curr = curr.next_win
return curr
return curr, slot
db_matches = []
friendly_counter = 1
for m in abstract_matches:
real_win = resolve_target(m.next_win)
real_loss = resolve_target(m.next_loss)
real_win, win_slot = resolve_target(m, True)
real_loss, loss_slot = resolve_target(m, False)
initial_status = MatchStatus.SCHEDULED
p1_id = None
@@ -59,7 +66,9 @@ def generate_bracket(db: Session, t: models.Tournament):
p1_team_id=p1_id,
p2_team_id=p2_id,
winner_next_match_id=id_map[real_win.id] if real_win else None,
winner_next_match_slot=win_slot if real_win else None,
loser_next_match_id=id_map[real_loss.id] if real_loss else None,
loser_next_match_slot=loss_slot if real_loss else None,
)
db_matches.append(new_match)
friendly_counter += 1
@@ -172,12 +181,14 @@ def advance_winner(db: Session, match: models.Match, winner_id: int):
loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id
def update_next_match(next_match: models.Match, team_id: int):
if not next_match:
def update_next_match(
next_match: models.Match, team_id: int, target_slot: int | None
):
if not next_match or target_slot is None:
return
if not next_match.p1_team_id:
if target_slot == 0:
next_match.p1_team_id = team_id
elif not next_match.p2_team_id:
elif target_slot == 1:
next_match.p2_team_id = team_id
if (
@@ -189,9 +200,9 @@ def advance_winner(db: Session, match: models.Match, winner_id: int):
db.add(next_match)
update_next_match(match.winner_next_match, winner_id)
update_next_match(match.winner_next_match, winner_id, match.winner_next_match_slot)
if match.loser_next_match and loser_id:
update_next_match(match.loser_next_match, loser_id)
update_next_match(match.loser_next_match, loser_id, match.loser_next_match_slot)
db.commit()
@@ -203,8 +214,10 @@ def undo_advancement(db: Session, match: models.Match):
winner_id = match.winner_team_id
loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id
def clear_from_next(next_match: models.Match, team_id: int):
if not next_match:
def clear_from_next(
next_match: models.Match, team_id: int, target_slot: int | None
):
if not next_match or target_slot is None:
return
if next_match.winner_team_id:
@@ -212,17 +225,16 @@ def undo_advancement(db: Session, match: models.Match):
next_match.winner_team_id = None
next_match.sets = []
if next_match.p1_team_id == team_id:
if target_slot == 0:
next_match.p1_team_id = None
elif next_match.p2_team_id == team_id:
elif target_slot == 1:
next_match.p2_team_id = None
if next_match.p1_team_id and next_match.p2_team_id:
next_match.status = MatchStatus.PENDING
else:
if next_match.status == MatchStatus.PENDING:
next_match.status = MatchStatus.SCHEDULED
db.add(next_match)
clear_from_next(match.winner_next_match, winner_id)
clear_from_next(match.winner_next_match, winner_id, match.winner_next_match_slot)
if match.loser_next_match and loser_id:
clear_from_next(match.loser_next_match, loser_id)
clear_from_next(match.loser_next_match, loser_id, match.loser_next_match_slot)
+12
View File
@@ -96,6 +96,18 @@ class Match(Base):
ForeignKey("matches.id"), nullable=True
)
winner_next_match_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("matches.id"), nullable=True
)
winner_next_match_slot: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True
)
loser_next_match_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("matches.id"), nullable=True
)
loser_next_match_slot: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
tournament: Mapped["Tournament"] = relationship(back_populates="matches")
court: Mapped[Optional["Court"]] = relationship()
p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id])
+1 -1
View File
@@ -3,4 +3,4 @@ from fastapi import APIRouter
router = APIRouter(prefix="/tournaments", tags=["Tournaments"])
from . import tournaments, teams, courts, matches, report
from . import tournaments, settings, teams, courts, matches, report
@@ -0,0 +1,20 @@
# backend/app/routes/tournaments/settings.py
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import crud, schemas
from ...database import get_db
from ...core.auth import get_current_user
from . import router
@router.get("/{id}/settings", response_model=schemas.TournamentSettingsResponse)
def get_tournament(
id: str, db: Session = Depends(get_db), user: str = Depends(get_current_user)
):
t = crud.get_tournament(db, id)
if not t:
raise HTTPException(404, "Tournament not found")
return t
@@ -10,7 +10,7 @@ from ...core.auth import get_current_user
from . import router
@router.post("", response_model=schemas.TournamentUpdateResponse)
@router.post("", response_model=schemas.TournamentSettingsResponse)
async def create_tournament(
data: schemas.TournamentCreate,
db: Session = Depends(get_db),
@@ -34,7 +34,7 @@ def get_tournament(id: str, db: Session = Depends(get_db)):
return t
@router.patch("/{id}", response_model=schemas.TournamentUpdateResponse)
@router.patch("/{id}", response_model=schemas.TournamentSettingsResponse)
async def update_settings(
id: str,
data: schemas.TournamentUpdate,
+6 -1
View File
@@ -51,7 +51,9 @@ class MatchOut(BaseModel):
winner_team_id: int | None = None
winner_next_match_id: str | None = None
winner_next_match_slot: int | None = None
loser_next_match_id: str | None = None
loser_next_match_slot: int | None = None
sets: list[SetScore] = []
@@ -80,6 +82,7 @@ class TournamentOut(BaseModel):
id: str
name: str
timestamp: datetime
duration: int
type: TournamentTypes
team_count: int
court_count: int
@@ -98,8 +101,10 @@ class TournamentDetail(BaseModel):
model_config = ConfigDict(from_attributes=True)
class TournamentUpdateResponse(TournamentDetail):
class TournamentSettingsResponse(TournamentOut):
code: str
teams: list[TeamSchema]
courts: list[CourtSchema]
class Token(BaseModel):
+14 -47
View File
@@ -1,47 +1,9 @@
// frontend/src/components/Bracket/BracketView.jsx
import React, { useEffect, useRef, useState } from 'react';
import { Check } from 'lucide-react';
import { stringToColor } from '../../utils/helpers';
import { useEffect, useRef, useState } from 'react';
import MatchCard from "./MatchCard";
import Podium from './Podium';
const MatchCard = ({ match, onClick }) => {
const isFinished = match.status === "Finished";
const badgeColor = match.time ? stringToColor(match.court) : null;
let borderClass = 'border-zinc-300 dark:border-zinc-700';
if (isFinished) borderClass = 'border-orange-500 ring-2 ring-orange-500/10';
const canInteract = match.hasTeams;
const cursorClass = canInteract
? 'cursor-pointer hover:shadow-md hover:-translate-y-0.5'
: 'cursor-default opacity-100';
return (
<div
id={`match-${match.id}`}
onClick={() => canInteract && onClick(match)}
className={`w-64 bg-white dark:bg-zinc-900 rounded-lg border ${borderClass} shadow-sm transition-all duration-200 relative z-10 flex flex-col ${cursorClass}`}
>
<div className="bg-zinc-50 dark:bg-zinc-900/50 px-3 py-2 flex justify-between items-center border-b border-zinc-200 dark:border-zinc-800 rounded-t-lg">
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] font-bold text-zinc-400"># {match.number}</span>
{match.time && <span className="text-[9px] font-black text-white px-1.5 py-0.5 rounded-sm uppercase" style={{ background: badgeColor }}>{match.court}</span>}
</div>
{isFinished ? <Check className="text-orange-500" size={14} strokeWidth={3} /> : <span className="text-[10px] font-bold text-zinc-500 font-mono">{match.time || 'TBD'}</span>}
</div>
<div className="p-3 space-y-2">
{[{ n: match.p1, s: match.p1_sets, win: match.winner === match.p1, real: match.p1_is_real },
{ n: match.p2, s: match.p2_sets, win: match.winner === match.p2, real: match.p2_is_real }].map((p, i) => (
<div key={i} className={`flex justify-between items-center ${p.win ? 'text-zinc-900 dark:text-white font-black' : p.real ? 'text-zinc-600 dark:text-zinc-300' : 'text-zinc-400 italic'}`}>
<span className="truncate text-xs uppercase tracking-tight">{p.n}</span>
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${p.win ? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'}`}>{p.s}</span>
</div>
))}
</div>
</div>
);
};
export default function BracketView({ matches, onMatchClick }) {
const containerRef = useRef(null);
@@ -85,7 +47,7 @@ export default function BracketView({ matches, onMatchClick }) {
} else {
matchesInRound.sort((a, b) => {
const getSourceAvg = (match) => {
const sources = list.filter(x => x.next_win === match.id);
const sources = list.filter(x => x.winner_next_match_id === match.id);
if (sources.length === 0) return 9999;
const indices = sources.map(s => prevRoundMap.get(s.id)).filter(i => i !== undefined);
if (indices.length === 0) return 9999;
@@ -110,9 +72,6 @@ export default function BracketView({ matches, onMatchClick }) {
return (
<div className="w-full h-full overflow-auto bg-[#f8f9fa] dark:bg-zinc-950 bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] dark:bg-[radial-gradient(#27272a_1px,transparent_1px)] [background-size:24px_24px]">
{/* UPDATED: items-center ensures Finals (right col) are centered vertically
relative to the Winners/Losers block (left col).
*/}
<div ref={containerRef} className="relative min-w-max min-h-full p-12 flex gap-20 items-center">
<svg className="absolute inset-0 w-full h-full pointer-events-none z-0">
{lines}
@@ -130,12 +89,20 @@ export default function BracketView({ matches, onMatchClick }) {
</div>
)}
</div>
<div className="flex flex-col justify-center gap-6 z-10">
{matches.some(m => m.bracket === 'Finals') && (
<div className="flex flex-col justify-center gap-6">
<>
<div className="text-[10px] font-black uppercase bg-orange-100 dark:bg-orange-900/30 text-orange-600 px-4 py-1.5 rounded-full border border-orange-200 dark:border-orange-800 shadow-sm mx-auto">Championship</div>
{finals.map(m => <MatchCard key={m.id} match={m} onClick={onMatchClick} />)}
</div>
</>
)}
</div>
<div className="flex flex-col justify-center gap-6 z-10">
<Podium matches={matches} />
</div>
</div>
</div>
);
@@ -0,0 +1,66 @@
// frontend/src/components/Bracket/MatchCard.jsx
import { Check } from 'lucide-react';
import { stringToColor } from '../../utils/helpers';
export default function MatchCard({ match, onClick }) {
const isFinished = match.status === "Finished";
const badgeColor = match.time ? stringToColor(match.court) : null;
let borderClass = 'border-zinc-300 dark:border-zinc-700';
if (isFinished) borderClass = 'border-orange-500 ring-2 ring-orange-500/10';
const canInteract = match.hasTeams;
const cursorClass = canInteract
? 'cursor-pointer hover:shadow-md hover:-translate-y-0.5'
: 'cursor-default opacity-100';
return (
<div
id={`match-${match.id}`}
onClick={() => canInteract && onClick(match)}
className={`w-64 bg-white dark:bg-zinc-900 rounded-lg border ${borderClass} shadow-sm transition-all duration-200 relative z-10 flex flex-col ${cursorClass}`}
>
<div className="bg-zinc-50 dark:bg-zinc-900/50 px-3 py-2 flex justify-between items-center border-b border-zinc-200 dark:border-zinc-800 rounded-t-lg">
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] font-bold text-zinc-400"># {match.number}</span>
{match.time && (
<span className="text-[9px] font-black text-white px-1.5 py-0.5 rounded-sm uppercase" style={{ background: badgeColor }}>
{match.court}
</span>
)}
</div>
{isFinished ? (
<Check className="text-orange-500" size={14} strokeWidth={3} />
) : (
<span className="text-[10px] font-bold text-zinc-500 font-mono">{match.time || 'TBD'}</span>
)}
</div>
<div className="p-3 space-y-2">
{[
{
n: match.p1,
s: match.p1_sets,
win: match.winner_team_id !== null && match.winner_team_id === match.p1_team_id,
real: match.p1_team_id
},
{
n: match.p2,
s: match.p2_sets,
win: match.winner_team_id !== null && match.winner_team_id === match.p2_team_id,
real: match.p2_team_id
}
].map((p, i) => (
<div key={i} className={`flex justify-between items-center ${p.win ? 'text-zinc-900 dark:text-white font-black' : p.real ? 'text-zinc-600 dark:text-zinc-300' : 'text-zinc-400 italic'}`}>
<span className="truncate text-xs uppercase tracking-tight">{p.n}</span>
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${p.win ? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'}`}>
{p.s}
</span>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,91 @@
// frontend/src/components/Bracket/Podium.jsx
import React from 'react';
import { Trophy } from 'lucide-react';
export default function Podium({ matches }) {
const isDoubleElim = matches.some(m => m.bracket === 'Loser');
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 finals = matches.filter(m => m.bracket === 'Finals').sort((a, b) => a.round - b.round);
let gfMatch = null;
let resetMatch = null;
let tpMatch = null;
if (isDoubleElim) {
tpMatch = lb[lb.length - 1];
gfMatch = finals[0];
resetMatch = finals[1];
} else {
gfMatch = wb[wb.length - 1];
tpMatch = finals[0];
}
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: 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: 3, label: "3rd", team: "TBD", isReal: false, color: "bg-amber-600 text-amber-50 dark:text-amber-50 shadow-amber-600/50", hidden: false }
];
if (tpMatch) {
if (tpMatch.isFinished && tpMatch.winnerName) {
if (isDoubleElim) {
podium[2].team = tpMatch.winnerName === tpMatch.p1 ? tpMatch.p2 : tpMatch.p1;
} else {
podium[2].team = tpMatch.winnerName;
}
podium[2].isReal = true;
} else {
podium[2].team = isDoubleElim ? `Loser of #${tpMatch.number}` : `Winner of #${tpMatch.number}`;
}
} else {
podium[2].hidden = true;
}
if (resetMatch && resetMatch.isFinished && resetMatch.winnerName) {
podium[0].team = resetMatch.winnerName;
podium[0].isReal = true;
podium[1].team = resetMatch.winnerName === resetMatch.p1 ? resetMatch.p2 : resetMatch.p1;
podium[1].isReal = true;
} else if (gfMatch) {
if (gfMatch.isFinished && gfMatch.winnerName) {
const isResetForced = resetMatch && resetMatch.hasTeams;
if (!isResetForced) {
podium[0].team = gfMatch.winnerName;
podium[0].isReal = true;
podium[1].team = gfMatch.winnerName === gfMatch.p1 ? gfMatch.p2 : gfMatch.p1;
podium[1].isReal = true;
} else {
podium[0].team = `Winner of #${resetMatch.number}`;
podium[1].team = `Loser of #${resetMatch.number}`;
}
} else {
podium[0].team = `Winner of #${gfMatch.number}`;
podium[1].team = `Loser of #${gfMatch.number}`;
}
}
return (
<div className="mt-8 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl shadow-sm w-64 overflow-hidden z-10">
<div className="bg-zinc-50 dark:bg-zinc-900/50 p-3 border-b border-zinc-200 dark:border-zinc-800">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500 text-center flex items-center justify-center gap-2">
<Trophy size={14} className="text-orange-500" /> Final Standings
</h3>
</div>
<div className="p-4 space-y-4">
{podium.filter(p => !p.hidden).map(p => (
<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 ${p.color}`}>
{p.rank}
</div>
<div className={`text-sm truncate ${p.isReal ? 'font-bold text-zinc-900 dark:text-white' : 'font-medium italic text-zinc-400'}`} title={p.team}>
{p.team}
</div>
</div>
))}
</div>
</div>
);
}
@@ -1,12 +1,37 @@
// frontend/src/components/Forms/TournamentForm.jsx
import React, { useState } from 'react';
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import api from '../../services/api';
export default function TournamentForm({ tournament, onSuccess, onDelete }) {
export default function TournamentForm({ tournamentId, onSuccess, onDelete }) {
const [initialData, setInitialData] = useState(null);
const [isLoading, setIsLoading] = useState(!!tournamentId);
const [error, setError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (!tournamentId) {
setInitialData(null);
setIsLoading(false);
return;
}
const fetchSettings = async () => {
try {
const data = await api.get(`/tournaments/${tournamentId}/settings`);
setInitialData(data);
} catch (err) {
console.error(err);
setError("Failed to load tournament settings.");
} finally {
setIsLoading(false);
}
};
fetchSettings();
}, [tournamentId]);
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
@@ -14,7 +39,6 @@ export default function TournamentForm({ tournament, onSuccess, onDelete }) {
const formData = new FormData(e.target);
// Extract raw values to transform
const rawTeams = formData.get('teams');
const rawCourts = formData.get('courts');
const date = formData.get('date');
@@ -33,21 +57,39 @@ export default function TournamentForm({ tournament, onSuccess, onDelete }) {
return;
}
const timestamp = new Date(`${date}T${startTime}`).toISOString();
const timestamp = `${date}T${startTime}:00`;
const formattedType = typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1);
const parsedDuration = parseInt(duration);
const payload = {
try {
if (tournamentId) {
// UPDATE: Dispatch the 3 separated PATCH endpoints concurrently
const basePayload = {
name,
code,
type: typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1),
type: formattedType,
timestamp,
duration: parseInt(duration),
duration: parsedDuration
};
await Promise.all([
api.patch(`/tournaments/${tournamentId}`, basePayload),
api.patch(`/tournaments/${tournamentId}/teams`, teams),
api.patch(`/tournaments/${tournamentId}/courts`, courts)
]);
} else {
// CREATE: Send the full monolithic payload to POST /tournaments
const fullPayload = {
name,
code,
type: formattedType,
timestamp,
duration: parsedDuration,
teams,
courts
};
try {
if (tournament) await api.patch(`/tournaments/${tournament.id}`, payload);
else await api.post('/tournaments', payload);
await api.post('/tournaments', fullPayload);
}
onSuccess();
} catch (err) {
console.error(err);
@@ -61,17 +103,26 @@ export default function TournamentForm({ tournament, onSuccess, onDelete }) {
}
};
// Calculate default date/time for form
const defaultDate = tournament?.timestamp
? new Date(tournament.timestamp).toISOString().split('T')[0]
: new Date().toISOString().split('T')[0];
if (isLoading) {
return (
<div className="flex justify-center items-center p-12">
<Loader2 className="animate-spin text-orange-600" size={32} />
</div>
);
}
const defaultTime = tournament?.timestamp
? new Date(tournament.timestamp).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
: "09:00";
let defaultDate = new Date().toISOString().split('T')[0];
let defaultTime = "09:00";
if (initialData?.timestamp) {
const [d, t] = initialData.timestamp.split('T');
defaultDate = d;
defaultTime = t ? t.substring(0, 5) : "09:00";
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
// Add a key mapped to the ID so React completely rebuilds the form when data loads
<form key={initialData?.id || 'new'} onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="text-xs text-red-500 dark:text-red-400 text-center mb-4 bg-red-50 dark:bg-red-900/10 p-2 rounded border border-red-200 dark:border-red-900/30">
{error}
@@ -83,7 +134,7 @@ export default function TournamentForm({ tournament, onSuccess, onDelete }) {
<label className="text-xs font-bold text-zinc-500 uppercase">Name</label>
<input
name="name"
defaultValue={tournament?.name}
defaultValue={initialData?.name}
required
placeholder="My Awesome Tournament"
autoFocus
@@ -96,7 +147,7 @@ export default function TournamentForm({ tournament, onSuccess, onDelete }) {
<label className="font-bold text-zinc-500 text-xs uppercase">Code</label>
<input
name="code"
defaultValue={tournament?.code}
defaultValue={initialData?.code}
required
placeholder="••••"
autoComplete="off"
@@ -107,7 +158,7 @@ export default function TournamentForm({ tournament, onSuccess, onDelete }) {
<label className="font-bold text-zinc-500 text-xs uppercase">Type</label>
<select
name="type"
defaultValue={tournament?.type?.toLowerCase() || "double"}
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>
@@ -118,11 +169,11 @@ export default function TournamentForm({ tournament, onSuccess, onDelete }) {
<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>
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Duration (min)</label>
<input
type="number"
name="duration"
defaultValue={tournament?.duration || 30}
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"
/>
@@ -152,7 +203,7 @@ export default function TournamentForm({ tournament, onSuccess, onDelete }) {
<input
name="courts"
placeholder="Center Court, Court 1"
defaultValue={tournament?.courts?.map(c => c.name || c).join(', ')}
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"
/>
@@ -163,7 +214,7 @@ export default function TournamentForm({ tournament, onSuccess, onDelete }) {
<textarea
name="teams"
placeholder="One team per line..."
defaultValue={tournament?.teams?.map(t => t.name || t).join('\n')}
defaultValue={initialData?.teams?.map(t => t.name || t).join('\n')}
rows={5}
required
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 font-mono text-sm focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white resize-none"
@@ -172,23 +223,23 @@ export default function TournamentForm({ tournament, onSuccess, onDelete }) {
</div>
<div className="flex justify-between mt-4 pt-4 border-t border-zinc-200 dark:border-zinc-800 flex-wrap gap-y-4">
{tournament && onDelete && (
{tournamentId && onDelete && (
<button
type="button"
onClick={() => onDelete(tournament.id)}
onClick={() => onDelete(tournamentId)}
className="text-red-500 text-sm hover:underline h-5 self-end"
>
Delete Tournament
</button>
)}
{!tournament && <div className="hidden"></div>}
{!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"
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...' : (tournament ? 'Save Changes' : 'Create')}
{isSubmitting ? 'Saving...' : (tournamentId ? 'Save Changes' : 'Create')}
</button>
</div>
</form>
+3 -22
View File
@@ -1,36 +1,19 @@
// frontend/src/components/Layout/Layout.jsx
import { Moon, Sun } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import api, { getToken } from '../../services/api';
import { getToken } from '../../services/api';
import Navbar from './Navbar';
export default function Layout({ darkMode, setDarkMode }) {
const [isAdmin, setIsAdmin] = useState(false);
const [isAdmin, setIsAdmin] = useState(!!getToken());
const [navTitle, setNavTitle] = useState('');
const [navSubtitle, setNavSubtitle] = useState('');
// State to trigger settings modals from Navbar
const [showSettings, setShowSettings] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const isDashboard = location.pathname === '/';
useEffect(() => {
const checkAuth = async () => {
if (getToken()) {
try {
const res = await api.get('/auth/check');
setIsAdmin(res.is_admin);
} catch {
setIsAdmin(false);
}
}
};
checkAuth();
}, [location.pathname]);
const handleLogout = () => {
localStorage.removeItem('volleyToken');
@@ -44,8 +27,6 @@ export default function Layout({ darkMode, setDarkMode }) {
subtitle={navSubtitle}
isAdmin={isAdmin}
onLogout={handleLogout}
onOpenSettings={() => setShowSettings(true)}
isDashboard={isDashboard}
/>
<main className="flex-1 overflow-hidden relative flex flex-col">
+2 -12
View File
@@ -1,9 +1,9 @@
// frontend/src/components/Layout/Navbar.jsx
import { Lock, LogOut, Plus, SlidersHorizontal, Volleyball } from 'lucide-react';
import { Lock, LogOut, Volleyball } from 'lucide-react';
import { Link } from 'react-router-dom';
export default function Navbar({ title, subtitle, isAdmin, onLogout, onOpenSettings, isDashboard }) {
export default function Navbar({ title, subtitle, isAdmin, onLogout }) {
return (
<nav className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur-lg border-b border-zinc-300 dark:border-zinc-800 sticky top-0 z-[100] px-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">
@@ -30,16 +30,6 @@ export default function Navbar({ title, subtitle, isAdmin, onLogout, onOpenSetti
<div className="flex gap-2 sm:gap-4 items-center shrink-0">
{isAdmin ? (
<>
{isDashboard ? (
<button onClick={onOpenSettings} className="bg-orange-600 hover:bg-orange-500 text-white px-3 sm:px-5 py-2 sm:py-2.5 rounded-xl flex items-center gap-2 text-[9px] sm:text-[10px] font-black uppercase tracking-wider sm:tracking-[0.2em] transition shadow-xl shadow-orange-600/20 active:scale-95 shrink-0">
<Plus size={16} strokeWidth={4} /> <span className="hidden xs:inline">Create</span>
</button>
) : (
<button onClick={onOpenSettings} className="text-zinc-500 hover:text-orange-500 transition p-2 sm:p-3 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-2xl active:scale-90">
<SlidersHorizontal size={18} className="sm:size-[22px]" strokeWidth={2.5} />
</button>
)}
<div className="w-px h-5 sm:h-6 bg-zinc-200 dark:bg-zinc-800 mx-0.5 sm:mx-1" />
<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]" />
</button>
@@ -14,22 +14,38 @@ export default function ScheduleView({ schedule, onMatchClick }) {
const badgeWidth = Math.max(100, longestCourt.length * 9);
const filtered = schedule.filter(m =>
(m.p1 + m.p2 + m.number).toLowerCase().includes(filter.toLowerCase())
);
const filteredAndSorted = schedule
.filter(m => (m.p1 + m.p2 + m.number).toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => {
const timeA = a.start_time || a.timestamp || a.time || "";
const timeB = b.start_time || b.timestamp || b.time || "";
if (timeA !== timeB) {
return timeA.localeCompare(timeB);
}
const courtA = a.court || "";
const courtB = b.court || "";
return courtA.localeCompare(courtB);
});
return (
<div className="h-full overflow-hidden relative flex flex-col">
<div className="absolute top-0 inset-x-0 z-30 p-6 pb-2 bg-transparent pointer-events-none">
<div className="relative group max-w-3xl mx-auto w-full pointer-events-auto">
<input placeholder="Search matches..." className="w-full bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-800 rounded-2xl p-4 pl-12 focus:ring-2 focus:ring-orange-500 outline-none transition shadow-sm text-zinc-900 dark:text-white font-bold" value={filter} onChange={e => setFilter(e.target.value)} />
<input
placeholder="Search matches..."
className="w-full bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-800 rounded-2xl p-4 pl-12 focus:ring-2 focus:ring-orange-500 outline-none transition shadow-sm text-zinc-900 dark:text-white font-bold"
value={filter}
onChange={e => setFilter(e.target.value)}
/>
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-400" size={20} />
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 pt-28 pb-32 [mask-image:linear-gradient(to_bottom,transparent_0px,transparent_60px,black_110px)]">
<div className="max-w-4xl mx-auto w-full space-y-3">
{filtered.map(m => {
{filteredAndSorted.map(m => {
const courtColor = stringToColor(m.court);
const isFinished = m.isFinished;
+2 -1
View File
@@ -73,6 +73,7 @@ export default function Dashboard() {
setNavTitle('Dashboard');
setNavSubtitle('');
loadDashboard();
localStorage.removeItem('volley_view');
let ws;
const connect = () => {
@@ -186,7 +187,7 @@ export default function Dashboard() {
<Modal isOpen={showSettings} onClose={() => { setShowSettings(false); setEditTarget(null); }} title={editTarget ? 'Modify Event' : 'Initialize Event'}>
<TournamentForm
tournament={editTarget}
tournamentId={editTarget?.id}
onSuccess={handleSuccess}
onDelete={handleDelete}
/>
+17 -13
View File
@@ -1,7 +1,7 @@
// frontend/src/pages/Tournament.jsx
import { CalendarDays, Loader2, Network, Settings } from 'lucide-react';
import { useEffect, useState, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useOutletContext, useParams } from 'react-router-dom';
import BracketView from '../components/Bracket/BracketView';
import TournamentForm from '../components/Forms/TournamentForm';
@@ -14,15 +14,18 @@ export default function Tournament() {
const { id } = useParams();
const { setNavTitle, setNavSubtitle, isAdmin } = useOutletContext();
const [details, setDetails] = useState(null);
const [matches, setMatches] = useState([]);
const [view, setView] = useState('bracket');
const [view, setView] = useState(() => localStorage.getItem('volley_view') || 'bracket');
const [loading, setLoading] = useState(true);
const [showSettings, setShowSettings] = useState(false);
const [scoreMatch, setScoreMatch] = useState(null);
const wsRef = useRef(null);
// --- DATA PROCESSOR ---
const handleViewChange = (newView) => {
setView(newView);
localStorage.setItem('volley_view', newView);
};
const processMatches = (rawMatches, courts, teams) => {
if (!rawMatches) return [];
@@ -43,10 +46,9 @@ export default function Tournament() {
return rawMatches.map(m => {
const sources = incoming[m.id] || [];
const p1 = m.p1_team_id ? teamMap[m.p1_team_id] : (sources[0]?.label || 'TBD');
const p2 = m.p2_team_id ? teamMap[m.p2_team_id] : (sources[1]?.label || 'TBD');
const winnerName = m.winner_team_id ? teamMap[m.winner_team_id] : null;
let sourceIndex = 0;
const p1 = m.p1_team_id ? teamMap[m.p1_team_id] : (sources[sourceIndex++]?.label || 'TBD');
const p2 = m.p2_team_id ? teamMap[m.p2_team_id] : (sources[sourceIndex++]?.label || 'TBD');
const hasTeams = !!(m.p1_team_id && m.p2_team_id);
const isFinished = m.status === "Finished";
@@ -61,7 +63,6 @@ export default function Tournament() {
p2,
p1_is_real: !!m.p1_team_id,
p2_is_real: !!m.p2_team_id,
winnerName,
isReady,
court: courtMap[m.court_id] || 'TBD',
time: m.start_time ? new Date(m.start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '',
@@ -76,7 +77,6 @@ export default function Tournament() {
const fetchData = async () => {
try {
const res = await api.get(`/tournaments/${id}`);
setDetails(res);
setNavTitle(res.name);
setNavSubtitle(new Date(res.timestamp).toLocaleDateString());
setMatches(processMatches(res.matches, res.courts, res.teams));
@@ -112,10 +112,10 @@ export default function Tournament() {
<div className="h-full flex flex-col">
<div className="border-b border-zinc-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-900/50 backdrop-blur px-6 py-3 flex justify-between items-center shrink-0 z-20">
<div className="flex gap-2">
<button onClick={() => setView('bracket')} className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider transition ${view === 'bracket' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'}`}>
<button onClick={() => handleViewChange('bracket')} className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider transition ${view === 'bracket' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'}`}>
<Network size={16} /> Bracket
</button>
<button onClick={() => setView('schedule')} className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider transition ${view === 'schedule' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'}`}>
<button onClick={() => handleViewChange('schedule')} className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider transition ${view === 'schedule' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'}`}>
<CalendarDays size={16} /> Schedule
</button>
</div>
@@ -137,7 +137,11 @@ export default function Tournament() {
/>
)}
<Modal isOpen={showSettings} onClose={() => setShowSettings(false)} title="Edit Tournament">
<TournamentForm tournament={details} onSuccess={() => { setShowSettings(false); fetchData(); }} onDelete={handleDeleteTournament} />
<TournamentForm
tournamentId={id}
onSuccess={() => { setShowSettings(false); fetchData(); }}
onDelete={handleDeleteTournament}
/>
</Modal>
</div>
);