Better bracket logic

This commit is contained in:
2026-02-14 01:04:54 +01:00 Verified
parent 87d3ea5ef0
commit 7b878b3abe
32 changed files with 1326 additions and 1348 deletions
+7 -12
View File
@@ -7,23 +7,18 @@ class TournamentTypes(str, Enum):
DOUBLE = "Double" DOUBLE = "Double"
class BracketType(str, Enum):
WINNERS = "Winners"
LOSERS = "Losers"
FINALS = "Finals"
class MatchSourceType(str, Enum):
WINNER = "Winner"
LOSER = "Loser"
class MatchStatus(str, Enum): class MatchStatus(str, Enum):
PENDING = "Pending"
SCHEDULED = "Scheduled" SCHEDULED = "Scheduled"
PENDING = "Pending"
FINISHED = "Finished" FINISHED = "Finished"
class BracketTypes(str, Enum):
WB = "Winner"
LB = "Loser"
FINALS = "Finals"
class WinnerSide(str, Enum): class WinnerSide(str, Enum):
P1 = "p1" P1 = "p1"
P2 = "p2" P2 = "p2"
+79 -68
View File
@@ -1,22 +1,11 @@
# backend/app/core/brackets.py # backend/app/core/brackets.py
import math import math
from typing import Optional from ..constants import BracketTypes
from .structures import Match
class Match:
def __init__(self, match_id: int, name: str):
self.id = match_id
self.name = name
self.next_win: Optional["Match"] = None
self.next_loss: Optional["Match"] = None
self.players: list[Optional[int]] = [None, None]
self.is_bye: bool = False
self.ghost_count: int = 0
class BracketGenerator: class BracketGenerator:
def __init__(self): def __init__(self):
self.matches_dict = {} self.matches_dict = {}
self.match_counter = 1 self.match_counter = 1
@@ -30,23 +19,24 @@ class BracketGenerator:
size = 2 ** math.ceil(math.log2(num_players)) size = 2 ** math.ceil(math.log2(num_players))
seeds = self._generate_seeding_indices(num_players) seeds = self._generate_seeding_indices(num_players)
bracket = self._create_tree_bracket(size, "WB") # Create Winners Bracket
bracket = self._create_tree_bracket(size)
for i, match in enumerate(bracket[0]): for i, match in enumerate(bracket[0]):
match.players = [seeds[i * 2], seeds[i * 2 + 1]] match.teams = [seeds[i * 2], seeds[i * 2 + 1]]
lb_rounds = [] lb_rounds = []
if double_elimination: if double_elimination:
lb_rounds = self._create_strict_lb(size, bracket) lb_rounds = self._create_strict_lb(size, bracket)
if double_elimination:
self._create_de_finals(bracket, lb_rounds) self._create_de_finals(bracket, lb_rounds)
else: else:
self._create_se_third_place(bracket) self._create_se_third_place(bracket)
self._apply_ghost_logic(bracket[0], num_players) # Propagate ghosts (this marks matches as is_bye=True)
self._resolve_byes(num_players)
all_matches = sorted(self.matches_dict.values(), key=lambda x: x.id) all_matches = sorted(self.matches_dict.values(), key=lambda x: x.id)
# Filter out byes so they don't become DB rows
return [m for m in all_matches if not m.is_bye] return [m for m in all_matches if not m.is_bye]
def _generate_seeding_indices(self, num_players: int) -> list[int]: def _generate_seeding_indices(self, num_players: int) -> list[int]:
@@ -58,7 +48,7 @@ class BracketGenerator:
curr *= 2 curr *= 2
return seeds return seeds
def _create_tree_bracket(self, size: int, prefix: str) -> list[list[Match]]: def _create_tree_bracket(self, size: int) -> list[list[Match]]:
rounds: list[list[Match]] = [] rounds: list[list[Match]] = []
current_size = size // 2 current_size = size // 2
r_num = 1 r_num = 1
@@ -66,8 +56,7 @@ class BracketGenerator:
while current_size >= 1: while current_size >= 1:
round_matches: list[Match] = [] round_matches: list[Match] = []
for _ in range(current_size): for _ in range(current_size):
name = f"{prefix} Round {r_num}" m = Match(self.match_counter, BracketTypes.WB, r_num)
m = Match(self.match_counter, name)
self.matches_dict[m.id] = m self.matches_dict[m.id] = m
round_matches.append(m) round_matches.append(m)
self.match_counter += 1 self.match_counter += 1
@@ -75,15 +64,10 @@ class BracketGenerator:
current_size //= 2 current_size //= 2
r_num += 1 r_num += 1
if rounds:
rounds[-1][0].name = "Grand Final"
if len(rounds) > 1:
for m in rounds[-2]:
m.name = f"{prefix} Semifinal"
for r in range(len(rounds) - 1): for r in range(len(rounds) - 1):
for i, match in enumerate(rounds[r]): for i, match in enumerate(rounds[r]):
match.next_win = rounds[r + 1][i // 2] match.next_win = rounds[r + 1][i // 2]
match.next_win_slot = i % 2
return rounds return rounds
@@ -104,102 +88,129 @@ class BracketGenerator:
round_matches = [] round_matches = []
for _ in range(current_lb_size): for _ in range(current_lb_size):
m = Match(self.match_counter, f"LB Round {r}") m = Match(self.match_counter, BracketTypes.LB, r)
self.matches_dict[m.id] = m self.matches_dict[m.id] = m
round_matches.append(m) round_matches.append(m)
self.match_counter += 1 self.match_counter += 1
lb_rounds.append(round_matches) lb_rounds.append(round_matches)
if lb_rounds: # Link LB rounds
lb_rounds[-1][0].name = "Losers Final (3rd Place Match)"
for r in range(len(lb_rounds) - 1): for r in range(len(lb_rounds) - 1):
curr = lb_rounds[r] curr = lb_rounds[r]
next_r = lb_rounds[r + 1] next_r = lb_rounds[r + 1]
for i, match in enumerate(curr): for i, match in enumerate(curr):
if len(curr) == len(next_r): if len(curr) == len(next_r):
# 1-to-1 Mapping case (e.g. 4 matches -> 4 matches)
match.next_win = next_r[i] match.next_win = next_r[i]
# FIX: Always use slot 1 (Slot 0 is taken by WB Drop-down)
match.next_win_slot = 1
else: else:
# 2-to-1 Mapping case (e.g. 4 matches -> 2 matches)
match.next_win = next_r[i // 2] match.next_win = next_r[i // 2]
match.next_win_slot = i % 2
# Link WB Losers -> LB
for r in range(num_wb_rounds): for r in range(num_wb_rounds):
wb_round = wb_rounds[r] wb_round = wb_rounds[r]
lb_target_idx = 0 if r == 0 else (r * 2) - 1
if r == 0:
lb_target_idx = 0
else:
lb_target_idx = (r * 2) - 1
if lb_target_idx >= len(lb_rounds): if lb_target_idx >= len(lb_rounds):
break break
lb_round = lb_rounds[lb_target_idx] lb_round = lb_rounds[lb_target_idx]
for i, wb_match in enumerate(wb_round): for i, wb_match in enumerate(wb_round):
if r == 0: if r == 0:
target = lb_round[i // 2] target = lb_round[i // 2]
wb_match.next_loss_slot = i % 2
else: else:
# Drop-in round
target = lb_round[i] if i < len(lb_round) else lb_round[0] target = lb_round[i] if i < len(lb_round) else lb_round[0]
# Drop-ins take Slot 0
wb_match.next_loss_slot = 0
wb_match.next_loss = target wb_match.next_loss = target
return lb_rounds return lb_rounds
def _create_se_third_place(self, wb_rounds: list[list[Match]]) -> None: def _create_se_third_place(self, wb_rounds: list[list[Match]]) -> None:
"""Creates a standalone 3rd place match for Single Elim"""
if len(wb_rounds) < 2: if len(wb_rounds) < 2:
return return
semis = wb_rounds[-2] semis = wb_rounds[-2]
third_place_match = Match(self.match_counter, "3rd Place Match") final_round_idx = wb_rounds[-1][0].round_number
third_place_match = Match(
self.match_counter, BracketTypes.FINALS, final_round_idx
)
self.matches_dict[third_place_match.id] = third_place_match self.matches_dict[third_place_match.id] = third_place_match
self.match_counter += 1 self.match_counter += 1
for m in semis: for i, sm in enumerate(semis):
m.next_loss = third_place_match sm.next_loss = third_place_match
sm.next_loss_slot = i % 2
def _create_de_finals( def _create_de_finals(
self, wb_rounds: list[list[Match]], lb_rounds: list[list[Match]] self, wb_rounds: list[list[Match]], lb_rounds: list[list[Match]]
) -> None: ) -> None:
"""Links Double Elim Grand Final"""
wb_final = wb_rounds[-1][0] wb_final = wb_rounds[-1][0]
wb_final.name = "Winners Final" final_round_idx = wb_final.round_number + 1
gf = Match(self.match_counter, "Grand Final") gf = Match(self.match_counter, BracketTypes.FINALS, final_round_idx)
self.matches_dict[gf.id] = gf self.matches_dict[gf.id] = gf
self.match_counter += 1 self.match_counter += 1
wb_final.next_win = gf wb_final.next_win = gf
wb_final.next_win_slot = 0
if lb_rounds: if lb_rounds:
lb_rounds[-1][0].next_win = gf lb_rounds[-1][0].next_win = gf
reset = Match(self.match_counter, "Grand Final Reset") lb_rounds[-1][0].next_win_slot = 1
reset = Match(self.match_counter, BracketTypes.FINALS, final_round_idx + 1)
self.matches_dict[reset.id] = reset self.matches_dict[reset.id] = reset
self.match_counter += 1 self.match_counter += 1
gf.next_loss = reset gf.next_loss = reset
gf.next_loss_slot = 0
def _apply_ghost_logic( def _resolve_byes(self, num_players: int) -> None:
self, round_one_matches: list[Match], num_players: int
) -> None:
""" """
Injects ghosts into WB Round 1 and lets them flow recursively. Iterates through all matches to identify 'Ghost' players (seeds > num_players).
If a match has a ghost, it is marked as a Bye, and the real player is
automatically pushed to the next match.
""" """
for m in round_one_matches: all_matches: list[Match] = sorted(
p1, p2 = m.players self.matches_dict.values(), key=lambda x: x.id
if p1 and p1 > num_players: )
self._add_ghost(m)
if p2 and p2 > num_players:
self._add_ghost(m)
def _add_ghost(self, match: Match) -> None: for m in all_matches:
""" if m.is_bye:
Recursively propagates a ghost through the bracket. continue
"""
match.ghost_count += 1
match.is_bye = True
if match.next_loss: p1, p2 = m.teams
self._add_ghost(match.next_loss)
if match.ghost_count == 2: p1_is_ghost = p1 is not None and p1 > num_players
if match.next_win: p2_is_ghost = p2 is not None and p2 > num_players
self._add_ghost(match.next_win)
if p1_is_ghost or p2_is_ghost:
m.is_bye = True
winner = None
loser = None # Ghost
if p1_is_ghost and p2_is_ghost:
winner = None
loser = None
elif p1_is_ghost:
winner = p2
loser = p1
elif p2_is_ghost:
winner = p1
loser = p2
# Propagate Winner (Advance to next round)
if m.next_win:
m.next_win.teams[m.next_win_slot] = winner
# Propagate Loser (Drop to LB)
# Important: We push the GHOST to the LB.
# When the loop reaches that LB match later, it will see the ghost
# and mark THAT match as a bye too, recursively cleaning the bracket.
if m.next_loss:
m.next_loss.teams[m.next_loss_slot] = loser
+19
View File
@@ -0,0 +1,19 @@
# backend/app/core/structures.py
from dataclasses import dataclass, field
from typing import Optional
from ..constants import BracketTypes
@dataclass
class Match:
id: int
bracket_type: BracketTypes
round_number: int
next_win: Optional["Match"] = None
next_loss: Optional["Match"] = None
next_win_slot: int = 0
next_loss_slot: int = 0
teams: list[Optional[int]] = field(default_factory=lambda: [None, None])
is_bye: bool = False
ghost_count: int = 0
+2 -2
View File
@@ -58,6 +58,6 @@ class MermaidLive:
@staticmethod @staticmethod
def _print_node(m: Match, style: str) -> None: def _print_node(m: Match, style: str) -> None:
label = m.name label = m.name
if m.players[0] and m.players[1]: if m.teams[0] and m.teams[1]:
label += f" ({m.players[0]} vs {m.players[1]})" label += f" ({m.teams[0]} vs {m.teams[1]})"
print(f' M{m.id}["{label}"]:::{style}') print(f' M{m.id}["{label}"]:::{style}')
+18 -33
View File
@@ -7,39 +7,16 @@ from . import logic, models, schemas
def _rebuild_structure(db: Session, t: models.Tournament): def _rebuild_structure(db: Session, t: models.Tournament):
""" for m in t.matches:
Nukes existing nodes/matches and regenerates them based on current teams. db.delete(m)
Used when teams are added/removed.
"""
t.matches = []
db.flush() db.flush()
t.nodes = [] logic.generate_bracket(db, t)
db.flush()
node_data = logic.generate_bracket_nodes(t)
for nd in node_data:
db.add(models.BracketNode(**nd, tournament_id=t.id))
db.flush() db.flush()
db.refresh(t) db.refresh(t)
logic.initialize_seeding(db, t)
logic.update_schedule_times(db, t) logic.update_schedule_times(db, t)
def get_tournaments(db: Session):
return db.query(models.Tournament).all()
def get_tournament(db: Session, tournament_id: str):
return (
db.query(models.Tournament)
.filter(models.Tournament.id == tournament_id)
.first()
)
def create_tournament(db: Session, data: schemas.TournamentCreate): def create_tournament(db: Session, data: schemas.TournamentCreate):
t_id = str(uuid4())[:8] t_id = str(uuid4())[:8]
new_t = models.Tournament( new_t = models.Tournament(
@@ -56,19 +33,27 @@ def create_tournament(db: Session, data: schemas.TournamentCreate):
db.add(new_t) db.add(new_t)
db.commit() db.commit()
node_data = logic.generate_bracket_nodes(new_t) # Generate Bracket
for nd in node_data: logic.generate_bracket(db, new_t)
db.add(models.BracketNode(**nd, tournament_id=t_id))
db.commit()
db.refresh(new_t)
logic.initialize_seeding(db, new_t)
logic.update_schedule_times(db, new_t) logic.update_schedule_times(db, new_t)
db.commit()
db.refresh(new_t) db.refresh(new_t)
return new_t return new_t
def get_tournaments(db: Session):
return db.query(models.Tournament).all()
def get_tournament(db: Session, tournament_id: str):
return (
db.query(models.Tournament)
.filter(models.Tournament.id == tournament_id)
.first()
)
def delete_tournament(db: Session, tournament_id: str) -> bool: def delete_tournament(db: Session, tournament_id: str) -> bool:
t = get_tournament(db, tournament_id) t = get_tournament(db, tournament_id)
if not t: if not t:
+176 -299
View File
@@ -1,333 +1,210 @@
# backend/app/logic.py # backend/app/logic.py
import math from collections import defaultdict, deque
from datetime import datetime, timedelta from datetime import timedelta
from typing import Optional
from uuid import uuid4 from uuid import uuid4
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from . import models from . import models
from .constants import ( from .constants import MatchStatus, TournamentTypes
BracketType, from .core.brackets import BracketGenerator
MatchSourceType,
MatchStatus,
TournamentTypes,
WinnerSide,
)
def get_seeded_positions(num_slots, teams): def generate_bracket(db: Session, t: models.Tournament):
seeds = [1, 2]
while len(seeds) < num_slots:
next_seeds = []
for s in seeds:
next_seeds.append(s)
next_seeds.append(2 * len(seeds) + 1 - s)
seeds = next_seeds
return [teams[s - 1] if s <= len(teams) else None for s in seeds]
def generate_bracket_nodes(t: models.Tournament) -> list[dict]:
"""
Generates the skeleton (BracketNodes). Does NOT create Matches.
"""
teams = t.teams teams = t.teams
count = len(teams) if not teams:
if count < 2: return
return []
power = math.ceil(math.log2(count)) if count > 0 else 1 gen = BracketGenerator()
size = 2**power is_double = t.type == TournamentTypes.DOUBLE
abstract_matches = gen.generate(len(teams), double_elimination=is_double)
id_map = {m.id: str(uuid4()) for m in abstract_matches}
nodes = [] def resolve_target(match_node):
display_counter = 1 curr = match_node
while curr and curr.is_bye:
curr = curr.next_win
return curr
def make_id(): db_matches = []
return str(uuid4()) friendly_counter = 1
class NodeRef: for m in abstract_matches:
def __init__(self, bracket, round_n): real_win = resolve_target(m.next_win)
self.id = make_id() real_loss = resolve_target(m.next_loss)
self.bracket = bracket
self.round = round_n
self.display_num = 0
self.next_win: Optional["NodeRef"] = None initial_status = MatchStatus.SCHEDULED
self.next_loss: Optional["NodeRef"] = None p1_id = None
self.src_p1: Optional["NodeRef"] = None p2_id = None
self.src_p2: Optional["NodeRef"] = None
self.src_p1_type: Optional[MatchSourceType] = None p1_seed = m.teams[0]
self.src_p2_type: Optional[MatchSourceType] = None p2_seed = m.teams[1]
def to_dict(self): if p1_seed and p1_seed <= len(teams):
return { p1_id = teams[p1_seed - 1].id
"id": self.id, if p2_seed and p2_seed <= len(teams):
"bracket_type": self.bracket, p2_id = teams[p2_seed - 1].id
"round_number": self.round,
"display_number": self.display_num,
"winner_next_node_id": self.next_win.id if self.next_win else None,
"loser_next_node_id": self.next_loss.id if self.next_loss else None,
"source_p1_node_id": self.src_p1.id if self.src_p1 else None,
"source_p2_node_id": self.src_p2.id if self.src_p2 else None,
"source_p1_type": self.src_p1_type,
"source_p2_type": self.src_p2_type,
}
wb_rounds = power if p1_id and p2_id:
wb_layers = {r: [] for r in range(1, wb_rounds + 1)} initial_status = MatchStatus.PENDING
for r in range(1, wb_rounds + 1):
for _ in range(size // (2**r)):
n = NodeRef(BracketType.WINNERS, r)
wb_layers[r].append(n)
# Link Winners
for r in range(1, wb_rounds):
for i, node in enumerate(wb_layers[r]):
target = wb_layers[r + 1][i // 2]
node.next_win = target
if i % 2 == 0:
target.src_p1 = node
target.src_p1_type = MatchSourceType.WINNER
else:
target.src_p2 = node
target.src_p2_type = MatchSourceType.WINNER
lb_layers = {}
if t.type == TournamentTypes.DOUBLE and size >= 4:
lb_rounds = (wb_rounds - 1) * 2
lb_layers = {r: [] for r in range(1, lb_rounds + 1)}
current_count = size // 4
for r in range(1, lb_rounds + 1):
for _ in range(current_count):
n = NodeRef(BracketType.LOSERS, r)
lb_layers[r].append(n)
if r % 2 == 0:
current_count //= 2
for r in range(1, lb_rounds):
for i, node in enumerate(lb_layers[r]):
target = lb_layers[r + 1][i] if r % 2 != 0 else lb_layers[r + 1][i // 2]
node.next_win = target
if r % 2 != 0:
target.src_p1 = node
target.src_p1_type = MatchSourceType.WINNER
else:
if i % 2 == 0:
target.src_p1 = node
target.src_p1_type = MatchSourceType.WINNER
else:
target.src_p2 = node
target.src_p2_type = MatchSourceType.WINNER
# Link Drop-down (Winners -> Losers)
for r in range(1, wb_rounds):
drop_round = 1 if r == 1 else (r - 1) * 2
wb_layer_nodes = wb_layers[r]
lb_layer_nodes = lb_layers[drop_round]
for i, wb_node in enumerate(wb_layer_nodes):
target = None
if r == 1:
target = lb_layer_nodes[i // 2]
else:
if i < len(lb_layer_nodes):
target = lb_layer_nodes[i]
else:
target = lb_layer_nodes[-1]
wb_node.next_loss = target
slot = WinnerSide.P1 if (r == 1 and i % 2 == 0) else WinnerSide.P2
if slot == WinnerSide.P1:
target.src_p1 = wb_node
target.src_p1_type = MatchSourceType.LOSER
else:
target.src_p2 = wb_node
target.src_p2_type = MatchSourceType.LOSER
# Finals
final_node = NodeRef(BracketType.FINALS, 1)
wb_final = wb_layers[wb_rounds][0]
lb_final = lb_layers[lb_rounds][0]
wb_final.next_loss = lb_final
wb_final.next_win = final_node
lb_final.next_win = final_node
final_node.src_p1 = wb_final
final_node.src_p1_type = MatchSourceType.WINNER
final_node.src_p2 = lb_final
final_node.src_p2_type = MatchSourceType.WINNER
all_nodes = []
for r in sorted(wb_layers.keys()):
all_nodes.extend(wb_layers[r])
for r in sorted(lb_layers.keys()):
all_nodes.extend(lb_layers[r])
all_nodes.append(final_node)
else:
all_nodes = []
for r in sorted(wb_layers.keys()):
all_nodes.extend(wb_layers[r])
for n in all_nodes:
n.display_num = display_counter
display_counter += 1
nodes.append(n.to_dict())
return nodes
def initialize_seeding(db: Session, t: models.Tournament):
seeded_teams = get_seeded_positions(
2 ** math.ceil(math.log2(t.team_count)), t.teams
)
r1_nodes = [
n
for n in t.nodes
if n.bracket_type == BracketType.WINNERS and n.round_number == 1
]
r1_nodes.sort(key=lambda x: x.display_number)
for i, node in enumerate(r1_nodes):
t1 = seeded_teams[i * 2]
t2 = seeded_teams[i * 2 + 1]
node.p1_team_id = t1.id if t1 else None
node.p2_team_id = t2.id if t2 else None
advance_flow(db, t)
def advance_flow(db: Session, t: models.Tournament):
changes = True
while changes:
changes = False
for node in t.nodes:
if node.round_number == 1 and node.bracket_type == BracketType.WINNERS:
if node.p1_team_id and not node.p2_team_id:
if _move_team(
db,
t,
node.p1_team_id,
node.winner_next_node_id,
MatchSourceType.WINNER,
):
pass
elif node.p2_team_id and not node.p1_team_id:
if _move_team(
db,
t,
node.p2_team_id,
node.winner_next_node_id,
MatchSourceType.WINNER,
):
pass
# 2. MATCH CREATION
if node.p1_team_id and node.p2_team_id:
if not node.match:
m_id = str(uuid4())
new_match = models.Match( new_match = models.Match(
id=m_id, id=id_map[m.id],
tournament_id=t.id, tournament_id=t.id,
node_id=node.id, match_number=friendly_counter,
p1_team_id=node.p1_team_id, bracket_type=m.bracket_type,
p2_team_id=node.p2_team_id, round_number=m.round_number,
status=MatchStatus.PENDING, status=initial_status,
court_id=node.planned_court_id, p1_team_id=p1_id,
start_time=node.planned_start_time, p2_team_id=p2_id,
winner_next_match_id=id_map[real_win.id] if real_win else None,
loser_next_match_id=id_map[real_loss.id] if real_loss else None,
) )
db.add(new_match) db_matches.append(new_match)
db.commit() friendly_counter += 1
db.refresh(node)
changes = True
# 3. MATCH RESULT PROPAGATION db.add_all(db_matches)
elif ( db.flush()
node.match.status == MatchStatus.FINISHED
and node.match.winner_team_id
):
winner_id = node.match.winner_team_id
loser_id = (
node.match.p1_team_id
if winner_id == node.match.p2_team_id
else node.match.p2_team_id
)
if _move_team(
db,
t,
winner_id,
node.winner_next_node_id,
MatchSourceType.WINNER,
):
changes = True
if _move_team(
db, t, loser_id, node.loser_next_node_id, MatchSourceType.LOSER
):
changes = True
def _move_team(db, t, team_id, target_node_id, source_type):
if not target_node_id or not team_id:
return False
target = next((n for n in t.nodes if n.id == target_node_id), None)
if not target:
return False
updated = False
if target.source_p1_type == source_type and not target.p1_team_id:
target.p1_team_id = team_id
updated = True
elif target.source_p2_type == source_type and not target.p2_team_id:
target.p2_team_id = team_id
updated = True
elif not target.p1_team_id:
target.p1_team_id = team_id
updated = True
elif not target.p2_team_id:
target.p2_team_id = team_id
updated = True
if updated:
db.add(target)
db.commit()
return updated
def update_schedule_times(db: Session, t: models.Tournament): def update_schedule_times(db: Session, t: models.Tournament):
nodes = sorted(t.nodes, key=lambda n: n.display_number) if not t.courts:
current_time = t.timestamp
courts = t.courts
if not courts:
return return
court_timers: dict[int, datetime] = {c.id: current_time for c in courts} matches = t.matches
if not matches:
return
for node in nodes: # 1. Build Dependency Graph
if node.planned_start_time: adj = defaultdict(list)
continue in_degree = {m.id: 0 for m in matches}
match_map = {m.id: m for m in matches}
best_court_id = min(court_timers, key=lambda k: court_timers[k]) for m in matches:
start = court_timers[best_court_id] if m.id not in adj:
adj[m.id] = []
node.planned_court_id = best_court_id if m.winner_next_match_id and m.winner_next_match_id in match_map:
node.planned_start_time = start adj[m.id].append(m.winner_next_match_id)
in_degree[m.winner_next_match_id] += 1
court_timers[best_court_id] = start + timedelta(minutes=t.duration) if m.loser_next_match_id and m.loser_next_match_id in match_map:
adj[m.id].append(m.loser_next_match_id)
in_degree[m.loser_next_match_id] += 1
if node.match and node.match.status == MatchStatus.PENDING: # 2. Initialize constraints
node.match.court_id = best_court_id # FIX: Ensure tournament_start is naive (no timezone) to match SQLite DB datetimes
node.match.timestamp = start tournament_start = t.timestamp
if tournament_start.tzinfo is not None:
tournament_start = tournament_start.replace(tzinfo=None)
match_earliest_start = {m.id: tournament_start for m in matches}
court_timers = {c.id: tournament_start for c in t.courts}
# 3. Topological Sort
queue = deque([m.id for m in matches if in_degree[m.id] == 0])
# Sort initial batch to prioritize logical order
initial_order = sorted(
list(queue),
key=lambda mid: (match_map[mid].bracket_type, match_map[mid].match_number),
)
queue = deque(initial_order)
while queue:
current_id = queue.popleft()
match = match_map[current_id]
# FIX: Ensure DB start_time is treated as naive
m_start = match.start_time
if m_start and m_start.tzinfo is not None:
m_start = m_start.replace(tzinfo=None)
if match.status == MatchStatus.FINISHED:
# If finished, propagate actual time
if not m_start:
m_start = tournament_start
actual_end = m_start + timedelta(minutes=t.duration)
else:
# Schedule: Must wait for dependencies (min_start) AND court availability
min_start = match_earliest_start[current_id]
best_court_id = min(
court_timers, key=lambda k: max(court_timers[k], min_start)
)
scheduled_start = max(court_timers[best_court_id], min_start)
match.court_id = best_court_id
match.start_time = scheduled_start
actual_end = scheduled_start + timedelta(minutes=t.duration)
court_timers[best_court_id] = actual_end
for child_id in adj[current_id]:
if actual_end > match_earliest_start[child_id]:
match_earliest_start[child_id] = actual_end
in_degree[child_id] -= 1
if in_degree[child_id] == 0:
queue.append(child_id)
db.commit()
def advance_winner(db: Session, match: models.Match, winner_id: int):
if not winner_id:
return
loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id
def update_next_match(next_match, team_id):
if not next_match:
return
if not next_match.p1_team_id:
next_match.p1_team_id = team_id
elif not next_match.p2_team_id:
next_match.p2_team_id = team_id
if (
next_match.p1_team_id
and next_match.p2_team_id
and next_match.status == MatchStatus.SCHEDULED
):
next_match.status = MatchStatus.PENDING
db.add(next_match)
update_next_match(match.winner_next_match, winner_id)
if match.loser_next_match and loser_id:
update_next_match(match.loser_next_match, loser_id)
db.commit()
def undo_advancement(db: Session, match: models.Match):
if not match.winner_team_id:
return
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, team_id):
if not next_match:
return
if next_match.p1_team_id == team_id:
next_match.p1_team_id = None
elif next_match.p2_team_id == team_id:
next_match.p2_team_id = None
if next_match.status == MatchStatus.PENDING:
next_match.status = MatchStatus.SCHEDULED
db.add(next_match)
clear_from_next(match.winner_next_match, winner_id)
if match.loser_next_match and loser_id:
clear_from_next(match.loser_next_match, loser_id)
db.commit() db.commit()
+25 -67
View File
@@ -7,7 +7,7 @@ from sqlalchemy import Enum as SqlEnum
from sqlalchemy import ForeignKey, Integer, String from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from .constants import BracketType, MatchSourceType, MatchStatus, TournamentTypes from .constants import MatchStatus, TournamentTypes, BracketTypes
from .database import Base from .database import Base
@@ -28,12 +28,6 @@ class Tournament(Base):
"Court", back_populates="tournament", cascade="all, delete-orphan" "Court", back_populates="tournament", cascade="all, delete-orphan"
) )
# The Structure (Skeleton)
nodes: Mapped[list["BracketNode"]] = relationship(
"BracketNode", back_populates="tournament", cascade="all, delete-orphan"
)
# The Events (Real Games)
matches: Mapped[list["Match"]] = relationship( matches: Mapped[list["Match"]] = relationship(
"Match", back_populates="tournament", cascade="all, delete-orphan" "Match", back_populates="tournament", cascade="all, delete-orphan"
) )
@@ -65,86 +59,50 @@ class Court(Base):
tournament: Mapped["Tournament"] = relationship(back_populates="courts") tournament: Mapped["Tournament"] = relationship(back_populates="courts")
class BracketNode(Base):
__tablename__ = "bracket_nodes"
id: Mapped[str] = mapped_column(String, primary_key=True)
tournament_id: Mapped[str] = mapped_column(ForeignKey("tournaments.id"))
bracket_type: Mapped[BracketType] = mapped_column(SqlEnum(BracketType))
round_number: Mapped[int] = mapped_column(Integer)
display_number: Mapped[int] = mapped_column(Integer)
# --- Planning / Scheduling ---
planned_court_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("courts.id"), nullable=True
)
planned_start_time: Mapped[Optional[datetime]] = mapped_column(
DateTime, nullable=True
)
# --- Flow Logic ---
source_p1_node_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
source_p2_node_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
source_p1_type: Mapped[Optional[MatchSourceType]] = mapped_column(
SqlEnum(MatchSourceType), nullable=True
)
source_p2_type: Mapped[Optional[MatchSourceType]] = mapped_column(
SqlEnum(MatchSourceType), nullable=True
)
# --- Next Step ---
winner_next_node_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
loser_next_node_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
# --- Current State ---
p1_team_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("teams.id"), nullable=True
)
p2_team_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("teams.id"), nullable=True
)
# Link to actual match (only exists if active)
match: Mapped[Optional["Match"]] = relationship(
"Match", back_populates="node", uselist=False
)
tournament: Mapped["Tournament"] = relationship(back_populates="nodes")
court: Mapped[Optional["Court"]] = relationship()
# Helper to get team names quickly
p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id])
p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_id])
class Match(Base): class Match(Base):
__tablename__ = "matches" __tablename__ = "matches"
id: Mapped[str] = mapped_column(String, primary_key=True) id: Mapped[str] = mapped_column(String, primary_key=True)
tournament_id: Mapped[str] = mapped_column(ForeignKey("tournaments.id")) tournament_id: Mapped[str] = mapped_column(ForeignKey("tournaments.id"))
node_id: Mapped[str] = mapped_column(ForeignKey("bracket_nodes.id"), unique=True) match_number: Mapped[int] = mapped_column(Integer)
round_number: Mapped[int] = mapped_column(Integer, default=1)
bracket_type: Mapped[BracketTypes] = mapped_column(SqlEnum(BracketTypes))
court_id: Mapped[Optional[int]] = mapped_column( court_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("courts.id"), nullable=True ForeignKey("courts.id"), nullable=True
) )
timestamp: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) start_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
status: Mapped[MatchStatus] = mapped_column( status: Mapped[MatchStatus] = mapped_column(
SqlEnum(MatchStatus), default=MatchStatus.PENDING SqlEnum(MatchStatus), default=MatchStatus.SCHEDULED
) )
p1_team_id: Mapped[int] = mapped_column(ForeignKey("teams.id")) p1_team_id: Mapped[Optional[int]] = mapped_column(
p2_team_id: Mapped[int] = mapped_column(ForeignKey("teams.id")) ForeignKey("teams.id"), nullable=True
)
p2_team_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("teams.id"), nullable=True
)
sets: Mapped[list[dict]] = mapped_column(JSON, default=list) sets: Mapped[list[dict]] = mapped_column(JSON, default=list)
winner_team_id: Mapped[Optional[int]] = mapped_column( winner_team_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("teams.id"), nullable=True ForeignKey("teams.id"), nullable=True
) )
winner_next_match_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("matches.id"), nullable=True
)
loser_next_match_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("matches.id"), nullable=True
)
node: Mapped["BracketNode"] = relationship(back_populates="match")
tournament: Mapped["Tournament"] = relationship(back_populates="matches") tournament: Mapped["Tournament"] = relationship(back_populates="matches")
court: Mapped[Optional["Court"]] = relationship() court: Mapped[Optional["Court"]] = relationship()
p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id]) p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id])
p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_id]) p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_id])
winner_next_match: Mapped["Match"] = relationship(
"Match", remote_side=[id], foreign_keys=[winner_next_match_id]
)
loser_next_match: Mapped["Match"] = relationship(
"Match", remote_side=[id], foreign_keys=[loser_next_match_id]
)
+1 -1
View File
@@ -3,4 +3,4 @@ from fastapi import APIRouter
router = APIRouter(prefix="/tournaments", tags=["Tournaments"]) router = APIRouter(prefix="/tournaments", tags=["Tournaments"])
from . import tournaments, teams, courts, matches, report, bracket from . import tournaments, teams, courts, matches, report
-15
View File
@@ -1,15 +0,0 @@
# backend/app/routes/tournaments/bracket.py
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import crud, schemas
from ...database import get_db
from . import router
@router.get("/{id}/bracket", response_model=list[schemas.BracketNodeOut])
def get_tournament_bracket(id: str, db: Session = Depends(get_db)):
t = crud.get_tournament(db, id)
if not t:
raise HTTPException(404, "Tournament not found")
return t.nodes
+12 -9
View File
@@ -1,5 +1,5 @@
# backend/app/routes/tournaments/report.py # backend/app/routes/tournaments/report.py
from typing import List, Optional from typing import Optional
from fastapi import Depends, HTTPException, Query from fastapi import Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -45,7 +45,9 @@ async def report_score(
_apply_score(match, report.sets) _apply_score(match, report.sets)
flag_modified(match, "sets") flag_modified(match, "sets")
db.commit() db.commit()
logic.advance_flow(db, t)
if match.winner_team_id:
logic.advance_winner(db, match, match.winner_team_id)
await send_ws_update(id) await send_ws_update(id)
return SUCCESS return SUCCESS
@@ -59,11 +61,6 @@ async def edit_score(
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: Optional[str] = Depends(get_optional_user), user: Optional[str] = Depends(get_optional_user),
): ):
"""
Allows correcting a score.
Note: If the winner changes, 'advance_flow' might need to handle
undoing previous advancements, but for now we just re-run the flow.
"""
t = crud.get_tournament(db, id) t = crud.get_tournament(db, id)
if not t: if not t:
raise HTTPException(404, "Tournament not found") raise HTTPException(404, "Tournament not found")
@@ -74,12 +71,16 @@ async def edit_score(
if not match: if not match:
raise HTTPException(404, "Match not found") raise HTTPException(404, "Match not found")
logic.undo_advancement(db, match)
if report.sets: if report.sets:
_apply_score(match, report.sets) _apply_score(match, report.sets)
flag_modified(match, "sets") flag_modified(match, "sets")
db.commit() db.commit()
logic.advance_flow(db, t)
if match.winner_team_id:
logic.advance_winner(db, match, match.winner_team_id)
await send_ws_update(id) await send_ws_update(id)
return SUCCESS return SUCCESS
@@ -103,6 +104,8 @@ async def clear_score(
if not match: if not match:
raise HTTPException(404, "Match not found") raise HTTPException(404, "Match not found")
logic.undo_advancement(db, match)
match.winner_team_id = None match.winner_team_id = None
match.status = MatchStatus.PENDING match.status = MatchStatus.PENDING
match.sets = [] match.sets = []
@@ -114,7 +117,7 @@ async def clear_score(
return SUCCESS return SUCCESS
def _apply_score(match: models.Match, sets: List[schemas.SetScore]): def _apply_score(match: models.Match, sets: list[schemas.SetScore]):
""" """
Calculates winner based on sets and updates the match object. Calculates winner based on sets and updates the match object.
Does NOT commit to DB. Does NOT commit to DB.
+1 -1
View File
@@ -6,7 +6,7 @@ from ..core.websocket_manager import manager
router = APIRouter(prefix="/ws", tags=["Websocket"]) router = APIRouter(prefix="/ws", tags=["Websocket"])
@router.websocket("/") @router.websocket("")
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket) await manager.connect(websocket)
try: try:
+14 -26
View File
@@ -3,7 +3,7 @@ from datetime import datetime
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from .constants import BracketType, MatchSourceType, MatchStatus, TournamentTypes from .constants import MatchStatus, TournamentTypes, BracketTypes
class TeamSchema(BaseModel): class TeamSchema(BaseModel):
@@ -38,39 +38,26 @@ class ScoreReport(BaseModel):
class MatchOut(BaseModel): class MatchOut(BaseModel):
id: str id: str
node_id: str match_number: int
round_number: int
bracket_type: BracketTypes
status: MatchStatus status: MatchStatus
timestamp: datetime | None = None start_time: datetime | None = None
court: CourtSchema | None = None court_id: int | None = None
p1_team: TeamSchema | None = None
p2_team: TeamSchema | None = None p1_team_id: int | None = None
p2_team_id: int | None = None
winner_team_id: int | None = None winner_team_id: int | None = None
winner_next_match_id: str | None = None
loser_next_match_id: str | None = None
sets: list[SetScore] = [] sets: list[SetScore] = []
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class BracketNodeOut(BaseModel):
id: str
display_number: int
bracket_type: BracketType
round_number: int
planned_start_time: datetime | None = None
planned_court_id: int | None = None
source_p1_node_id: str | None = None
source_p2_node_id: str | None = None
source_p1_type: MatchSourceType | None = None
source_p2_type: MatchSourceType | None = None
winner_next_node_id: str | None = None
loser_next_node_id: str | None = None
p1_team: TeamSchema | None = None
p2_team: TeamSchema | None = None
match: MatchOut | None = None
model_config = ConfigDict(from_attributes=True)
class TournamentCreate(BaseModel): class TournamentCreate(BaseModel):
name: str name: str
code: str code: str
@@ -106,6 +93,7 @@ class TournamentDetail(BaseModel):
type: TournamentTypes type: TournamentTypes
teams: list[TeamSchema] teams: list[TeamSchema]
courts: list[CourtSchema] courts: list[CourtSchema]
matches: list[MatchOut]
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
Binary file not shown.
+23 -18
View File
@@ -15,16 +15,19 @@ async def test_scoring_flow(
t_id = res.json()["id"] t_id = res.json()["id"]
t_code = valid_tournament_payload["code"] t_code = valid_tournament_payload["code"]
# 2. Get Bracket Nodes (Updated Endpoint) # 2. Get Bracket (Now returns list of Matches)
bracket_res = await client.get(f"/tournaments/{t_id}/bracket") # Note: You can now get this from GET /tournaments/{id} or /bracket depending on your routes
nodes = bracket_res.json() bracket_res = await client.get(f"/tournaments/{t_id}")
matches = bracket_res.json()["matches"]
# Find active node # Find the first playable match (Round 1)
active_node = next(n for n in nodes if n.get("match") is not None) # We look for a match that has teams assigned but is not finished
active_match = next(
m for m in matches if m["status"] == "Pending" and m["p1_team"] and m["p2_team"]
)
match_data = active_node["match"] match_id = active_match["id"]
match_id = match_data["id"] next_match_id = active_match["winner_next_match_id"]
next_node_id = active_node["winner_next_node_id"]
# 3. Report Score # 3. Report Score
score_payload = { score_payload = {
@@ -38,15 +41,17 @@ async def test_scoring_flow(
) )
assert report_res.status_code == 200 assert report_res.status_code == 200
# 4. Verify Winner Advanced (Fetch bracket again) # 4. Verify Winner Advanced
updated_res = await client.get(f"/tournaments/{t_id}/bracket") updated_res = await client.get(f"/tournaments/{t_id}")
updated_nodes = updated_res.json() updated_matches = updated_res.json()["matches"]
target_node = next(n for n in updated_nodes if n["id"] == next_node_id) target_match = next(m for m in updated_matches if m["id"] == next_match_id)
winner_id = match_data["p1_team"]["id"] winner_id = active_match["p1_team"]["id"]
p1_in_target = target_node["p1_team"]["id"] if target_node["p1_team"] else None
p2_in_target = target_node["p2_team"]["id"] if target_node["p2_team"] else None # Check if winner is now in the next match
p1_in_target = target_match["p1_team"]["id"] if target_match["p1_team"] else None
p2_in_target = target_match["p2_team"]["id"] if target_match["p2_team"] else None
assert winner_id in [p1_in_target, p2_in_target] assert winner_id in [p1_in_target, p2_in_target]
@@ -67,9 +72,9 @@ async def test_clear_score(client: AsyncClient, auth_headers, valid_tournament_p
t_id = res.json()["id"] t_id = res.json()["id"]
# Get active match # Get active match
bracket = (await client.get(f"/tournaments/{t_id}/bracket")).json() detail = (await client.get(f"/tournaments/{t_id}")).json()
active_node = next(n for n in bracket if n.get("match")) active_match = next(m for m in detail["matches"] if m["p1_team"] and m["p2_team"])
match_id = active_node["match"]["id"] match_id = active_match["id"]
score_payload = {"id": match_id, "code": "1234", "sets": [{"p1": 25, "p2": 0}]} score_payload = {"id": match_id, "code": "1234", "sets": [{"p1": 25, "p2": 0}]}
await client.post( await client.post(
+28 -10
View File
@@ -20,14 +20,13 @@ async def test_manage_teams(
f"/tournaments/{t_id}/teams", json=new_team, headers=auth_headers f"/tournaments/{t_id}/teams", json=new_team, headers=auth_headers
) )
assert post_res.status_code == 200 assert post_res.status_code == 200
assert post_res.json()["name"] == "Team E"
# 2. Verify Bracket Regenerated (Call new endpoint) # 2. Verify Bracket Regenerated
bracket_res = await client.get(f"/tournaments/{t_id}/bracket") detail_res = await client.get(f"/tournaments/{t_id}")
nodes = bracket_res.json() matches = detail_res.json()["matches"]
# With 5 teams -> size 8 bracket # With 5 teams, bracket size increases
assert len(nodes) > 4 assert len(matches) > 3
# 3. Bulk Update # 3. Bulk Update
new_team_list = ["Team X", "Team Y"] new_team_list = ["Team X", "Team Y"]
@@ -35,8 +34,27 @@ async def test_manage_teams(
f"/tournaments/{t_id}/teams", json=new_team_list, headers=auth_headers f"/tournaments/{t_id}/teams", json=new_team_list, headers=auth_headers
) )
assert patch_res.status_code == 200 assert patch_res.status_code == 200
data = patch_res.json()
assert len(data) == 2 updated_detail = await client.get(f"/tournaments/{t_id}")
final_matches = updated_detail.json()["matches"]
# Fix: For Double Elim with 2 teams, we might get 2 matches (WB Final + Grand Final).
# Just ensure we have at least 1 match.
assert len(final_matches) >= 1
# Verify the teams are actually in the first match
first_match = next(
m
for m in final_matches
if m["name"] == "Winners Final"
or m["name"] == "Grand Final"
or m["name"].startswith("WB")
)
p1_name = first_match["p1_team"]["name"] if first_match["p1_team"] else None
p2_name = first_match["p2_team"]["name"] if first_match["p2_team"] else None
assert "Team X" in [p1_name, p2_name]
assert "Team Y" in [p1_name, p2_name]
async def test_manage_courts( async def test_manage_courts(
@@ -48,8 +66,8 @@ async def test_manage_courts(
t_id = res.json()["id"] t_id = res.json()["id"]
# Get initial courts # Get initial courts
courts_res = await client.get(f"/tournaments/{t_id}/courts") courts_res = await client.get(f"/tournaments/{t_id}")
initial_courts = courts_res.json() initial_courts = courts_res.json()["courts"]
assert len(initial_courts) == 2 assert len(initial_courts) == 2
# Delete # Delete
+31 -28
View File
@@ -33,7 +33,7 @@ async def test_list_tournaments(
assert data[0]["name"] == "Test Tournament" assert data[0]["name"] == "Test Tournament"
async def test_get_tournament_detail_and_bracket( async def test_delete_tournament(
client: AsyncClient, auth_headers, valid_tournament_payload client: AsyncClient, auth_headers, valid_tournament_payload
): ):
create_res = await client.post( create_res = await client.post(
@@ -41,25 +41,41 @@ async def test_get_tournament_detail_and_bracket(
) )
t_id = create_res.json()["id"] t_id = create_res.json()["id"]
# 1. Test Light Detail Endpoint del_res = await client.delete(f"/tournaments/{t_id}", headers=auth_headers)
assert del_res.status_code == 200
get_res = await client.get(f"/tournaments/{t_id}")
assert get_res.status_code == 404
async def test_get_tournament_detail(
client: AsyncClient, auth_headers, valid_tournament_payload
):
create_res = await client.post(
"/tournaments", json=valid_tournament_payload, headers=auth_headers
)
t_id = create_res.json()["id"]
# 1. Test Detail Endpoint
response = await client.get(f"/tournaments/{t_id}") response = await client.get(f"/tournaments/{t_id}")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
# Should HAVE metadata # Should HAVE metadata
assert len(data["teams"]) == 4 assert len(data["teams"]) == 4
# Should NOT have heavy bracket data
# Should HAVE matches (since we merged nodes into matches and put them in Detail)
assert "matches" in data
assert len(data["matches"]) > 0
# Check structure of a match
first_match = data["matches"][0]
assert "id" in first_match
assert "winner_next_match_id" in first_match
# Ensure no old "nodes" key
assert "nodes" not in data assert "nodes" not in data
# 2. Test New Bracket Endpoint
bracket_res = await client.get(f"/tournaments/{t_id}/bracket")
assert bracket_res.status_code == 200
nodes = bracket_res.json()
assert len(nodes) > 0
first_node = nodes[0]
assert "display_number" in first_node
async def test_update_settings( async def test_update_settings(
client: AsyncClient, auth_headers, valid_tournament_payload client: AsyncClient, auth_headers, valid_tournament_payload
@@ -77,19 +93,6 @@ async def test_update_settings(
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["name"] == "Updated Name" assert data["name"] == "Updated Name"
assert data["code"] == "9999" # The response model is TournamentUpdateResponse which inherits TournamentDetail
# So it should also have matches
assert "matches" in data
async def test_delete_tournament(
client: AsyncClient, auth_headers, valid_tournament_payload
):
create_res = await client.post(
"/tournaments", json=valid_tournament_payload, headers=auth_headers
)
t_id = create_res.json()["id"]
del_res = await client.delete(f"/tournaments/{t_id}", headers=auth_headers)
assert del_res.status_code == 200
get_res = await client.get(f"/tournaments/{t_id}")
assert get_res.status_code == 404
-46
View File
@@ -1,46 +0,0 @@
/* frontend/src/App.css */
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+9 -4
View File
@@ -1,23 +1,28 @@
// frontend/src/App.jsx // frontend/src/App.jsx
import React, { useState, useEffect } from 'react'; import { useEffect, useState } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import Layout from './components/Layout/Layout'; import Layout from './components/Layout/Layout';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import TournamentPage from './pages/TournamentPage';
import Login from './pages/Login'; import Login from './pages/Login';
import Tournament from './pages/Tournament';
export default function App() { export default function App() {
const [darkMode, setDarkMode] = useState(() => localStorage.theme === 'dark'); const [darkMode, setDarkMode] = useState(() => localStorage.theme === 'dark');
useEffect(() => { useEffect(() => {
const root = window.document.documentElement; const root = window.document.documentElement;
const body = window.document.body;
if (darkMode) { if (darkMode) {
root.classList.add('dark'); root.classList.add('dark');
localStorage.setItem('theme', 'dark'); localStorage.setItem('theme', 'dark');
root.style.backgroundColor = '#09090b'; // zinc-950
body.style.backgroundColor = '#09090b';
} else { } else {
root.classList.remove('dark'); root.classList.remove('dark');
localStorage.setItem('theme', 'light'); localStorage.setItem('theme', 'light');
root.style.backgroundColor = '#fafafa'; // zinc-50
body.style.backgroundColor = '#fafafa';
} }
}, [darkMode]); }, [darkMode]);
@@ -29,7 +34,7 @@ export default function App() {
{/* Main App Layout */} {/* Main App Layout */}
<Route element={<Layout darkMode={darkMode} setDarkMode={setDarkMode} />}> <Route element={<Layout darkMode={darkMode} setDarkMode={setDarkMode} />}>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/tournaments/:id" element={<TournamentPage />} /> <Route path="/tournaments/:id" element={<Tournament />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
@@ -1,99 +0,0 @@
// frontend/src/components/Bracket/BracketNode.jsx
import React, { useState } from 'react';
import clsx from 'clsx';
import ScoreModal from '../Tournament/ScoreModal';
export default function BracketNode({ node, tournamentId, isAdmin, isFinal }) {
const [showScore, setShowScore] = useState(false);
const match = node.match;
// Display Logic
const p1 = node.p1_team;
const p2 = node.p2_team;
const isBye = !p2 && node.round_number === 1; // Simplistic BYE detection
// Don't show score modal if it's not a real match
const canInteract = match && !isBye;
return (
<>
<div
className={clsx(
"relative w-64 bg-white dark:bg-zinc-900 rounded-lg border shadow-sm transition-all overflow-hidden group",
match?.winner_team_id ? "border-orange-500/50 dark:border-orange-500/50" : "border-zinc-300 dark:border-zinc-800",
canInteract ? "cursor-pointer hover:border-orange-500 hover:shadow-md" : "opacity-80"
)}
onClick={() => canInteract && setShowScore(true)}
>
{/* Anchors for Lines */}
<div id={`node-left-${node.id}`} className="absolute top-1/2 -left-1 w-1 h-1" />
<div id={`node-right-${node.id}`} className="absolute top-1/2 -right-1 w-1 h-1" />
{/* Header */}
<div className="flex justify-between items-center px-3 py-1.5 bg-zinc-50 dark:bg-zinc-950/50 border-b border-zinc-200 dark:border-zinc-800">
<div className="flex items-center gap-2">
<span className="text-[9px] font-black text-zinc-400">#{node.display_number}</span>
{node.match?.court && (
<span className="text-[8px] font-bold text-white px-1.5 py-0.5 rounded bg-[#0891b2] uppercase tracking-wider">
{node.match.court.name}
</span>
)}
</div>
<div className="text-[9px] font-bold text-zinc-500 font-mono">
{new Date(node.match?.start_time || node.planned_start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
{/* Teams */}
<div className="p-2 space-y-1">
{/* P1 Row */}
<div className="flex justify-between items-center px-1 rounded hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition">
<span className={clsx(
"text-xs font-bold uppercase truncate max-w-[180px]",
match?.winner_team_id && match.winner_team_id === p1?.id ? "text-orange-600 dark:text-orange-500" : "text-zinc-700 dark:text-zinc-300",
!p1 && "text-zinc-400 italic font-medium"
)}>
{p1 ? p1.name : (node.source_p1_type ? 'TBD' : 'Bye')}
</span>
{match && <span className="text-[10px] font-mono font-black text-zinc-400 dark:text-zinc-600 bg-zinc-100 dark:bg-zinc-800 px-1.5 rounded">{getWinCount(match, p1?.id)}</span>}
</div>
{/* P2 Row */}
<div className="flex justify-between items-center px-1 rounded hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition">
<span className={clsx(
"text-xs font-bold uppercase truncate max-w-[180px]",
match?.winner_team_id && match.winner_team_id === p2?.id ? "text-orange-600 dark:text-orange-500" : "text-zinc-700 dark:text-zinc-300",
!p2 && "text-zinc-400 italic font-medium"
)}>
{p2 ? p2.name : (node.source_p2_type ? 'TBD' : 'Bye')}
</span>
{match && <span className="text-[10px] font-mono font-black text-zinc-400 dark:text-zinc-600 bg-zinc-100 dark:bg-zinc-800 px-1.5 rounded">{getWinCount(match, p2?.id)}</span>}
</div>
</div>
</div>
{match && (
<ScoreModal
isOpen={showScore}
onClose={() => setShowScore(false)}
match={match}
tournamentId={tournamentId}
isAdmin={isAdmin}
p1Name={p1?.name || 'TBD'}
p2Name={p2?.name || 'TBD'}
/>
)}
</>
);
}
function getWinCount(match, teamId) {
if (!match.sets || !teamId) return 0;
let wins = 0;
match.sets.forEach(s => {
if (teamId === match.p1_team_id && s.p1 > s.p2) wins++;
if (teamId === match.p2_team_id && s.p2 > s.p1) wins++;
});
return wins;
}
+109 -112
View File
@@ -1,142 +1,139 @@
// frontend/src/components/Bracket/BracketView.jsx // frontend/src/components/Bracket/BracketView.jsx
import { Check, Trophy } from 'lucide-react'; import React, { useEffect, useRef, useState } from 'react';
import { useEffect, useRef } from 'react'; import { Check } from 'lucide-react';
import { stringToColor } from '../../utils/helpers'; import { stringToColor } from '../../utils/helpers';
// --- EXACT COPY OF YOUR OLD MATCHCARD (Adapted data props) --- const MatchCard = ({ match, onClick }) => {
const MatchCard = ({ node, onClick }) => { const isFinished = match.status === "Finished";
// GHOST NODE LOGIC: If no display number, it's a structural/hidden node const badgeColor = match.time ? stringToColor(match.court) : null;
if (!node.display_number) return <div id={`node-${node.id}`} className="hidden" />;
const match = node.match; let borderClass = 'border-zinc-300 dark:border-zinc-700';
const p1 = node.p1_team; if (isFinished) borderClass = 'border-orange-500 ring-2 ring-orange-500/10';
const p2 = node.p2_team;
// Old logic: "isPending" means we don't have two players yet const canInteract = match.hasTeams;
const isPending = !p1 || !p2;
const badgeColor = match?.court ? stringToColor(match.court.name) : null;
const timeDisplay = match?.start_time || node.planned_start_time;
// Calculate wins for display const cursorClass = canInteract
const getWins = (teamId) => { ? 'cursor-pointer hover:shadow-md hover:-translate-y-0.5'
if (!match?.sets) return 0; : 'cursor-default opacity-100';
return match.sets.reduce((acc, s) => acc + (s.p1 > s.p2 && match.p1_team_id === teamId ? 1 : (s.p2 > s.p1 && match.p2_team_id === teamId ? 1 : 0)), 0);
}
const p1Wins = p1 ? getWins(p1.id) : 0;
const p2Wins = p2 ? getWins(p2.id) : 0;
const winnerId = match?.winner_team_id;
return ( return (
<div <div
id={`node-${node.id}`} id={`match-${match.id}`}
onClick={() => !isPending && onClick(node)} onClick={() => canInteract && onClick(match)}
className={`w-64 bg-white dark:bg-zinc-900 rounded-xl border-2 ${winnerId ? 'border-orange-500 ring-4 ring-orange-500/10' : 'border-zinc-300 dark:border-zinc-800'} shadow-sm ${!isPending ? 'cursor-pointer hover:-translate-y-1 transition duration-200 group' : 'opacity-80 cursor-default'} overflow-hidden transition-all`} 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}`}
> >
{/* Header */} <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="bg-zinc-50 dark:bg-zinc-950/50 px-3 py-2 flex justify-between items-center border-b border-zinc-200 dark:border-zinc-800">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono text-[10px] font-black text-zinc-500 dark:text-zinc-500 uppercase"># {node.display_number}</span> <span className="font-mono text-[10px] font-bold text-zinc-400"># {match.number}</span>
{match?.court && <span className="text-[9px] font-black text-white px-1.5 py-0.5 rounded uppercase" style={{ background: badgeColor }}>{match.court.name}</span>} {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> </div>
{winnerId ? <Check className="text-orange-500" size={14} strokeWidth={4} /> : <span className="text-[10px] font-black text-zinc-800 dark:text-zinc-300 font-mono">{timeDisplay ? new Date(timeDisplay).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : 'TBD'}</span>} {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>
<div className="p-3 space-y-2">
{/* Content */} {[{ n: match.p1, s: match.p1_sets, win: match.winner === match.p1, real: match.p1_is_real },
<div className="p-3 space-y-1.5"> { n: match.p2, s: match.p2_sets, win: match.winner === match.p2, real: match.p2_is_real }].map((p, i) => (
{/* P1 */} <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'}`}>
<div className={`flex justify-between items-center ${winnerId === p1?.id ? 'text-orange-600 dark:text-orange-500 font-black' : 'text-zinc-900 dark:text-zinc-400 font-bold'}`}> <span className="truncate text-xs uppercase tracking-tight">{p.n}</span>
<span className="truncate text-xs uppercase tracking-tight font-bold">{p1 ? p1.name : (node.source_p1_type ? 'TBD' : 'Bye')}</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>
<span className="bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 rounded text-[10px] font-black">{p1Wins}</span>
</div> </div>
{/* P2 */}
<div className={`flex justify-between items-center ${winnerId === p2?.id ? 'text-orange-600 dark:text-orange-500 font-black' : 'text-zinc-900 dark:text-zinc-400 font-bold'}`}>
<span className="truncate text-xs uppercase tracking-tight font-bold">{p2 ? p2.name : (node.source_p2_type ? 'TBD' : 'Bye')}</span>
<span className="bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 rounded text-[10px] font-black">{p2Wins}</span>
</div>
</div>
</div>
);
};
export default function BracketView({ nodes, onMatchClick }) {
const containerRef = useRef(null);
const svgRef = useRef(null);
useEffect(() => {
// --- EXACT COPY OF YOUR SVG LOGIC ---
if (!containerRef.current || !svgRef.current) return;
const container = containerRef.current.getBoundingClientRect();
const svg = svgRef.current;
while (svg.firstChild) svg.removeChild(svg.firstChild);
nodes.forEach(node => {
// Logic: Find DOM elements by ID
const sEl = document.getElementById(`node-${node.id}`);
const eEl = document.getElementById(`node-${node.winner_next_node_id}`);
// Only draw if both exist and are visible
if (sEl && eEl && sEl.offsetParent !== null && eEl.offsetParent !== null) {
const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
// Calculate connection points
const sx = r1.right - container.left, sy = r1.top + r1.height / 2 - container.top;
const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top;
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const c1 = sx + (ex - sx) / 2; // Control point X
path.setAttribute("d", `M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`);
path.setAttribute("class", "stroke-zinc-300 dark:stroke-zinc-800 fill-none stroke-[2px] opacity-40");
svg.appendChild(path);
}
});
}, [nodes]); // Re-run when nodes change
const renderTree = (list, align = 'justify-center') => {
const rounds = {};
list.forEach(n => { if (!rounds[n.round_number]) rounds[n.round_number] = []; rounds[n.round_number].push(n); });
return Object.keys(rounds)
.sort((a, b) => a - b)
.filter(r => rounds[r].some(n => n.display_number)) // Filter empty rounds
.map(r => (
<div key={r} className={`flex flex-col ${align} gap-12 min-w-[280px] z-10`}>
{rounds[r].sort((a, b) => a.display_number - b.display_number).map(n => (
<MatchCard key={n.id} node={n} onClick={onMatchClick} />
))} ))}
</div> </div>
)); </div>
);
}; };
const wb = nodes.filter(n => n.bracket_type === 'Winners'); export default function BracketView({ matches, onMatchClick }) {
const lb = nodes.filter(n => n.bracket_type === 'Losers'); const containerRef = useRef(null);
const finals = nodes.filter(n => n.bracket_type === 'Finals'); const [lines, setLines] = useState([]);
useEffect(() => {
const draw = () => {
if (!containerRef.current) return;
const container = containerRef.current.getBoundingClientRect();
const newLines = [];
matches.forEach(m => {
if (!m.winner_next_match_id) return;
const sEl = document.getElementById(`match-${m.id}`);
const eEl = document.getElementById(`match-${m.winner_next_match_id}`);
if (sEl && eEl) {
const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
const sx = r1.right - container.left, sy = r1.top + r1.height / 2 - container.top;
const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top;
const c1 = sx + (ex - sx) / 2;
newLines.push(<path key={`${m.id}-${m.winner_next_match_id}`} d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-300 dark:stroke-zinc-700 fill-none stroke-[1.5px]" />);
}
});
setLines(newLines);
};
const t = setTimeout(draw, 100);
window.addEventListener('resize', draw);
return () => { clearTimeout(t); window.removeEventListener('resize', draw); };
}, [matches]);
const renderRound = (list) => {
const rounds = {};
list.forEach(m => (rounds[m.round] = rounds[m.round] || []).push(m));
const roundKeys = Object.keys(rounds).sort((a, b) => Number(a) - Number(b));
let prevRoundMap = new Map();
return roundKeys.map((r, rIdx) => {
let matchesInRound = rounds[r];
if (rIdx === 0) {
matchesInRound.sort((a, b) => a.number - b.number);
} else {
matchesInRound.sort((a, b) => {
const getSourceAvg = (match) => {
const sources = list.filter(x => x.next_win === 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;
return indices.reduce((sum, val) => sum + val, 0) / indices.length;
};
return getSourceAvg(a) - getSourceAvg(b);
});
}
matchesInRound.forEach((m, idx) => prevRoundMap.set(m.id, idx));
return ( return (
<div className="w-full h-full overflow-auto p-8 bg-[radial-gradient(#d1d5db_1px,transparent_1px)] dark:bg-[radial-gradient(#18181b_1px,transparent_1px)] [background-size:20px_20px]"> <div key={r} className="flex flex-col gap-10 z-10 w-64 shrink-0 justify-around">
<div ref={containerRef} className="relative min-w-max p-4 flex gap-24"> {matchesInRound.map(m => <MatchCard key={m.id} match={m} onClick={onMatchClick} />)}
<svg ref={svgRef} className="absolute inset-0 w-full h-full pointer-events-none z-0" /> </div>
);
});
};
const wb = matches.filter(m => m.bracket === 'Winner');
const lb = matches.filter(m => m.bracket === 'Loser');
const finals = matches.filter(m => m.bracket === 'Finals');
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}
</svg>
<div className="flex flex-col gap-24"> <div className="flex flex-col gap-24">
<div className="relative flex gap-16"> <div className="relative">
<div className="absolute -top-10 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500 dark:text-zinc-600">Winners Bracket</div> <div className="absolute -top-8 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400">Winners Bracket</div>
{renderTree(wb)} <div className="flex gap-20">{renderRound(wb)}</div>
</div> </div>
{matches.some(m => m.bracket === 'Loser') && (
{lb.length > 0 && ( <div className="relative pt-8 border-t border-dashed border-zinc-300 dark:border-zinc-800">
<div className="relative flex flex-col gap-12 pt-16 border-t border-zinc-300 dark:border-zinc-800 w-full"> <div className="absolute top-0 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400">Losers Bracket</div>
<div className="absolute top-6 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500 dark:text-zinc-600">Losers Bracket</div> <div className="flex gap-20 mt-8">{renderRound(lb)}</div>
<div className="flex gap-16 justify-start">{renderTree(lb, 'justify-start')}</div>
</div> </div>
)} )}
</div> </div>
{matches.some(m => m.bracket === 'Finals') && (
{finals.length > 0 && ( <div className="flex flex-col justify-center gap-6">
<div className="flex flex-col justify-center items-center gap-4 relative z-10 min-w-[280px]"> <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>
<div className="absolute top-1/2 -translate-y-[calc(50%+140px)] flex items-center gap-2 bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest border border-orange-200 dark:border-orange-800 shadow-sm"> {finals.map(m => <MatchCard key={m.id} match={m} onClick={onMatchClick} />)}
<Trophy size={14} /> Championship
</div>
{finals.map(n => <MatchCard key={n.id} node={n} onClick={onMatchClick} />)}
</div> </div>
)} )}
</div> </div>
+154 -43
View File
@@ -1,83 +1,194 @@
// frontend/src/components/Forms/TournamentForm.jsx // frontend/src/components/Forms/TournamentForm.jsx
import { useState } from 'react'; import React, { useState } from 'react';
import api from '../../services/api'; import api from '../../services/api';
export default function TournamentForm({ initialData, onSuccess }) { export default function TournamentForm({ tournament, onSuccess, onDelete }) {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
setIsSubmitting(true); setIsSubmitting(true);
setError(null);
const formData = new FormData(e.target); const formData = new FormData(e.target);
const data = Object.fromEntries(formData.entries());
// Extract raw values to transform
const rawTeams = formData.get('teams');
const rawCourts = formData.get('courts');
const date = formData.get('date');
const startTime = formData.get('start_time');
const typeRaw = formData.get('type');
const duration = formData.get('duration');
const name = formData.get('name');
const code = formData.get('code');
const teams = rawTeams.split('\n').map(t => t.trim()).filter(t => t.length > 0);
const courts = rawCourts.split(',').map(c => c.trim()).filter(c => c.length > 0);
if (teams.length < 2) {
setError("At least 2 teams required.");
setIsSubmitting(false);
return;
}
const timestamp = new Date(`${date}T${startTime}`).toISOString();
// Format Arrays
const payload = { const payload = {
...data, name,
duration: parseInt(data.duration), code,
timestamp: `${data.date}T${data.time}:00`, type: typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1),
courts: data.courts.split(',').map(s => s.trim()).filter(Boolean), timestamp,
teams: data.teams.split('\n').map(s => s.trim()).filter(Boolean) duration: parseInt(duration),
teams,
courts
}; };
try { try {
if (initialData) await api.patch(`/tournaments/${initialData.id}`, payload); if (tournament) await api.patch(`/tournaments/${tournament.id}`, payload);
else await api.post('/tournaments', payload); else await api.post('/tournaments', payload);
onSuccess(); onSuccess();
} catch (err) { } catch (err) {
setError(typeof err.detail === 'string' ? err.detail : "Error saving"); console.error(err);
if (Array.isArray(err.detail)) {
setError(err.detail.map(e => `${e.loc.join('.')}: ${e.msg}`).join(', '));
} else {
setError(typeof err.detail === 'string' ? err.detail : "Error saving tournament");
}
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
const handleDelete = async () => { // Calculate default date/time for form
if (!window.confirm("Purge this tournament and all its history?")) return; const defaultDate = tournament?.timestamp
try { ? new Date(tournament.timestamp).toISOString().split('T')[0]
await api.delete(`/tournaments/${initialData.id}`); : new Date().toISOString().split('T')[0];
window.location.href = '/';
} catch (err) { alert("Error deleting"); }
};
// Defaults const defaultTime = tournament?.timestamp
const defaultDate = initialData?.timestamp ? new Date(initialData.timestamp).toISOString().split('T')[0] : new Date().toISOString().split('T')[0]; ? new Date(tournament.timestamp).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
const defaultTime = initialData?.timestamp ? new Date(initialData.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) : "09:00"; : "09:00";
const defaultTeams = initialData?.teams?.map(t => t.name).join('\n');
const defaultCourts = initialData?.courts?.map(c => c.name).join(', ');
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm font-bold border border-red-100">{error}</div>} {error && (
<div> <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">
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-500">Name</label> {error}
<input name="name" defaultValue={initialData?.name} required className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-3 rounded-xl dark:text-white outline-none focus:border-orange-500 font-bold" />
</div> </div>
)}
<div className="space-y-4">
<div>
<label className="text-xs font-bold text-zinc-500 uppercase">Name</label>
<input
name="name"
defaultValue={tournament?.name}
required
placeholder="My Awesome Tournament"
autoFocus
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
/>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-500">Access Code</label> <label className="font-bold text-zinc-500 text-xs uppercase">Code</label>
<input name="code" defaultValue={initialData?.code} required className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-3 rounded-xl text-center font-mono dark:text-white font-bold" /> <input
name="code"
defaultValue={tournament?.code}
required
placeholder="••••"
autoComplete="off"
className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 text-center font-mono focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
/>
</div> </div>
<div> <div>
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-500">Type</label> <label className="font-bold text-zinc-500 text-xs uppercase">Type</label>
<select name="type" defaultValue={initialData?.type || "Double"} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-3 rounded-xl dark:text-white outline-none font-bold"> <select
<option value="Double">Double Elimination</option> name="type"
<option value="Single">Single Elimination</option> defaultValue={tournament?.type?.toLowerCase() || "double"}
className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
>
<option value="double">Double Elimination</option>
<option value="single">Single Elimination</option>
</select> </select>
</div> </div>
</div> </div>
<div className="grid grid-cols-3 gap-4">
<input type="number" name="duration" placeholder="Min" defaultValue={initialData?.duration || 30} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-3 rounded-xl dark:text-white font-bold" /> <div className="grid grid-cols-7 gap-4">
<input type="time" name="time" defaultValue={defaultTime} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-3 rounded-xl dark:text-white font-bold" /> <div className="col-span-2">
<input type="date" name="date" defaultValue={defaultDate} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-3 rounded-xl dark:text-white font-bold" /> <label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Duration</label>
<input
type="number"
name="duration"
defaultValue={tournament?.duration || 30}
min="0"
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
/>
</div> </div>
<input name="courts" placeholder="Courts (e.g. Center, Court 1)" defaultValue={defaultCourts} required className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-3 rounded-xl dark:text-white font-bold" /> <div className="col-span-2">
<textarea name="teams" placeholder="Teams (one per line)" defaultValue={defaultTeams} rows={5} required className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-3 rounded-xl font-mono text-sm dark:text-white font-bold" /> <label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Start Time</label>
<div className="flex justify-between pt-4 border-t border-zinc-200 dark:border-zinc-800"> <input
{initialData && <button type="button" onClick={handleDelete} className="text-red-500 text-sm font-black uppercase tracking-widest hover:underline">Delete Tournament</button>} type="time"
<button disabled={isSubmitting} type="submit" className="bg-orange-600 hover:bg-orange-500 text-white px-8 py-3 rounded-xl font-black uppercase tracking-widest text-xs transition active:scale-95 ml-auto shadow-lg shadow-orange-600/20"> name="start_time"
{isSubmitting ? 'Saving...' : (initialData ? 'Save Changes' : 'Create Tournament')} defaultValue={defaultTime}
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
/>
</div>
<div className="col-span-3">
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Date</label>
<input
type="date"
name="date"
defaultValue={defaultDate}
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
/>
</div>
</div>
<div>
<label className="text-xs font-bold text-zinc-500 uppercase">Courts</label>
<input
name="courts"
placeholder="Center Court, Court 1"
defaultValue={tournament?.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"
/>
</div>
<div>
<label className="text-xs font-bold text-zinc-500 uppercase">Teams</label>
<textarea
name="teams"
placeholder="One team per line..."
defaultValue={tournament?.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"
/>
</div>
</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 && (
<button
type="button"
onClick={() => onDelete(tournament.id)}
className="text-red-500 text-sm hover:underline h-5 self-end"
>
Delete Tournament
</button>
)}
{!tournament && <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"
>
{isSubmitting ? 'Saving...' : (tournament ? 'Save Changes' : 'Create')}
</button> </button>
</div> </div>
</form> </form>
+40 -19
View File
@@ -1,44 +1,65 @@
// frontend/src/components/Layout/Layout.jsx // frontend/src/components/Layout/Layout.jsx
import React from 'react';
import { Outlet, useOutletContext } from 'react-router-dom';
import Navbar from './Navbar';
import { Moon, Sun } from 'lucide-react'; import { Moon, Sun } from 'lucide-react';
import api from '../../services/api'; import { useEffect, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import api, { getToken } from '../../services/api';
import Navbar from './Navbar';
export default function Layout({ darkMode, setDarkMode }) { export default function Layout({ darkMode, setDarkMode }) {
const [isAdmin, setIsAdmin] = React.useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [navTitle, setNavTitle] = useState('');
const [navSubtitle, setNavSubtitle] = useState('');
// Shared state for the navbar title, settable by child pages // State to trigger settings modals from Navbar
const [navTitle, setNavTitle] = React.useState(''); const [showSettings, setShowSettings] = useState(false);
const [navSubtitle, setNavSubtitle] = React.useState('');
React.useEffect(() => { const location = useLocation();
const navigate = useNavigate();
const isDashboard = location.pathname === '/';
useEffect(() => {
const checkAuth = async () => { const checkAuth = async () => {
if (getToken()) {
try { try {
const res = await api.get('/auth/check'); const res = await api.get('/auth/check');
setIsAdmin(true); // Endpoint returns 200 OK if token valid setIsAdmin(res.is_admin);
} catch { } catch {
setIsAdmin(false); setIsAdmin(false);
} }
}
};
checkAuth();
}, [location.pathname]);
const handleLogout = () => {
localStorage.removeItem('volleyToken');
window.location.reload();
}; };
if (localStorage.getItem('volleyToken')) checkAuth();
}, []);
return ( return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 flex flex-col"> <div className="fixed inset-0 bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 transition-colors selection:bg-orange-500/30 flex flex-col overflow-hidden">
<Navbar title={navTitle} subtitle={navSubtitle} isAdmin={isAdmin} /> <Navbar
title={navTitle}
subtitle={navSubtitle}
isAdmin={isAdmin}
onLogout={handleLogout}
onOpenSettings={() => setShowSettings(true)}
isDashboard={isDashboard}
/>
<main className="flex-1 relative overflow-hidden flex flex-col"> <main className="flex-1 overflow-hidden relative flex flex-col">
<Outlet context={{ setNavTitle, setNavSubtitle, isAdmin }} /> <Outlet context={{ setNavTitle, setNavSubtitle, isAdmin, showSettings, setShowSettings }} />
</main> </main>
<div className="fixed bottom-8 right-8 z-40"> {/* Dark Mode FAB */}
<div className="fixed bottom-6 sm:bottom-8 right-6 sm:right-8 z-40">
<button <button
onClick={() => setDarkMode(!darkMode)} onClick={() => setDarkMode(!darkMode)}
className="p-4 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-full shadow-2xl transition hover:scale-110 active:scale-95 border-2 border-zinc-700 dark:border-zinc-300" 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"
> >
{darkMode ? <Sun size={24} /> : <Moon size={24} />} {darkMode ? <Sun size={24} strokeWidth={2.5} className="sm:size-7" /> : <Moon size={24} strokeWidth={2.5} className="sm:size-7" />}
</button> </button>
</div> </div>
</div> </div>
+28 -24
View File
@@ -1,48 +1,52 @@
// frontend/src/components/Layout/Navbar.jsx // frontend/src/components/Layout/Navbar.jsx
import React from 'react'; import { Lock, LogOut, Plus, SlidersHorizontal, Volleyball } from 'lucide-react';
import { Link, useLocation } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Volleyball, LogOut, Lock } from 'lucide-react';
export default function Navbar({ title, subtitle, isAdmin }) {
const location = useLocation();
const isDashboard = location.pathname === '/';
export default function Navbar({ title, subtitle, isAdmin, onLogout, onOpenSettings, isDashboard }) {
return ( return (
<nav className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur-lg border-b border-zinc-300 dark:border-zinc-800 sticky top-0 z-[100] px-6 py-4 flex justify-between items-center shadow-sm"> <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-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-2.5 bg-orange-600 rounded-xl group-hover:rotate-12 transition-transform shadow-lg shadow-orange-600/30"> <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={24} /> <Volleyball className="text-white" size={20} />
</div> </div>
<div className="hidden sm:block"> <div className="hidden sm:block">
<h1 className="text-2xl font-black tracking-tighter leading-none text-zinc-900 dark:text-white">VolleyManager</h1> <h1 className="text-2xl font-black tracking-tighter leading-none text-zinc-900 dark:text-white">VolleyManager</h1>
<p className="text-[10px] font-black text-zinc-500 dark:text-zinc-400 uppercase tracking-widest mt-0.5">Tournament Ops</p> <p className="text-[9px] font-black text-zinc-500 dark:text-zinc-400 uppercase tracking-widest mt-0.5">Tournament Ops</p>
</div> </div>
</Link> </Link>
<div className="absolute left-1/2 -translate-x-1/2 text-center pointer-events-none"> <div className="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="font-black uppercase text-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-[0.1em] sm:tracking-[0.3em] text-zinc-900 dark:text-white truncate leading-none mb-1">
{title || 'Dashboard'} {title || 'Dashboard'}
</div> </div>
{subtitle && ( {subtitle && (
<div className="text-[10px] font-black text-zinc-400 dark:text-zinc-500 uppercase tracking-widest leading-none"> <div className="text-[8px] sm:text-[10px] font-black text-zinc-400 dark:text-zinc-500 uppercase tracking-widest leading-none">
{subtitle} {subtitle}
</div> </div>
)} )}
</div> </div>
<div className="flex gap-4 items-center"> <div className="flex gap-2 sm:gap-4 items-center shrink-0">
{isAdmin ? ( {isAdmin ? (
<button <>
onClick={() => { localStorage.removeItem('volleyToken'); window.location.reload(); }} {isDashboard ? (
className="text-zinc-400 hover:text-red-500 transition active:scale-90" <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">
title="Logout" <Plus size={16} strokeWidth={4} /> <span className="hidden xs:inline">Create</span>
>
<LogOut size={22} />
</button> </button>
) : ( ) : (
<Link to="/login" className="text-orange-600 font-black flex items-center gap-2 text-[10px] uppercase tracking-widest hover:text-orange-500 transition group p-2 rounded-xl hover:bg-orange-50 dark:hover:bg-orange-950/20"> <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">
<Lock size={14} className="group-hover:-translate-y-0.5 transition-transform" /> <span>Login</span> <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>
</>
) : (
<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>
</Link> </Link>
)} )}
</div> </div>
@@ -1,87 +1,96 @@
// frontend/src/components/Schedule/ScheduleView.jsx // frontend/src/components/Schedule/ScheduleView.jsx
import { Search } from 'lucide-react'; import { CheckCircle, Pencil, Plus, Search, Trophy } from 'lucide-react';
import { useState } from 'react'; import React, { useState } from 'react';
import { stringToColor } from '../../utils/helpers'; import { stringToColor } from '../../utils/helpers';
export default function ScheduleView({ nodes, onMatchClick }) { export default function ScheduleView({ schedule, onMatchClick }) {
const [filter, setFilter] = useState(""); const [filter, setFilter] = useState("");
// Flatten and prepare matches const longestCourt = schedule.reduce((max, m) => {
const schedule = nodes const c = m.court || "Court";
.filter(n => n.match) // Only real matches return c.length > max.length ? c : max;
.map(n => { }, "Court");
const m = n.match;
return { const badgeWidth = Math.max(100, longestCourt.length * 9);
id: m.id, // For API calls
node: n, // Pass full node for context if needed
number: n.display_number,
time: new Date(m.start_time || n.planned_start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
timestamp: new Date(m.start_time || n.planned_start_time),
court: m.court ? m.court.name : 'TBD',
p1: n.p1_team?.name,
p2: n.p2_team?.name,
p1_label: n.source_p1_type ? `Winner of #${n.source_p1_node_id}` : 'TBD', // Simplified label logic
p2_label: n.source_p2_type ? `Winner of #${n.source_p2_node_id}` : 'TBD',
bracket: n.bracket_type,
round: n.round_number,
winner: m.winner_team_id,
p1_sets: m.sets.filter(s => s.p1 > s.p2).length,
p2_sets: m.sets.filter(s => s.p2 > s.p1).length
};
})
.sort((a, b) => a.timestamp - b.timestamp);
const filtered = schedule.filter(m => const filtered = schedule.filter(m =>
(m.p1 || "").toLowerCase().includes(filter.toLowerCase()) || (m.p1 + m.p2 + m.number).toLowerCase().includes(filter.toLowerCase())
(m.p2 || "").toLowerCase().includes(filter.toLowerCase())
); );
return ( return (
<div className="h-full overflow-y-auto overflow-x-hidden relative flex flex-col"> <div className="h-full overflow-hidden relative flex flex-col">
{/* STICKY SEARCH BAR */} <div className="absolute top-0 inset-x-0 z-30 p-6 pb-2 bg-transparent pointer-events-none">
<div className="sticky top-0 z-20 bg-zinc-50 dark:bg-zinc-950 p-6 pb-2"> <div className="relative group max-w-3xl mx-auto w-full pointer-events-auto">
<div className="relative group max-w-3xl mx-auto w-full"> <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 <Search className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-400" size={20} />
placeholder="Search teams..."
className="w-full bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-800 rounded-2xl p-4 pl-12 outline-none focus:ring-2 focus:ring-orange-500 transition shadow-sm text-zinc-900 dark:text-white font-bold"
value={filter}
onChange={e => setFilter(e.target.value)}
/>
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-400 group-focus-within:text-orange-500 transition" size={20} />
</div> </div>
</div> </div>
<div className="p-6 pt-2 max-w-4xl mx-auto w-full space-y-3 pb-32"> <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)]">
{filtered.map(m => ( <div className="max-w-4xl mx-auto w-full space-y-3">
<div key={m.id} className="bg-white dark:bg-zinc-900 p-4 rounded-2xl border border-zinc-300 dark:border-zinc-800 shadow-sm flex items-center justify-between group transition-all hover:border-orange-500/30"> {filtered.map(m => {
<div className="flex gap-6 items-center"> const courtColor = stringToColor(m.court);
<div className="text-center min-w-[70px]"> const isFinished = m.isFinished;
<div className="text-xl font-black font-mono text-zinc-900 dark:text-white leading-none mb-1">{m.time}</div>
<div className="text-[9px] font-black text-white px-2 py-0.5 rounded uppercase tracking-wider" style={{ background: stringToColor(m.court) }}>{m.court}</div> return (
<div key={m.id} className="bg-white dark:bg-zinc-900 p-4 rounded-2xl border border-zinc-300 dark:border-zinc-800 shadow-sm flex items-center justify-between transition-all hover:border-orange-500/30">
<div className="flex gap-4 md:gap-6 items-center flex-1 min-w-0">
{/* UNIFORM WIDTH METADATA COLUMN */}
<div className="flex flex-col gap-1 items-center shrink-0" style={{ minWidth: badgeWidth }}>
<div className="text-lg md:text-xl font-black font-mono text-zinc-900 dark:text-white">{m.time}</div>
<div className="text-[9px] font-black text-white px-2 py-1 rounded uppercase w-full truncate text-center" style={{ background: courtColor }}>
{m.court}
</div> </div>
<div> <div className="text-[9px] font-black bg-zinc-100 dark:bg-zinc-800 text-zinc-400 px-2 py-0.5 rounded w-full md:hidden text-center">#{m.number}</div>
<div className="font-black text-base uppercase tracking-tight text-zinc-900 dark:text-zinc-100">
{m.p1 || <span className="text-zinc-400 italic lowercase font-medium">{m.p1_label}</span>}
<span className="text-zinc-300 dark:text-zinc-700 mx-2 text-xs font-black">VS</span>
{m.p2 || <span className="text-zinc-400 italic lowercase font-medium">{m.p2_label}</span>}
</div> </div>
<div className="text-[10px] font-black text-zinc-400 uppercase tracking-widest mt-1">Match #{m.number} {m.bracket} Round {m.round}</div>
<div className="flex-1 flex flex-col gap-0.5 min-w-0">
<div className="flex flex-col md:flex-row md:items-center gap-1 md:gap-2">
{[{ n: m.p1, win: m.winnerName === m.p1, real: m.p1_is_real },
{ n: m.p2, win: m.winnerName === m.p2, real: m.p2_is_real }].map((p, i) => (
<React.Fragment key={i}>
<div className="flex items-center gap-2 min-w-0">
{p.win && <Trophy size={14} className="text-orange-500 shrink-0" />}
<span className={`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>
</div>
{i === 0 && <span className="hidden md:block text-zinc-300 text-xs font-black px-1">VS</span>}
</React.Fragment>
))}
</div>
<div className="hidden md:block text-[10px] font-black bg-zinc-100 dark:bg-zinc-800 text-zinc-400 px-2 py-0.5 rounded w-fit">Match #{m.number}</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-4">
{m.winner ? ( <div className="ml-3 flex items-center shrink-0">
<div className="text-right shrink-0"> {isFinished ? (
<div className="text-orange-500 font-black text-[10px] uppercase tracking-wider mb-0.5">Finished</div> <button
onClick={() => onMatchClick(m)}
className="flex flex-col items-center md:items-end hover:bg-zinc-50 dark:hover:bg-zinc-800 p-2 rounded-xl transition min-w-[80px] group cursor-pointer"
title="Edit Score"
>
{/* Normal View: Finished + Score */}
<div className="group-hover:hidden flex flex-col items-center md:items-end">
<div className="text-orange-500 font-black text-[10px] uppercase flex items-center gap-1"><CheckCircle size={12} /> Finished</div>
<div className="text-sm font-black font-mono text-zinc-900 dark:text-zinc-300">{m.p1_sets} - {m.p2_sets}</div> <div className="text-sm font-black font-mono text-zinc-900 dark:text-zinc-300">{m.p1_sets} - {m.p2_sets}</div>
</div> </div>
) : (m.p1 && m.p2) && (
<button onClick={() => onMatchClick(m.node)} className="bg-orange-600 hover:bg-orange-500 text-white text-[10px] font-black uppercase px-5 py-2.5 rounded-xl transition shadow-lg shadow-orange-600/20 active:scale-95 shrink-0">Report</button> {/* Hover View: Edit Icon */}
<div className="hidden group-hover:flex flex-col items-center md:items-end text-zinc-500 dark:text-zinc-400 animate-in fade-in zoom-in duration-200">
<div className="text-[10px] font-black uppercase flex items-center gap-1"><Pencil size={12} /> Edit</div>
<div className="text-sm font-black font-mono">{m.p1_sets} - {m.p2_sets}</div>
</div>
</button>
) : m.isReady && (
<button onClick={() => onMatchClick(m)} className="bg-orange-600 hover:bg-orange-500 text-white p-2 md:px-4 md:py-2 rounded-xl shadow-lg active:scale-95 transition-all flex items-center gap-2">
<Plus size={18} strokeWidth={3} /> <span className="text-xs font-bold uppercase hidden md:inline">Report score</span>
</button>
)} )}
</div> </div>
</div> </div>
))} );
{filtered.length === 0 && <div className="text-center py-20 text-zinc-400 font-black uppercase tracking-widest text-xs">No matching matches found</div>} })}
</div>
</div> </div>
</div> </div>
); );
+111 -65
View File
@@ -1,37 +1,19 @@
// frontend/src/components/Tournament/ScoreModal.jsx // frontend/src/components/Tournament/ScoreModal.jsx
import { Trash2 } from 'lucide-react'; import React, { useState } from 'react';
import { useState } from 'react'; import { Eraser, Clock, MapPin } from 'lucide-react';
import api from '../../services/api';
import { stringToColor } from '../../utils/helpers'; import { stringToColor } from '../../utils/helpers';
import Modal from '../UI/Modal'; import Modal from '../UI/Modal';
export default function ScoreModal({ isOpen, onClose, node, tournamentId, isAdmin }) { const ScoreForm = ({ match, isAdmin, onSubmit, onClear }) => {
const match = node.match; // Safe init of sets
const [sets, setSets] = useState(match.sets && match.sets.length ? match.sets : [{ p1: '', p2: '' }]);
const initialSets = match.sets?.length ? match.sets : [{ p1: '', p2: '' }];
const [sets, setSets] = useState(initialSets);
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [error, setError] = useState(null); const [error, setError] = useState(null);
const handleSubmit = async () => { const handleSubmit = async () => {
try { try { await onSubmit(match.id, sets, code); }
await api.post(`/tournaments/${tournamentId}/matches/${match.id}/score`, { catch (err) { setError(typeof err.detail === 'string' ? err.detail : "Check code or scores"); }
code: code || undefined,
sets: sets.map(s => ({ p1: Number(s.p1), p2: Number(s.p2) }))
});
onClose();
} catch (err) {
setError("Check code or scores");
}
};
const handleClear = async () => {
if (!confirm("Clear match?")) return;
try {
await api.delete(`/tournaments/${tournamentId}/matches/${match.id}/score`, { params: { code: code || undefined } });
onClose();
} catch (err) { setError("Error clearing"); }
}; };
const removeSet = (idx) => { const removeSet = (idx) => {
@@ -44,68 +26,132 @@ export default function ScoreModal({ isOpen, onClose, node, tournamentId, isAdmi
setSets(n); setSets(n);
}; };
const timeDisplay = match.start_time || node.planned_start_time;
const courtName = match.court?.name || 'TBD';
const p1Name = node.p1_team?.name || 'TBD';
const p2Name = node.p2_team?.name || 'TBD';
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title={`Match Protocol #${node.display_number}`}>
<div className="space-y-6"> <div className="space-y-6">
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm font-bold border border-red-100">{error}</div>} {/* Top Info Bar */}
<div className="flex justify-center items-center gap-4 bg-zinc-50 dark:bg-zinc-950 p-3 rounded-lg border border-gray-200 dark:border-zinc-800 shadow-sm transition-colors">
<div className="flex justify-around items-center bg-zinc-50 dark:bg-zinc-950 p-5 rounded-2xl border border-zinc-200 dark:border-zinc-800 shadow-inner"> <div className="flex items-center gap-2 text-sm font-mono text-zinc-600 dark:text-zinc-300">
<div className="text-center"> <Clock className="text-orange-500" size={18} />
<div className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 mb-1">Time</div> <span>{match.time || "10:00"}</span>
<div className="text-xl font-black font-mono text-zinc-900 dark:text-white">{new Date(timeDisplay).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>
</div>
<div className="w-px h-10 bg-zinc-200 dark:bg-zinc-800" />
<div className="text-center">
<div className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 mb-1">Court</div>
<div className="text-xl font-black uppercase text-zinc-900 dark:text-white tracking-tighter flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full" style={{ background: stringToColor(courtName) }} />
{courtName}
</div> </div>
<div className="h-4 w-px bg-zinc-300 dark:bg-zinc-800" />
<div className="flex items-center gap-2 text-sm font-mono text-zinc-900 dark:text-white">
<MapPin className="text-orange-500" size={18} />
<span>{match.court || "TBD"}</span>
</div> </div>
</div> </div>
{!isAdmin && ( {error && (
<div className="space-y-2"> <div className="text-xs text-red-500 dark:text-red-400 text-center bg-red-50 dark:bg-red-900/10 p-2 rounded border border-red-200 dark:border-red-900/30">
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400">Authorization</label> {error}
<input type="password" value={code} onChange={e => setCode(e.target.value)} placeholder="•••••" className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-3 rounded-xl text-center tracking-[0.5em] dark:text-white font-bold outline-none focus:border-orange-500" />
</div> </div>
)} )}
<div className="grid grid-cols-3 gap-2 text-center font-black text-zinc-800 dark:text-zinc-200 items-center"> {!isAdmin && (
<div className="text-sm truncate uppercase tracking-tight">{p1Name}</div> <div className="bg-zinc-50 dark:bg-zinc-950 p-4 rounded-lg border border-gray-200 dark:border-zinc-800">
<div className="text-[10px] bg-orange-600 text-white px-3 py-1.5 rounded-full w-fit mx-auto shadow-lg shadow-orange-600/20">VS</div> <label className="block text-xs font-bold text-orange-500 uppercase mb-2">Tournament Code</label>
<div className="text-sm truncate uppercase tracking-tight">{p2Name}</div> <input
type="password"
value={code}
onChange={e => setCode(e.target.value)}
placeholder="•••••"
autoComplete="off"
className="w-full bg-white dark:bg-zinc-900 border border-gray-300 dark:border-zinc-700 rounded p-3 text-center text-lg tracking-[0.5em] focus:ring-1 focus:ring-orange-500 outline-none transition text-zinc-900 dark:text-white shadow-sm"
/>
</div>
)}
<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="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> </div>
<div className="space-y-4"> <div className="space-y-2 max-h-48 overflow-y-auto pr-1">
{sets.map((s, i) => ( {sets.map((s, i) => (
<div key={i} className="animate-in slide-in-from-top-1 px-1"> <div key={i} className="animate-in slide-in-from-top-1 px-1">
<div className="flex items-center gap-4"> <div className="flex items-center justify-center gap-2">
<div className="flex-1 flex items-center gap-3"> <div className="grid grid-cols-3 gap-2 items-center justify-items-center w-full">
<input type="number" value={s.p1} onChange={e => updateSet(i, 'p1', e.target.value)} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-4 rounded-xl text-center dark:text-white font-black text-xl outline-none focus:border-orange-500 shadow-sm" /> <input
<div className="w-4 h-0.5 bg-zinc-300 dark:bg-zinc-700 rounded-full shrink-0" /> type="number"
<input type="number" value={s.p2} onChange={e => updateSet(i, 'p2', e.target.value)} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-4 rounded-xl text-center dark:text-white font-black text-xl outline-none focus:border-orange-500 shadow-sm" /> min="0"
</div> pattern="[0-9]*"
<button onClick={() => removeSet(i)} title="Remove Set" className="p-3 text-zinc-300 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-xl transition shrink-0 group"> value={s.p1}
<Trash2 size={20} className="group-hover:scale-110 transition-transform" /> onChange={(e) => updateSet(i, "p1", e.target.value)}
className="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-800 p-2 rounded text-center focus:border-orange-500 outline-none text-zinc-900 dark:text-white"
/>
{sets.length > 1 ? (
<button
onClick={() => removeSet(i)}
title="Remove Set"
className="mx-auto p-2 text-zinc-300 hover:text-red-500 transition shrink-0 group"
>
<Eraser
size={18}
className="group-hover:scale-110 transition-transform"
/>
</button> </button>
) : (
<span className="text-zinc-400 dark:text-zinc-600 text-center font-bold">
-
</span>
)}
<input
type="number"
min="0"
pattern="[0-9]*"
value={s.p2}
onChange={(e) => updateSet(i, "p2", e.target.value)}
className="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-800 p-2 rounded text-center focus:border-orange-500 outline-none text-zinc-900 dark:text-white"
/>
</div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
<button onClick={() => setSets([...sets, { p1: '', p2: '' }])} className="w-full py-4 border-2 border-dashed border-zinc-300 dark:border-zinc-800 text-zinc-500 rounded-xl text-[10px] font-black uppercase tracking-[0.2em] hover:border-orange-500 hover:text-orange-500 transition active:bg-orange-50 dark:active:bg-orange-900/10">+ Add Set</button> <button onClick={() => setSets([...sets, { p1: '', p2: '' }])} className="w-full py-2 border border-dashed border-zinc-300 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 text-sm hover:border-orange-500 hover:text-orange-500 transition rounded">+ Add Set</button>
<div className="flex gap-3 pt-4 border-t border-zinc-100 dark:border-zinc-800"> <div className="flex gap-2">
{match.winner_team_id && <button onClick={handleClear} className="w-1/3 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded-xl font-black uppercase tracking-widest text-[10px] transition active:scale-95 border border-red-200 dark:border-red-900/50">Clear Match</button>} {match.isFinished && (
<button onClick={handleSubmit} className="flex-1 bg-orange-600 hover:bg-orange-500 text-white py-4 rounded-xl font-black uppercase tracking-widest text-sm shadow-xl shadow-orange-600/20 transition active:scale-95">Submit Result</button> <button
onClick={() => onClear(match.id, code)}
className="w-1/3 bg-red-100 dark:bg-red-900/50 hover:bg-red-200 dark:hover:bg-red-900 text-red-600 dark:text-red-300 py-3 rounded-lg font-bold transition text-sm"
>
Clear
</button>
)}
<button
onClick={handleSubmit}
className={`${match.isFinished ? 'w-2/3' : 'w-full'} bg-orange-600 hover:bg-orange-500 py-3 rounded-lg font-bold shadow-lg shadow-orange-900/20 transition text-white active:scale-95`}
>
Submit Result
</button>
</div> </div>
</div> </div>
);
};
export default function ScoreModal({ isOpen, onClose, match, isAdmin, onSubmit, onClear }) {
if (!isOpen || !match) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} title={`Match #${match.number}`}>
<ScoreForm
match={match}
isAdmin={isAdmin}
onSubmit={async (id, sets, code) => {
await onSubmit(id, sets, code);
onClose();
}}
onClear={async (id, code) => {
await onClear(id, code);
onClose();
}}
/>
</Modal> </Modal>
); );
} }
+3 -4
View File
@@ -1,17 +1,16 @@
// frontend/src/components/UI/Modal.jsx // frontend/src/components/UI/Modal.jsx
import React from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
export default function Modal({ isOpen, onClose, title, children }) { export default function Modal({ isOpen, onClose, title, children }) {
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm animate-in fade-in duration-200"> <div className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-md border border-zinc-200 dark:border-zinc-800 max-h-[90vh] overflow-y-auto"> <div className="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-lg font-black text-zinc-900 dark:text-white uppercase tracking-tight">{title}</h2> <h2 className="text-xl font-black text-zinc-900 dark:text-white flex items-center gap-2 uppercase tracking-tight">{title}</h2>
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition"> <button onClick={onClose} className="text-zinc-500 hover:text-zinc-900 dark:hover:text-white transition p-1 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={24} /> <X size={24} />
</button> </button>
</div> </div>
+104 -51
View File
@@ -1,72 +1,112 @@
// frontend/src/pages/Dashboard.jsx // frontend/src/pages/Dashboard.jsx
import { Calendar, ChevronDown, ChevronUp, History, Loader2, Plus, SlidersHorizontal, Users } from 'lucide-react'; import { Calendar, ChevronDown, ChevronUp, History, Plus, SlidersHorizontal, Users, MapPin, Clock } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom'; import { useNavigate, useOutletContext } from 'react-router-dom';
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 from '../services/api'; import api, { WS_URL } from '../services/api';
// --- EXACT COPY OF YOUR DASHCARD --- // --- RESTORED DASHCARD DESIGN ---
const DashCard = ({ t, isAdmin, onClick, onEdit }) => ( const DashCard = ({ t, isAdmin, onSelect, onEdit }) => (
<div onClick={onClick} className="bg-white dark:bg-zinc-900 rounded-3xl p-5 shadow-sm border border-zinc-200 dark:border-zinc-800 cursor-pointer hover:shadow-2xl hover:-translate-y-1.5 transition-all relative overflow-hidden group"> <div
<div className="absolute top-0 left-0 w-2 h-full bg-orange-600 group-hover:w-3 transition-all"></div> onClick={() => onSelect(t.id)}
<div className="flex justify-between items-start mb-4"> className="block bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-sm border border-zinc-200 dark:border-zinc-800 relative group hover:shadow-md hover:scale-[1.02] transition-all duration-200 will-change-transform transform-gpu cursor-pointer"
<h3 className="font-black text-xl text-zinc-900 dark:text-white truncate pr-4 leading-tight">{t.name}</h3>
{isAdmin && (
<button
onClick={(e) => { e.stopPropagation(); onEdit(t); }}
className="text-zinc-300 hover:text-orange-500 transition p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-2xl shrink-0"
> >
<SlidersHorizontal size={18} strokeWidth={2.5} /> <div className="flex justify-between items-start mb-4">
</button> <div className="min-w-0 pr-4">
)} <h2 className="text-xl font-bold truncate text-zinc-900 dark:text-white group-hover:text-orange-500 dark:group-hover:text-orange-400 transition">
{t.name}
</h2>
<div className="text-xs text-zinc-400 dark:text-zinc-500 mt-1 font-mono flex items-center gap-2">
{/* Parse date safely */}
<span>{t.timestamp ? new Date(t.timestamp).toLocaleDateString() : 'TBD'}</span>
<span>{t.timestamp ? new Date(t.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''}</span>
</div> </div>
<div className="space-y-2.5">
<div className="flex items-center gap-2.5 text-zinc-700 dark:text-zinc-300 font-bold text-sm tracking-tight">
<Calendar size={16} className="text-orange-600 shrink-0" />
{/* Use safe date parsing */}
<span>{t.timestamp ? new Date(t.timestamp).toLocaleDateString() : 'TBD'} <span className="text-zinc-300 dark:text-zinc-700 mx-1">/</span> {t.timestamp ? new Date(t.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''}</span>
</div> </div>
<div className="flex items-center justify-between"> <span className="bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300 text-[10px] px-2 py-1 rounded font-mono border border-orange-200 dark:border-orange-800 uppercase tracking-tight shrink-0">
<div className="flex items-center gap-2.5 text-zinc-700 dark:text-zinc-300 font-bold text-sm tracking-tight">
<Users size={16} className="text-orange-600 shrink-0" />
<span>{t.team_count} Teams</span>
</div>
<span className="bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 rounded-lg text-[10px] font-black uppercase tracking-widest border border-zinc-200 dark:border-zinc-700 text-zinc-500">
{t.type} {t.type}
</span> </span>
</div> </div>
<div className="flex gap-4 text-sm text-zinc-500 dark:text-zinc-400 items-center">
<div className="flex items-center gap-1.5 font-medium">
<Users size={16} className="text-orange-500" />
{t.team_count} Teams
</div>
<div className="flex items-center gap-1.5 font-medium">
<MapPin size={16} className="text-orange-500" />
{t.court_count} Courts
</div>
{isAdmin && (
<button
onClick={(e) => { e.stopPropagation(); onEdit(t); }}
className="ml-auto hover:text-orange-500 transition z-10 h-8 w-8 flex items-center justify-center rounded-full text-zinc-500 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
title="Tournament Settings"
>
<SlidersHorizontal size={18} />
</button>
)}
</div> </div>
</div> </div>
); );
export default function Dashboard() { export default function Dashboard() {
const { setNavTitle, isAdmin } = useOutletContext(); const { setNavTitle, setNavSubtitle, isAdmin, showSettings, setShowSettings } = useOutletContext();
const [tournaments, setTournaments] = useState([]); const [tournaments, setTournaments] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [editTarget, setEditTarget] = useState(null); const [editTarget, setEditTarget] = useState(null);
const [showPast, setShowPast] = useState(false); const [showPast, setShowPast] = useState(false);
const [showAllFuture, setShowAllFuture] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const loadDashboard = async () => {
try {
const res = await api.get('/tournaments');
const list = Array.isArray(res) ? res : (res.items || []);
setTournaments(list);
} catch (e) { console.error(e); }
};
useEffect(() => { useEffect(() => {
setNavTitle('Dashboard'); setNavTitle('Dashboard');
loadTournaments(); setNavSubtitle('');
loadDashboard();
let ws;
const connect = () => {
try {
ws = new WebSocket(WS_URL);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'dashboard_update') loadDashboard();
};
} catch (err) { }
};
connect();
return () => { if (ws) ws.close(); };
}, []); }, []);
const loadTournaments = async () => { const handleEdit = (t) => {
try { setEditTarget(t);
const data = await api.get('/tournaments'); setShowSettings(true);
setTournaments(data); };
} catch (err) {
console.error(err); const handleSuccess = () => {
} finally { setShowSettings(false);
setLoading(false); setEditTarget(null);
loadDashboard();
};
const handleDelete = async (id) => {
if (window.confirm("Purge this tournament and all its history?")) {
await api.delete(`/tournaments/${id}`);
handleSuccess();
} }
}; };
// --- REPLICATED GROUPING LOGIC --- // Grouping Logic
const now = new Date(); const now = new Date();
const groups = { live: [], future: [], past: [] }; const groups = { live: [], future: [], past: [] };
@@ -78,20 +118,20 @@ export default function Dashboard() {
else groups.past.push(t); else groups.past.push(t);
}); });
// Sort
groups.future.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); groups.future.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
groups.live.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); groups.live.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
groups.past.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); groups.past.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
if (loading) return <div className="flex h-full items-center justify-center"><Loader2 className="animate-spin text-orange-600" size={48} /></div>; const futureShow = showAllFuture ? groups.future : groups.future.slice(0, 4);
return ( return (
<div className="container mx-auto max-w-5xl p-6 pb-24 space-y-16 animate-in slide-in-from-bottom-4 duration-500"> <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">
{/* Create Button only if Admin */} {/* Create Button */}
<div className="flex justify-end"> <div className="flex justify-end">
{isAdmin && ( {isAdmin && (
<button onClick={() => { setEditTarget(null); setShowCreate(true); }} className="bg-orange-600 hover:bg-orange-500 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 text-[10px] font-black uppercase tracking-wider shadow-xl shadow-orange-600/20 active:scale-95 transition"> <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">
<Plus size={16} strokeWidth={4} /> Create <Plus size={16} strokeWidth={4} /> Create
</button> </button>
)} )}
@@ -103,7 +143,7 @@ export default function Dashboard() {
<div className="w-2.5 h-2.5 bg-green-500 rounded-full animate-ping shadow-lg shadow-green-500/50" /> Live Events <div className="w-2.5 h-2.5 bg-green-500 rounded-full animate-ping shadow-lg shadow-green-500/50" /> 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} onClick={() => navigate(`/tournaments/${t.id}`)} onEdit={(item) => { setEditTarget(item); setShowCreate(true); }} />)} {groups.live.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onSelect={(id) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)}
</div> </div>
</section> </section>
)} )}
@@ -113,12 +153,20 @@ export default function Dashboard() {
<Calendar size={18} /> Upcoming <Calendar size={18} /> Upcoming
</h2> </h2>
{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">
{groups.future.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onClick={() => navigate(`/tournaments/${t.id}`)} onEdit={(item) => { setEditTarget(item); setShowCreate(true); }} />)} {futureShow.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onSelect={(id) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)}
</div>
{groups.future.length > 4 && (
<div className="mt-8 text-center">
<button onClick={() => setShowAllFuture(!showAllFuture)} className="text-[10px] font-black uppercase tracking-[0.2em] 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 ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
</div> </div>
) : (
<div className="p-16 text-center rounded-3xl border-2 border-dashed border-zinc-300 dark:border-zinc-800 text-zinc-400 text-xs font-black uppercase tracking-[0.3em]">No Upcoming Events</div>
)} )}
</>
) : <div className="p-16 text-center rounded-3xl border-2 border-dashed border-zinc-300 dark:border-zinc-800 text-zinc-400 text-xs font-black uppercase tracking-[0.3em]">No Upcoming Events</div>}
</section> </section>
{groups.past.length > 0 && ( {groups.past.length > 0 && (
@@ -129,14 +177,19 @@ export default function Dashboard() {
</button> </button>
{showPast && ( {showPast && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4 opacity-75 hover:opacity-100 transition-opacity"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4 opacity-75 hover:opacity-100 transition-opacity">
{groups.past.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onClick={() => navigate(`/tournaments/${t.id}`)} onEdit={(item) => { setEditTarget(item); setShowCreate(true); }} />)} {groups.past.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onSelect={(id) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)}
</div> </div>
)} )}
</section> </section>
)} )}
</div>
<Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title={editTarget ? "Modify Event" : "New Tournament"}> <Modal isOpen={showSettings} onClose={() => { setShowSettings(false); setEditTarget(null); }} title={editTarget ? 'Modify Event' : 'Initialize Event'}>
<TournamentForm initialData={editTarget} isEdit={!!editTarget} onSuccess={(newT) => { setShowCreate(false); loadTournaments(); }} /> <TournamentForm
tournament={editTarget}
onSuccess={handleSuccess}
onDelete={handleDelete}
/>
</Modal> </Modal>
</div> </div>
); );
+15 -28
View File
@@ -1,8 +1,8 @@
// frontend/src/pages/Login.jsx // frontend/src/pages/Login.jsx
import React, { useState } from 'react'; import { Loader2, Volleyball } from 'lucide-react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Volleyball, ArrowRight, Loader2 } from 'lucide-react';
import api from '../services/api'; import api from '../services/api';
export default function Login() { export default function Login() {
@@ -14,23 +14,21 @@ export default function Login() {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setError(null); setError(null);
const formData = new FormData(e.target); const formData = new FormData(e.target);
try { try {
// The backend expects x-www-form-urlencoded for OAuth2
const res = await api.postForm('/auth/token', formData); const res = await api.postForm('/auth/token', formData);
localStorage.setItem('volleyToken', res.access_token); localStorage.setItem('volleyToken', res.access_token);
navigate('/'); navigate('/');
} catch (err) { } catch (err) {
setError('Invalid credentials. Please try again.'); setError('Invalid credentials.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 p-4"> <div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 p-4 transition-colors">
<div className="w-full max-w-md bg-white dark:bg-zinc-900 rounded-3xl shadow-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden"> <div className="w-full max-w-md bg-white dark:bg-zinc-900 rounded-3xl shadow-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
<div className="p-8"> <div className="p-8">
<div className="flex justify-center mb-8"> <div className="flex justify-center mb-8">
@@ -39,8 +37,8 @@ export default function Login() {
</div> </div>
</div> </div>
<h1 className="text-2xl font-black text-center text-zinc-900 dark:text-white tracking-tight mb-2">Admin Access</h1> <h1 className="text-2xl font-black text-center text-zinc-900 dark:text-white tracking-tight mb-2">System Access</h1>
<p className="text-center text-zinc-500 text-sm font-medium mb-8">Enter your credentials to manage events</p> <p className="text-center text-zinc-500 text-sm font-medium mb-8">Enter administrative credentials</p>
{error && ( {error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-bold uppercase tracking-wide rounded-xl text-center border border-red-100 dark:border-red-900/50"> <div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-bold uppercase tracking-wide rounded-xl text-center border border-red-100 dark:border-red-900/50">
@@ -48,34 +46,23 @@ export default function Login() {
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-1"> <div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400 ml-1">Username</label> <label className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500">Identity</label>
<input <input name="username" placeholder="Admin UID" required className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-4 rounded-2xl dark:text-white outline-none focus:border-orange-500 transition font-bold" />
name="username"
placeholder="admin"
required
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 p-4 rounded-xl font-bold dark:text-white outline-none focus:border-orange-500 focus:ring-4 focus:ring-orange-500/10 transition"
/>
</div> </div>
<div className="space-y-1"> <div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400 ml-1">Password</label> <label className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500">Secret Key</label>
<input <input name="password" type="password" placeholder="••••••••" required className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 p-4 rounded-2xl dark:text-white outline-none focus:border-orange-500 transition font-bold" />
name="password"
type="password"
placeholder="••••••••"
required
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 p-4 rounded-xl font-bold dark:text-white outline-none focus:border-orange-500 focus:ring-4 focus:ring-orange-500/10 transition"
/>
</div> </div>
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full bg-orange-600 hover:bg-orange-500 text-white p-4 rounded-xl font-black uppercase tracking-widest text-xs shadow-xl shadow-orange-600/20 transition active:scale-95 flex items-center justify-center gap-2 mt-4" className="w-full bg-orange-600 hover:bg-orange-500 text-white py-5 rounded-2xl font-black uppercase tracking-[0.2em] text-xs shadow-2xl shadow-orange-600/30 transition active:scale-95 mt-4 flex justify-center items-center gap-2"
> >
{loading ? <Loader2 className="animate-spin" size={18} /> : <>Sign In <ArrowRight size={18} /></>} {loading ? <Loader2 className="animate-spin" size={18} /> : 'Authenticate'}
</button> </button>
</form> </form>
</div> </div>
+146
View File
@@ -0,0 +1,146 @@
// frontend/src/pages/Tournament.jsx
import { CalendarDays, Loader2, Network, Settings } from 'lucide-react';
import { useEffect, useState, useRef } from 'react';
import { useOutletContext, useParams } from 'react-router-dom';
import BracketView from '../components/Bracket/BracketView';
import TournamentForm from '../components/Forms/TournamentForm';
import ScheduleView from '../components/Schedule/ScheduleView';
import ScoreModal from '../components/Tournament/ScoreModal';
import Modal from '../components/UI/Modal';
import api, { WS_URL } from '../services/api';
export default function Tournament() {
const { id } = useParams();
const { setNavTitle, setNavSubtitle, isAdmin } = useOutletContext();
const [details, setDetails] = useState(null);
const [matches, setMatches] = useState([]);
const [view, setView] = useState('bracket');
const [loading, setLoading] = useState(true);
const [showSettings, setShowSettings] = useState(false);
const [scoreMatch, setScoreMatch] = useState(null);
const wsRef = useRef(null);
// --- DATA PROCESSOR ---
const processMatches = (rawMatches, courts, teams) => {
if (!rawMatches) return [];
const courtMap = Object.fromEntries(courts.map(c => [c.id, c.name]));
const teamMap = Object.fromEntries(teams.map(t => [t.id, t.name]));
const incoming = {};
rawMatches.forEach(m => {
const num = m.match_number;
if (m.winner_next_match_id) {
(incoming[m.winner_next_match_id] = incoming[m.winner_next_match_id] || []).push({ label: `Winner of #${num}`, id: m.id });
}
if (m.loser_next_match_id) {
(incoming[m.loser_next_match_id] = incoming[m.loser_next_match_id] || []).push({ label: `Loser of #${num}`, id: m.id });
}
});
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;
const hasTeams = !!(m.p1_team_id && m.p2_team_id);
const isFinished = m.status === "Finished";
const isReady = hasTeams && !isFinished;
return {
...m,
bracket: m.bracket_type,
round: m.round_number,
number: m.match_number,
p1,
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' }) : '',
p1_sets: m.sets?.filter(s => s.p1 > s.p2).length || 0,
p2_sets: m.sets?.filter(s => s.p2 > s.p1).length || 0,
hasTeams,
isReady,
isFinished
};
});
};
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));
} catch (err) { console.error(err); } finally { setLoading(false); }
};
useEffect(() => {
fetchData();
if (wsRef.current) return;
const connect = () => {
const ws = new WebSocket(WS_URL);
wsRef.current = ws;
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'tournament_update' && msg.id === id) fetchData();
};
ws.onclose = () => { wsRef.current = null; };
};
connect();
return () => { if (wsRef.current?.readyState === 1) wsRef.current.close(); wsRef.current = null; };
}, [id]);
const handleDeleteTournament = async (tId) => {
if (window.confirm("Purge this tournament?")) {
await api.delete(`/tournaments/${tId}`);
window.location.href = '/';
}
};
if (loading) return <div className="flex h-full items-center justify-center"><Loader2 className="animate-spin text-orange-600" size={48} /></div>;
return (
<div className="h-full flex flex-col">
<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'}`}>
<Network size={16} /> Bracket
</button>
<button onClick={() => setView('schedule')} className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider transition ${view === 'schedule' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'}`}>
<CalendarDays size={16} /> Schedule
</button>
</div>
{isAdmin && <button onClick={() => setShowSettings(true)} className="p-2 text-zinc-400 hover:text-orange-600 transition rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800"><Settings size={20} /></button>}
</div>
<div className="flex-1 overflow-hidden relative">
{view === 'bracket'
? <BracketView matches={matches} onMatchClick={setScoreMatch} />
: <ScheduleView schedule={matches} onMatchClick={setScoreMatch} />
}
</div>
{scoreMatch && (
<ScoreModal
isOpen={!!scoreMatch} onClose={() => setScoreMatch(null)} match={scoreMatch} isAdmin={isAdmin}
onClear={async (mid, c) => { await api.delete(`/tournaments/${id}/matches/${mid}/score?code=${encodeURIComponent(c || '')}`); setScoreMatch(null); }}
onSubmit={async (mid, s, c) => { await api.post(`/tournaments/${id}/matches/${mid}/score`, { sets: s, code: c }); setScoreMatch(null); }}
/>
)}
<Modal isOpen={showSettings} onClose={() => setShowSettings(false)} title="Edit Tournament">
<TournamentForm tournament={details} onSuccess={() => { setShowSettings(false); fetchData(); }} onDelete={handleDeleteTournament} />
</Modal>
</div>
);
}
-107
View File
@@ -1,107 +0,0 @@
// frontend/src/pages/TournamentPage.jsx
import { CalendarDays, Loader2, Network, Settings } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useOutletContext, useParams } from 'react-router-dom';
import BracketView from '../components/Bracket/BracketView';
import TournamentForm from '../components/Forms/TournamentForm';
import ScheduleView from '../components/Schedule/ScheduleView';
import ScoreModal from '../components/Tournament/ScoreModal';
import Modal from '../components/UI/Modal';
import api, { WS_URL } from '../services/api';
export default function TournamentPage() {
const { id } = useParams();
const { setNavTitle, setNavSubtitle, isAdmin } = useOutletContext();
const [details, setDetails] = useState(null);
const [nodes, setNodes] = useState([]);
const [view, setView] = useState('bracket'); // 'bracket' | 'schedule'
const [loading, setLoading] = useState(true);
const [showSettings, setShowSettings] = useState(false);
const [scoreMatchNode, setScoreMatchNode] = useState(null);
const fetchData = async () => {
try {
// 1. Fetch Metadata
const meta = await api.get(`/tournaments/${id}`);
setDetails(meta);
setNavTitle(meta.name);
setNavSubtitle(new Date(meta.timestamp).toLocaleDateString());
// 2. Fetch Structure (Nodes)
const bracketData = await api.get(`/tournaments/${id}/bracket`);
setNodes(bracketData);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
// WebSocket for Live Updates
const ws = new WebSocket(WS_URL);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'tournament_update' && msg.id === id) {
fetchData();
}
};
return () => ws.close();
}, [id]);
if (loading) return <div className="flex h-full items-center justify-center"><Loader2 className="animate-spin text-orange-600" size={48} /></div>;
return (
<div className="h-full flex flex-col">
{/* Toolbar */}
<div className="border-b border-zinc-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-900/50 backdrop-blur px-6 py-3 flex justify-between items-center shrink-0">
<div className="flex gap-2">
<button
onClick={() => setView('bracket')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider transition ${view === 'bracket' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'}`}
>
<Network size={16} /> Bracket
</button>
<button
onClick={() => setView('schedule')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider transition ${view === 'schedule' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'}`}
>
<CalendarDays size={16} /> Schedule
</button>
</div>
{isAdmin && (
<button onClick={() => setShowSettings(true)} className="p-2 text-zinc-400 hover:text-orange-600 transition rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800">
<Settings size={20} />
</button>
)}
</div>
{/* Main Content Area */}
<div className="flex-1 overflow-hidden relative">
{view === 'bracket'
? <BracketView nodes={nodes} onMatchClick={setScoreMatchNode} />
: <ScheduleView nodes={nodes} onMatchClick={setScoreMatchNode} />
}
</div>
{scoreMatchNode && (
<ScoreModal
isOpen={!!scoreMatchNode}
onClose={() => setScoreMatchNode(null)}
node={scoreMatchNode}
tournamentId={id}
isAdmin={isAdmin}
/>
)}
<Modal isOpen={showSettings} onClose={() => setShowSettings(false)} title="Edit Tournament">
<TournamentForm initialData={details} onSuccess={() => { setShowSettings(false); fetchData(); }} isEdit />
</Modal>
</div>
);
}
+32 -26
View File
@@ -1,42 +1,48 @@
// frontend/src/services/api.js // frontend/src/services/api.js
import axios from 'axios';
const getBackendHost = () => { const getBackendHost = () => {
const host = window.location.hostname || 'localhost'; const host = window.location.hostname || 'localhost';
return host; return host;
}; };
// Updated to include /api prefix const port = '8000';
export const API_BASE = `${window.location.protocol}//${getBackendHost()}:8000/api`;
export const WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${getBackendHost()}:8000/ws`;
const api = axios.create({ export const API_BASE = `${window.location.protocol}//${getBackendHost()}:${port}/api`;
baseURL: API_BASE, export const WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${getBackendHost()}:${port}/api/ws`;
headers: {
'Content-Type': 'application/json',
},
});
// Request Interceptor for Auth
api.interceptors.request.use((config) => {
const token = localStorage.getItem('volleyToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response Interceptor for Errors export const getToken = () => localStorage.getItem('volleyToken');
api.interceptors.response.use(
(response) => response.data, const api = {
(error) => { request: async (method, url, data = null, isFormData = false) => {
if (error.response?.status === 401) { 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.endsWith('/') ? API_BASE.slice(0, -1) : API_BASE;
const endpoint = url.startsWith('/') ? url : `/${url}`;
const res = await fetch(`${baseUrl}${endpoint}`, opts);
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem('volleyToken'); localStorage.removeItem('volleyToken');
window.location.href = '/login'; window.location.href = '/login';
} }
return Promise.reject(error.response?.data || error.message); throw await res.json();
} }
); 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; export default api;
+1 -2
View File
@@ -14,8 +14,7 @@ export default defineConfig({
}, },
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'),
"/": path.resolve(__dirname, "./public"),
}, },
}, },
}) })