Better bracket logic
This commit is contained in:
@@ -7,23 +7,18 @@ class TournamentTypes(str, Enum):
|
||||
DOUBLE = "Double"
|
||||
|
||||
|
||||
class BracketType(str, Enum):
|
||||
WINNERS = "Winners"
|
||||
LOSERS = "Losers"
|
||||
FINALS = "Finals"
|
||||
|
||||
|
||||
class MatchSourceType(str, Enum):
|
||||
WINNER = "Winner"
|
||||
LOSER = "Loser"
|
||||
|
||||
|
||||
class MatchStatus(str, Enum):
|
||||
PENDING = "Pending"
|
||||
SCHEDULED = "Scheduled"
|
||||
PENDING = "Pending"
|
||||
FINISHED = "Finished"
|
||||
|
||||
|
||||
class BracketTypes(str, Enum):
|
||||
WB = "Winner"
|
||||
LB = "Loser"
|
||||
FINALS = "Finals"
|
||||
|
||||
|
||||
class WinnerSide(str, Enum):
|
||||
P1 = "p1"
|
||||
P2 = "p2"
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
# backend/app/core/brackets.py
|
||||
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
|
||||
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
|
||||
from ..constants import BracketTypes
|
||||
from .structures import Match
|
||||
|
||||
|
||||
class BracketGenerator:
|
||||
|
||||
def __init__(self):
|
||||
self.matches_dict = {}
|
||||
self.match_counter = 1
|
||||
@@ -30,23 +19,24 @@ class BracketGenerator:
|
||||
size = 2 ** math.ceil(math.log2(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]):
|
||||
match.players = [seeds[i * 2], seeds[i * 2 + 1]]
|
||||
match.teams = [seeds[i * 2], seeds[i * 2 + 1]]
|
||||
|
||||
lb_rounds = []
|
||||
if double_elimination:
|
||||
lb_rounds = self._create_strict_lb(size, bracket)
|
||||
|
||||
if double_elimination:
|
||||
self._create_de_finals(bracket, lb_rounds)
|
||||
else:
|
||||
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)
|
||||
# Filter out byes so they don't become DB rows
|
||||
return [m for m in all_matches if not m.is_bye]
|
||||
|
||||
def _generate_seeding_indices(self, num_players: int) -> list[int]:
|
||||
@@ -58,7 +48,7 @@ class BracketGenerator:
|
||||
curr *= 2
|
||||
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]] = []
|
||||
current_size = size // 2
|
||||
r_num = 1
|
||||
@@ -66,8 +56,7 @@ class BracketGenerator:
|
||||
while current_size >= 1:
|
||||
round_matches: list[Match] = []
|
||||
for _ in range(current_size):
|
||||
name = f"{prefix} Round {r_num}"
|
||||
m = Match(self.match_counter, name)
|
||||
m = Match(self.match_counter, BracketTypes.WB, r_num)
|
||||
self.matches_dict[m.id] = m
|
||||
round_matches.append(m)
|
||||
self.match_counter += 1
|
||||
@@ -75,15 +64,10 @@ class BracketGenerator:
|
||||
current_size //= 2
|
||||
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 i, match in enumerate(rounds[r]):
|
||||
match.next_win = rounds[r + 1][i // 2]
|
||||
match.next_win_slot = i % 2
|
||||
|
||||
return rounds
|
||||
|
||||
@@ -104,102 +88,129 @@ class BracketGenerator:
|
||||
|
||||
round_matches = []
|
||||
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
|
||||
round_matches.append(m)
|
||||
self.match_counter += 1
|
||||
lb_rounds.append(round_matches)
|
||||
|
||||
if lb_rounds:
|
||||
lb_rounds[-1][0].name = "Losers Final (3rd Place Match)"
|
||||
|
||||
# Link LB rounds
|
||||
for r in range(len(lb_rounds) - 1):
|
||||
curr = lb_rounds[r]
|
||||
next_r = lb_rounds[r + 1]
|
||||
for i, match in enumerate(curr):
|
||||
if len(curr) == len(next_r):
|
||||
# 1-to-1 Mapping case (e.g. 4 matches -> 4 matches)
|
||||
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:
|
||||
# 2-to-1 Mapping case (e.g. 4 matches -> 2 matches)
|
||||
match.next_win = next_r[i // 2]
|
||||
match.next_win_slot = i % 2
|
||||
|
||||
# Link WB Losers -> LB
|
||||
for r in range(num_wb_rounds):
|
||||
wb_round = wb_rounds[r]
|
||||
|
||||
if r == 0:
|
||||
lb_target_idx = 0
|
||||
else:
|
||||
lb_target_idx = (r * 2) - 1
|
||||
lb_target_idx = 0 if r == 0 else (r * 2) - 1
|
||||
|
||||
if lb_target_idx >= len(lb_rounds):
|
||||
break
|
||||
|
||||
lb_round = lb_rounds[lb_target_idx]
|
||||
|
||||
for i, wb_match in enumerate(wb_round):
|
||||
if r == 0:
|
||||
target = lb_round[i // 2]
|
||||
wb_match.next_loss_slot = i % 2
|
||||
else:
|
||||
# Drop-in round
|
||||
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
|
||||
|
||||
return lb_rounds
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
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.match_counter += 1
|
||||
|
||||
for m in semis:
|
||||
m.next_loss = third_place_match
|
||||
for i, sm in enumerate(semis):
|
||||
sm.next_loss = third_place_match
|
||||
sm.next_loss_slot = i % 2
|
||||
|
||||
def _create_de_finals(
|
||||
self, wb_rounds: list[list[Match]], lb_rounds: list[list[Match]]
|
||||
) -> None:
|
||||
"""Links Double Elim Grand Final"""
|
||||
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.match_counter += 1
|
||||
|
||||
wb_final.next_win = gf
|
||||
wb_final.next_win_slot = 0
|
||||
|
||||
if lb_rounds:
|
||||
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.match_counter += 1
|
||||
gf.next_loss = reset
|
||||
gf.next_loss_slot = 0
|
||||
|
||||
def _apply_ghost_logic(
|
||||
self, round_one_matches: list[Match], num_players: int
|
||||
) -> None:
|
||||
def _resolve_byes(self, 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:
|
||||
p1, p2 = m.players
|
||||
if p1 and p1 > num_players:
|
||||
self._add_ghost(m)
|
||||
if p2 and p2 > num_players:
|
||||
self._add_ghost(m)
|
||||
all_matches: list[Match] = sorted(
|
||||
self.matches_dict.values(), key=lambda x: x.id
|
||||
)
|
||||
|
||||
def _add_ghost(self, match: Match) -> None:
|
||||
"""
|
||||
Recursively propagates a ghost through the bracket.
|
||||
"""
|
||||
match.ghost_count += 1
|
||||
match.is_bye = True
|
||||
for m in all_matches:
|
||||
if m.is_bye:
|
||||
continue
|
||||
|
||||
if match.next_loss:
|
||||
self._add_ghost(match.next_loss)
|
||||
p1, p2 = m.teams
|
||||
|
||||
if match.ghost_count == 2:
|
||||
if match.next_win:
|
||||
self._add_ghost(match.next_win)
|
||||
p1_is_ghost = p1 is not None and p1 > num_players
|
||||
p2_is_ghost = p2 is not None and p2 > num_players
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -58,6 +58,6 @@ class MermaidLive:
|
||||
@staticmethod
|
||||
def _print_node(m: Match, style: str) -> None:
|
||||
label = m.name
|
||||
if m.players[0] and m.players[1]:
|
||||
label += f" ({m.players[0]} vs {m.players[1]})"
|
||||
if m.teams[0] and m.teams[1]:
|
||||
label += f" ({m.teams[0]} vs {m.teams[1]})"
|
||||
print(f' M{m.id}["{label}"]:::{style}')
|
||||
|
||||
+18
-33
@@ -7,39 +7,16 @@ from . import logic, models, schemas
|
||||
|
||||
|
||||
def _rebuild_structure(db: Session, t: models.Tournament):
|
||||
"""
|
||||
Nukes existing nodes/matches and regenerates them based on current teams.
|
||||
Used when teams are added/removed.
|
||||
"""
|
||||
t.matches = []
|
||||
for m in t.matches:
|
||||
db.delete(m)
|
||||
db.flush()
|
||||
|
||||
t.nodes = []
|
||||
db.flush()
|
||||
|
||||
node_data = logic.generate_bracket_nodes(t)
|
||||
for nd in node_data:
|
||||
db.add(models.BracketNode(**nd, tournament_id=t.id))
|
||||
|
||||
logic.generate_bracket(db, t)
|
||||
db.flush()
|
||||
db.refresh(t)
|
||||
|
||||
logic.initialize_seeding(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):
|
||||
t_id = str(uuid4())[:8]
|
||||
new_t = models.Tournament(
|
||||
@@ -56,19 +33,27 @@ def create_tournament(db: Session, data: schemas.TournamentCreate):
|
||||
db.add(new_t)
|
||||
db.commit()
|
||||
|
||||
node_data = logic.generate_bracket_nodes(new_t)
|
||||
for nd in node_data:
|
||||
db.add(models.BracketNode(**nd, tournament_id=t_id))
|
||||
db.commit()
|
||||
db.refresh(new_t)
|
||||
|
||||
logic.initialize_seeding(db, new_t)
|
||||
# Generate Bracket
|
||||
logic.generate_bracket(db, new_t)
|
||||
logic.update_schedule_times(db, new_t)
|
||||
|
||||
db.commit()
|
||||
db.refresh(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:
|
||||
t = get_tournament(db, tournament_id)
|
||||
if not t:
|
||||
|
||||
+176
-299
@@ -1,333 +1,210 @@
|
||||
# backend/app/logic.py
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from collections import defaultdict, deque
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import models
|
||||
from .constants import (
|
||||
BracketType,
|
||||
MatchSourceType,
|
||||
MatchStatus,
|
||||
TournamentTypes,
|
||||
WinnerSide,
|
||||
)
|
||||
from .constants import MatchStatus, TournamentTypes
|
||||
from .core.brackets import BracketGenerator
|
||||
|
||||
|
||||
def get_seeded_positions(num_slots, teams):
|
||||
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.
|
||||
"""
|
||||
def generate_bracket(db: Session, t: models.Tournament):
|
||||
teams = t.teams
|
||||
count = len(teams)
|
||||
if count < 2:
|
||||
return []
|
||||
if not teams:
|
||||
return
|
||||
|
||||
power = math.ceil(math.log2(count)) if count > 0 else 1
|
||||
size = 2**power
|
||||
gen = BracketGenerator()
|
||||
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 = []
|
||||
display_counter = 1
|
||||
def resolve_target(match_node):
|
||||
curr = match_node
|
||||
while curr and curr.is_bye:
|
||||
curr = curr.next_win
|
||||
return curr
|
||||
|
||||
def make_id():
|
||||
return str(uuid4())
|
||||
db_matches = []
|
||||
friendly_counter = 1
|
||||
|
||||
class NodeRef:
|
||||
def __init__(self, bracket, round_n):
|
||||
self.id = make_id()
|
||||
self.bracket = bracket
|
||||
self.round = round_n
|
||||
self.display_num = 0
|
||||
for m in abstract_matches:
|
||||
real_win = resolve_target(m.next_win)
|
||||
real_loss = resolve_target(m.next_loss)
|
||||
|
||||
self.next_win: Optional["NodeRef"] = None
|
||||
self.next_loss: Optional["NodeRef"] = None
|
||||
self.src_p1: Optional["NodeRef"] = None
|
||||
self.src_p2: Optional["NodeRef"] = None
|
||||
initial_status = MatchStatus.SCHEDULED
|
||||
p1_id = None
|
||||
p2_id = None
|
||||
|
||||
self.src_p1_type: Optional[MatchSourceType] = None
|
||||
self.src_p2_type: Optional[MatchSourceType] = None
|
||||
p1_seed = m.teams[0]
|
||||
p2_seed = m.teams[1]
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"bracket_type": self.bracket,
|
||||
"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,
|
||||
}
|
||||
if p1_seed and p1_seed <= len(teams):
|
||||
p1_id = teams[p1_seed - 1].id
|
||||
if p2_seed and p2_seed <= len(teams):
|
||||
p2_id = teams[p2_seed - 1].id
|
||||
|
||||
wb_rounds = power
|
||||
wb_layers = {r: [] for r in range(1, wb_rounds + 1)}
|
||||
if p1_id and p2_id:
|
||||
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(
|
||||
id=m_id,
|
||||
id=id_map[m.id],
|
||||
tournament_id=t.id,
|
||||
node_id=node.id,
|
||||
p1_team_id=node.p1_team_id,
|
||||
p2_team_id=node.p2_team_id,
|
||||
status=MatchStatus.PENDING,
|
||||
court_id=node.planned_court_id,
|
||||
start_time=node.planned_start_time,
|
||||
match_number=friendly_counter,
|
||||
bracket_type=m.bracket_type,
|
||||
round_number=m.round_number,
|
||||
status=initial_status,
|
||||
p1_team_id=p1_id,
|
||||
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.commit()
|
||||
db.refresh(node)
|
||||
changes = True
|
||||
db_matches.append(new_match)
|
||||
friendly_counter += 1
|
||||
|
||||
# 3. MATCH RESULT PROPAGATION
|
||||
elif (
|
||||
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
|
||||
db.add_all(db_matches)
|
||||
db.flush()
|
||||
|
||||
|
||||
def update_schedule_times(db: Session, t: models.Tournament):
|
||||
nodes = sorted(t.nodes, key=lambda n: n.display_number)
|
||||
|
||||
current_time = t.timestamp
|
||||
courts = t.courts
|
||||
if not courts:
|
||||
if not t.courts:
|
||||
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:
|
||||
if node.planned_start_time:
|
||||
continue
|
||||
# 1. Build Dependency Graph
|
||||
adj = defaultdict(list)
|
||||
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])
|
||||
start = court_timers[best_court_id]
|
||||
for m in matches:
|
||||
if m.id not in adj:
|
||||
adj[m.id] = []
|
||||
|
||||
node.planned_court_id = best_court_id
|
||||
node.planned_start_time = start
|
||||
if m.winner_next_match_id and m.winner_next_match_id in match_map:
|
||||
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:
|
||||
node.match.court_id = best_court_id
|
||||
node.match.timestamp = start
|
||||
# 2. Initialize constraints
|
||||
# FIX: Ensure tournament_start is naive (no timezone) to match SQLite DB datetimes
|
||||
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()
|
||||
|
||||
+25
-67
@@ -7,7 +7,7 @@ from sqlalchemy import Enum as SqlEnum
|
||||
from sqlalchemy import ForeignKey, Integer, String
|
||||
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
|
||||
|
||||
|
||||
@@ -28,12 +28,6 @@ class Tournament(Base):
|
||||
"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(
|
||||
"Match", back_populates="tournament", cascade="all, delete-orphan"
|
||||
)
|
||||
@@ -65,86 +59,50 @@ class Court(Base):
|
||||
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):
|
||||
__tablename__ = "matches"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
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(
|
||||
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(
|
||||
SqlEnum(MatchStatus), default=MatchStatus.PENDING
|
||||
SqlEnum(MatchStatus), default=MatchStatus.SCHEDULED
|
||||
)
|
||||
|
||||
p1_team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"))
|
||||
p2_team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"))
|
||||
|
||||
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
|
||||
)
|
||||
sets: Mapped[list[dict]] = mapped_column(JSON, default=list)
|
||||
winner_team_id: Mapped[Optional[int]] = mapped_column(
|
||||
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")
|
||||
court: Mapped[Optional["Court"]] = relationship()
|
||||
|
||||
p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_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]
|
||||
)
|
||||
|
||||
@@ -3,4 +3,4 @@ from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/tournaments", tags=["Tournaments"])
|
||||
|
||||
from . import tournaments, teams, courts, matches, report, bracket
|
||||
from . import tournaments, teams, courts, matches, report
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,5 @@
|
||||
# backend/app/routes/tournaments/report.py
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -45,7 +45,9 @@ async def report_score(
|
||||
_apply_score(match, report.sets)
|
||||
flag_modified(match, "sets")
|
||||
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)
|
||||
return SUCCESS
|
||||
@@ -59,11 +61,6 @@ async def edit_score(
|
||||
db: Session = Depends(get_db),
|
||||
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)
|
||||
if not t:
|
||||
raise HTTPException(404, "Tournament not found")
|
||||
@@ -74,12 +71,16 @@ async def edit_score(
|
||||
if not match:
|
||||
raise HTTPException(404, "Match not found")
|
||||
|
||||
logic.undo_advancement(db, match)
|
||||
|
||||
if report.sets:
|
||||
_apply_score(match, report.sets)
|
||||
|
||||
flag_modified(match, "sets")
|
||||
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)
|
||||
return SUCCESS
|
||||
@@ -103,6 +104,8 @@ async def clear_score(
|
||||
if not match:
|
||||
raise HTTPException(404, "Match not found")
|
||||
|
||||
logic.undo_advancement(db, match)
|
||||
|
||||
match.winner_team_id = None
|
||||
match.status = MatchStatus.PENDING
|
||||
match.sets = []
|
||||
@@ -114,7 +117,7 @@ async def clear_score(
|
||||
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.
|
||||
Does NOT commit to DB.
|
||||
|
||||
@@ -6,7 +6,7 @@ from ..core.websocket_manager import manager
|
||||
router = APIRouter(prefix="/ws", tags=["Websocket"])
|
||||
|
||||
|
||||
@router.websocket("/")
|
||||
@router.websocket("")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await manager.connect(websocket)
|
||||
try:
|
||||
|
||||
+14
-26
@@ -3,7 +3,7 @@ from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from .constants import BracketType, MatchSourceType, MatchStatus, TournamentTypes
|
||||
from .constants import MatchStatus, TournamentTypes, BracketTypes
|
||||
|
||||
|
||||
class TeamSchema(BaseModel):
|
||||
@@ -38,39 +38,26 @@ class ScoreReport(BaseModel):
|
||||
|
||||
class MatchOut(BaseModel):
|
||||
id: str
|
||||
node_id: str
|
||||
match_number: int
|
||||
round_number: int
|
||||
bracket_type: BracketTypes
|
||||
|
||||
status: MatchStatus
|
||||
timestamp: datetime | None = None
|
||||
court: CourtSchema | None = None
|
||||
p1_team: TeamSchema | None = None
|
||||
p2_team: TeamSchema | None = None
|
||||
start_time: datetime | None = None
|
||||
court_id: int | None = None
|
||||
|
||||
p1_team_id: int | None = None
|
||||
p2_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] = []
|
||||
|
||||
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):
|
||||
name: str
|
||||
code: str
|
||||
@@ -106,6 +93,7 @@ class TournamentDetail(BaseModel):
|
||||
type: TournamentTypes
|
||||
teams: list[TeamSchema]
|
||||
courts: list[CourtSchema]
|
||||
matches: list[MatchOut]
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
Binary file not shown.
@@ -15,16 +15,19 @@ async def test_scoring_flow(
|
||||
t_id = res.json()["id"]
|
||||
t_code = valid_tournament_payload["code"]
|
||||
|
||||
# 2. Get Bracket Nodes (Updated Endpoint)
|
||||
bracket_res = await client.get(f"/tournaments/{t_id}/bracket")
|
||||
nodes = bracket_res.json()
|
||||
# 2. Get Bracket (Now returns list of Matches)
|
||||
# Note: You can now get this from GET /tournaments/{id} or /bracket depending on your routes
|
||||
bracket_res = await client.get(f"/tournaments/{t_id}")
|
||||
matches = bracket_res.json()["matches"]
|
||||
|
||||
# Find active node
|
||||
active_node = next(n for n in nodes if n.get("match") is not None)
|
||||
# Find the first playable match (Round 1)
|
||||
# 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 = match_data["id"]
|
||||
next_node_id = active_node["winner_next_node_id"]
|
||||
match_id = active_match["id"]
|
||||
next_match_id = active_match["winner_next_match_id"]
|
||||
|
||||
# 3. Report Score
|
||||
score_payload = {
|
||||
@@ -38,15 +41,17 @@ async def test_scoring_flow(
|
||||
)
|
||||
assert report_res.status_code == 200
|
||||
|
||||
# 4. Verify Winner Advanced (Fetch bracket again)
|
||||
updated_res = await client.get(f"/tournaments/{t_id}/bracket")
|
||||
updated_nodes = updated_res.json()
|
||||
# 4. Verify Winner Advanced
|
||||
updated_res = await client.get(f"/tournaments/{t_id}")
|
||||
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"]
|
||||
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
|
||||
winner_id = active_match["p1_team"]["id"]
|
||||
|
||||
# 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]
|
||||
|
||||
@@ -67,9 +72,9 @@ async def test_clear_score(client: AsyncClient, auth_headers, valid_tournament_p
|
||||
t_id = res.json()["id"]
|
||||
|
||||
# Get active match
|
||||
bracket = (await client.get(f"/tournaments/{t_id}/bracket")).json()
|
||||
active_node = next(n for n in bracket if n.get("match"))
|
||||
match_id = active_node["match"]["id"]
|
||||
detail = (await client.get(f"/tournaments/{t_id}")).json()
|
||||
active_match = next(m for m in detail["matches"] if m["p1_team"] and m["p2_team"])
|
||||
match_id = active_match["id"]
|
||||
|
||||
score_payload = {"id": match_id, "code": "1234", "sets": [{"p1": 25, "p2": 0}]}
|
||||
await client.post(
|
||||
|
||||
@@ -20,14 +20,13 @@ async def test_manage_teams(
|
||||
f"/tournaments/{t_id}/teams", json=new_team, headers=auth_headers
|
||||
)
|
||||
assert post_res.status_code == 200
|
||||
assert post_res.json()["name"] == "Team E"
|
||||
|
||||
# 2. Verify Bracket Regenerated (Call new endpoint)
|
||||
bracket_res = await client.get(f"/tournaments/{t_id}/bracket")
|
||||
nodes = bracket_res.json()
|
||||
# 2. Verify Bracket Regenerated
|
||||
detail_res = await client.get(f"/tournaments/{t_id}")
|
||||
matches = detail_res.json()["matches"]
|
||||
|
||||
# With 5 teams -> size 8 bracket
|
||||
assert len(nodes) > 4
|
||||
# With 5 teams, bracket size increases
|
||||
assert len(matches) > 3
|
||||
|
||||
# 3. Bulk Update
|
||||
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
|
||||
)
|
||||
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(
|
||||
@@ -48,8 +66,8 @@ async def test_manage_courts(
|
||||
t_id = res.json()["id"]
|
||||
|
||||
# Get initial courts
|
||||
courts_res = await client.get(f"/tournaments/{t_id}/courts")
|
||||
initial_courts = courts_res.json()
|
||||
courts_res = await client.get(f"/tournaments/{t_id}")
|
||||
initial_courts = courts_res.json()["courts"]
|
||||
assert len(initial_courts) == 2
|
||||
|
||||
# Delete
|
||||
|
||||
@@ -33,7 +33,7 @@ async def test_list_tournaments(
|
||||
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
|
||||
):
|
||||
create_res = await client.post(
|
||||
@@ -41,25 +41,41 @@ async def test_get_tournament_detail_and_bracket(
|
||||
)
|
||||
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}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Should HAVE metadata
|
||||
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
|
||||
|
||||
# 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(
|
||||
client: AsyncClient, auth_headers, valid_tournament_payload
|
||||
@@ -77,19 +93,6 @@ async def test_update_settings(
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Updated Name"
|
||||
assert data["code"] == "9999"
|
||||
|
||||
|
||||
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
|
||||
# The response model is TournamentUpdateResponse which inherits TournamentDetail
|
||||
# So it should also have matches
|
||||
assert "matches" in data
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,23 +1,28 @@
|
||||
// frontend/src/App.jsx
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import Layout from './components/Layout/Layout';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import TournamentPage from './pages/TournamentPage';
|
||||
import Login from './pages/Login';
|
||||
import Tournament from './pages/Tournament';
|
||||
|
||||
export default function App() {
|
||||
const [darkMode, setDarkMode] = useState(() => localStorage.theme === 'dark');
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
const body = window.document.body;
|
||||
if (darkMode) {
|
||||
root.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
root.style.backgroundColor = '#09090b'; // zinc-950
|
||||
body.style.backgroundColor = '#09090b';
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
root.style.backgroundColor = '#fafafa'; // zinc-50
|
||||
body.style.backgroundColor = '#fafafa';
|
||||
}
|
||||
}, [darkMode]);
|
||||
|
||||
@@ -29,7 +34,7 @@ export default function App() {
|
||||
{/* Main App Layout */}
|
||||
<Route element={<Layout darkMode={darkMode} setDarkMode={setDarkMode} />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/tournaments/:id" element={<TournamentPage />} />
|
||||
<Route path="/tournaments/:id" element={<Tournament />} />
|
||||
</Route>
|
||||
|
||||
<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;
|
||||
}
|
||||
@@ -1,142 +1,139 @@
|
||||
// frontend/src/components/Bracket/BracketView.jsx
|
||||
|
||||
import { Check, Trophy } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { stringToColor } from '../../utils/helpers';
|
||||
|
||||
// --- EXACT COPY OF YOUR OLD MATCHCARD (Adapted data props) ---
|
||||
const MatchCard = ({ node, onClick }) => {
|
||||
// GHOST NODE LOGIC: If no display number, it's a structural/hidden node
|
||||
if (!node.display_number) return <div id={`node-${node.id}`} className="hidden" />;
|
||||
const MatchCard = ({ match, onClick }) => {
|
||||
const isFinished = match.status === "Finished";
|
||||
const badgeColor = match.time ? stringToColor(match.court) : null;
|
||||
|
||||
const match = node.match;
|
||||
const p1 = node.p1_team;
|
||||
const p2 = node.p2_team;
|
||||
let borderClass = 'border-zinc-300 dark:border-zinc-700';
|
||||
if (isFinished) borderClass = 'border-orange-500 ring-2 ring-orange-500/10';
|
||||
|
||||
// Old logic: "isPending" means we don't have two players yet
|
||||
const isPending = !p1 || !p2;
|
||||
const badgeColor = match?.court ? stringToColor(match.court.name) : null;
|
||||
const timeDisplay = match?.start_time || node.planned_start_time;
|
||||
const canInteract = match.hasTeams;
|
||||
|
||||
// Calculate wins for display
|
||||
const getWins = (teamId) => {
|
||||
if (!match?.sets) return 0;
|
||||
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;
|
||||
const cursorClass = canInteract
|
||||
? 'cursor-pointer hover:shadow-md hover:-translate-y-0.5'
|
||||
: 'cursor-default opacity-100';
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`node-${node.id}`}
|
||||
onClick={() => !isPending && onClick(node)}
|
||||
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`}
|
||||
id={`match-${match.id}`}
|
||||
onClick={() => canInteract && onClick(match)}
|
||||
className={`w-64 bg-white dark:bg-zinc-900 rounded-lg border ${borderClass} shadow-sm transition-all duration-200 relative z-10 flex flex-col ${cursorClass}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<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="bg-zinc-50 dark:bg-zinc-900/50 px-3 py-2 flex justify-between items-center border-b border-zinc-200 dark:border-zinc-800 rounded-t-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[10px] font-black text-zinc-500 dark:text-zinc-500 uppercase"># {node.display_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>}
|
||||
<span className="font-mono text-[10px] font-bold text-zinc-400"># {match.number}</span>
|
||||
{match.time && <span className="text-[9px] font-black text-white px-1.5 py-0.5 rounded-sm uppercase" style={{ background: badgeColor }}>{match.court}</span>}
|
||||
</div>
|
||||
{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>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-3 space-y-1.5">
|
||||
{/* P1 */}
|
||||
<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 font-bold">{p1 ? p1.name : (node.source_p1_type ? 'TBD' : 'Bye')}</span>
|
||||
<span className="bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 rounded text-[10px] font-black">{p1Wins}</span>
|
||||
</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 className="p-3 space-y-2">
|
||||
{[{ n: match.p1, s: match.p1_sets, win: match.winner === match.p1, real: match.p1_is_real },
|
||||
{ n: match.p2, s: match.p2_sets, win: match.winner === match.p2, real: match.p2_is_real }].map((p, i) => (
|
||||
<div key={i} className={`flex justify-between items-center ${p.win ? 'text-zinc-900 dark:text-white font-black' : p.real ? 'text-zinc-600 dark:text-zinc-300' : 'text-zinc-400 italic'}`}>
|
||||
<span className="truncate text-xs uppercase tracking-tight">{p.n}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${p.win ? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'}`}>{p.s}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function BracketView({ nodes, onMatchClick }) {
|
||||
export default function BracketView({ matches, onMatchClick }) {
|
||||
const containerRef = useRef(null);
|
||||
const svgRef = useRef(null);
|
||||
const [lines, setLines] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
// --- EXACT COPY OF YOUR SVG LOGIC ---
|
||||
if (!containerRef.current || !svgRef.current) return;
|
||||
const draw = () => {
|
||||
if (!containerRef.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 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();
|
||||
// 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);
|
||||
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]" />);
|
||||
}
|
||||
});
|
||||
}, [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>
|
||||
));
|
||||
setLines(newLines);
|
||||
};
|
||||
const t = setTimeout(draw, 100);
|
||||
window.addEventListener('resize', draw);
|
||||
return () => { clearTimeout(t); window.removeEventListener('resize', draw); };
|
||||
}, [matches]);
|
||||
|
||||
const wb = nodes.filter(n => n.bracket_type === 'Winners');
|
||||
const lb = nodes.filter(n => n.bracket_type === 'Losers');
|
||||
const finals = nodes.filter(n => n.bracket_type === 'Finals');
|
||||
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 (
|
||||
<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 ref={containerRef} className="relative min-w-max p-4 flex gap-24">
|
||||
<svg ref={svgRef} className="absolute inset-0 w-full h-full pointer-events-none z-0" />
|
||||
<div key={r} className="flex flex-col gap-10 z-10 w-64 shrink-0 justify-around">
|
||||
{matchesInRound.map(m => <MatchCard key={m.id} match={m} onClick={onMatchClick} />)}
|
||||
</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="relative flex gap-16">
|
||||
<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>
|
||||
{renderTree(wb)}
|
||||
<div className="relative">
|
||||
<div className="absolute -top-8 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400">Winners Bracket</div>
|
||||
<div className="flex gap-20">{renderRound(wb)}</div>
|
||||
</div>
|
||||
|
||||
{lb.length > 0 && (
|
||||
<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-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-16 justify-start">{renderTree(lb, 'justify-start')}</div>
|
||||
{matches.some(m => m.bracket === 'Loser') && (
|
||||
<div className="relative pt-8 border-t border-dashed border-zinc-300 dark:border-zinc-800">
|
||||
<div className="absolute top-0 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400">Losers Bracket</div>
|
||||
<div className="flex gap-20 mt-8">{renderRound(lb)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{finals.length > 0 && (
|
||||
<div className="flex flex-col justify-center items-center gap-4 relative z-10 min-w-[280px]">
|
||||
<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">
|
||||
<Trophy size={14} /> Championship
|
||||
</div>
|
||||
{finals.map(n => <MatchCard key={n.id} node={n} onClick={onMatchClick} />)}
|
||||
{matches.some(m => m.bracket === 'Finals') && (
|
||||
<div className="flex flex-col justify-center gap-6">
|
||||
<div className="text-[10px] font-black uppercase bg-orange-100 dark:bg-orange-900/30 text-orange-600 px-4 py-1.5 rounded-full border border-orange-200 dark:border-orange-800 shadow-sm mx-auto">Championship</div>
|
||||
{finals.map(m => <MatchCard key={m.id} match={m} onClick={onMatchClick} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,83 +1,194 @@
|
||||
// frontend/src/components/Forms/TournamentForm.jsx
|
||||
|
||||
import { useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import api from '../../services/api';
|
||||
|
||||
export default function TournamentForm({ initialData, onSuccess }) {
|
||||
export default function TournamentForm({ tournament, onSuccess, onDelete }) {
|
||||
const [error, setError] = useState(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
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 = {
|
||||
...data,
|
||||
duration: parseInt(data.duration),
|
||||
timestamp: `${data.date}T${data.time}:00`,
|
||||
courts: data.courts.split(',').map(s => s.trim()).filter(Boolean),
|
||||
teams: data.teams.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
name,
|
||||
code,
|
||||
type: typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1),
|
||||
timestamp,
|
||||
duration: parseInt(duration),
|
||||
teams,
|
||||
courts
|
||||
};
|
||||
|
||||
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);
|
||||
onSuccess();
|
||||
} 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 {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm("Purge this tournament and all its history?")) return;
|
||||
try {
|
||||
await api.delete(`/tournaments/${initialData.id}`);
|
||||
window.location.href = '/';
|
||||
} catch (err) { alert("Error deleting"); }
|
||||
};
|
||||
// Calculate default date/time for form
|
||||
const defaultDate = tournament?.timestamp
|
||||
? new Date(tournament.timestamp).toISOString().split('T')[0]
|
||||
: new Date().toISOString().split('T')[0];
|
||||
|
||||
// Defaults
|
||||
const defaultDate = initialData?.timestamp ? new Date(initialData.timestamp).toISOString().split('T')[0] : new Date().toISOString().split('T')[0];
|
||||
const defaultTime = initialData?.timestamp ? new Date(initialData.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) : "09:00";
|
||||
const defaultTeams = initialData?.teams?.map(t => t.name).join('\n');
|
||||
const defaultCourts = initialData?.courts?.map(c => c.name).join(', ');
|
||||
const defaultTime = tournament?.timestamp
|
||||
? new Date(tournament.timestamp).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||
: "09:00";
|
||||
|
||||
return (
|
||||
<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>}
|
||||
<div>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-500">Name</label>
|
||||
<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" />
|
||||
{error && (
|
||||
<div className="text-xs text-red-500 dark:text-red-400 text-center mb-4 bg-red-50 dark:bg-red-900/10 p-2 rounded border border-red-200 dark:border-red-900/30">
|
||||
{error}
|
||||
</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>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-500">Access 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" />
|
||||
<label className="font-bold text-zinc-500 text-xs uppercase">Code</label>
|
||||
<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>
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-500">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">
|
||||
<option value="Double">Double Elimination</option>
|
||||
<option value="Single">Single Elimination</option>
|
||||
<label className="font-bold text-zinc-500 text-xs uppercase">Type</label>
|
||||
<select
|
||||
name="type"
|
||||
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>
|
||||
</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" />
|
||||
<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" />
|
||||
<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" />
|
||||
|
||||
<div className="grid grid-cols-7 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Duration</label>
|
||||
<input
|
||||
type="number"
|
||||
name="duration"
|
||||
defaultValue={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>
|
||||
<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" />
|
||||
<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" />
|
||||
<div className="flex justify-between pt-4 border-t border-zinc-200 dark:border-zinc-800">
|
||||
{initialData && <button type="button" onClick={handleDelete} className="text-red-500 text-sm font-black uppercase tracking-widest hover:underline">Delete Tournament</button>}
|
||||
<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">
|
||||
{isSubmitting ? 'Saving...' : (initialData ? 'Save Changes' : 'Create Tournament')}
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Start Time</label>
|
||||
<input
|
||||
type="time"
|
||||
name="start_time"
|
||||
defaultValue={defaultTime}
|
||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,44 +1,65 @@
|
||||
// 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 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 }) {
|
||||
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
|
||||
const [navTitle, setNavTitle] = React.useState('');
|
||||
const [navSubtitle, setNavSubtitle] = React.useState('');
|
||||
// State to trigger settings modals from Navbar
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isDashboard = location.pathname === '/';
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
if (getToken()) {
|
||||
try {
|
||||
const res = await api.get('/auth/check');
|
||||
setIsAdmin(true); // Endpoint returns 200 OK if token valid
|
||||
setIsAdmin(res.is_admin);
|
||||
} catch {
|
||||
setIsAdmin(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
checkAuth();
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('volleyToken');
|
||||
window.location.reload();
|
||||
};
|
||||
if (localStorage.getItem('volleyToken')) checkAuth();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 flex flex-col">
|
||||
<Navbar title={navTitle} subtitle={navSubtitle} isAdmin={isAdmin} />
|
||||
<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}
|
||||
onLogout={handleLogout}
|
||||
onOpenSettings={() => setShowSettings(true)}
|
||||
isDashboard={isDashboard}
|
||||
/>
|
||||
|
||||
<main className="flex-1 relative overflow-hidden flex flex-col">
|
||||
<Outlet context={{ setNavTitle, setNavSubtitle, isAdmin }} />
|
||||
<main className="flex-1 overflow-hidden relative flex flex-col">
|
||||
<Outlet context={{ setNavTitle, setNavSubtitle, isAdmin, showSettings, setShowSettings }} />
|
||||
</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
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,48 +1,52 @@
|
||||
// frontend/src/components/Layout/Navbar.jsx
|
||||
|
||||
import React from 'react';
|
||||
import { Link, useLocation } 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 === '/';
|
||||
import { Lock, LogOut, Plus, SlidersHorizontal, Volleyball } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function Navbar({ title, subtitle, isAdmin, onLogout, onOpenSettings, isDashboard }) {
|
||||
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">
|
||||
<Link to="/" className="flex items-center 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">
|
||||
<Volleyball className="text-white" size={24} />
|
||||
<nav className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur-lg border-b border-zinc-300 dark:border-zinc-800 sticky top-0 z-[100] px-3 sm:px-6 py-3 sm:py-4 flex justify-between items-center shadow-md shrink-0">
|
||||
<Link to="/" className="flex items-center gap-2 sm:gap-4 cursor-pointer group select-none shrink-0">
|
||||
<div className="p-1.5 sm:p-2.5 bg-orange-600 rounded-xl group-hover:rotate-12 transition-transform shadow-lg shadow-orange-600/30 active:scale-90">
|
||||
<Volleyball className="text-white" size={20} />
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<h1 className="text-2xl font-black tracking-tighter leading-none text-zinc-900 dark:text-white">VolleyManager</h1>
|
||||
<p className="text-[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>
|
||||
</Link>
|
||||
|
||||
<div className="absolute left-1/2 -translate-x-1/2 text-center pointer-events-none">
|
||||
<div className="font-black uppercase text-sm tracking-[0.3em] text-zinc-900 dark:text-white truncate leading-none mb-1">
|
||||
<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-[10px] sm:text-sm tracking-[0.1em] sm:tracking-[0.3em] text-zinc-900 dark:text-white truncate leading-none mb-1">
|
||||
{title || 'Dashboard'}
|
||||
</div>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex gap-2 sm:gap-4 items-center shrink-0">
|
||||
{isAdmin ? (
|
||||
<button
|
||||
onClick={() => { localStorage.removeItem('volleyToken'); window.location.reload(); }}
|
||||
className="text-zinc-400 hover:text-red-500 transition active:scale-90"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut size={22} />
|
||||
<>
|
||||
{isDashboard ? (
|
||||
<button onClick={onOpenSettings} className="bg-orange-600 hover:bg-orange-500 text-white px-3 sm:px-5 py-2 sm:py-2.5 rounded-xl flex items-center gap-2 text-[9px] sm:text-[10px] font-black uppercase tracking-wider sm:tracking-[0.2em] transition shadow-xl shadow-orange-600/20 active:scale-95 shrink-0">
|
||||
<Plus size={16} strokeWidth={4} /> <span className="hidden xs:inline">Create</span>
|
||||
</button>
|
||||
) : (
|
||||
<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">
|
||||
<Lock size={14} className="group-hover:-translate-y-0.5 transition-transform" /> <span>Login</span>
|
||||
<button onClick={onOpenSettings} className="text-zinc-500 hover:text-orange-500 transition p-2 sm:p-3 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-2xl active:scale-90">
|
||||
<SlidersHorizontal size={18} className="sm:size-[22px]" strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
<div className="w-px h-5 sm:h-6 bg-zinc-200 dark:bg-zinc-800 mx-0.5 sm:mx-1" />
|
||||
<button onClick={onLogout} title="Sign Out" className="text-zinc-400 hover:text-red-500 transition active:scale-90 shrink-0">
|
||||
<LogOut size={18} className="sm:size-[22px]" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,87 +1,96 @@
|
||||
// frontend/src/components/Schedule/ScheduleView.jsx
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle, Pencil, Plus, Search, Trophy } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { stringToColor } from '../../utils/helpers';
|
||||
|
||||
export default function ScheduleView({ nodes, onMatchClick }) {
|
||||
export default function ScheduleView({ schedule, onMatchClick }) {
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
// Flatten and prepare matches
|
||||
const schedule = nodes
|
||||
.filter(n => n.match) // Only real matches
|
||||
.map(n => {
|
||||
const m = n.match;
|
||||
return {
|
||||
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 longestCourt = schedule.reduce((max, m) => {
|
||||
const c = m.court || "Court";
|
||||
return c.length > max.length ? c : max;
|
||||
}, "Court");
|
||||
|
||||
const badgeWidth = Math.max(100, longestCourt.length * 9);
|
||||
|
||||
const filtered = schedule.filter(m =>
|
||||
(m.p1 || "").toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(m.p2 || "").toLowerCase().includes(filter.toLowerCase())
|
||||
(m.p1 + m.p2 + m.number).toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto overflow-x-hidden relative flex flex-col">
|
||||
{/* STICKY SEARCH BAR */}
|
||||
<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">
|
||||
<input
|
||||
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 className="h-full overflow-hidden relative flex flex-col">
|
||||
<div className="absolute top-0 inset-x-0 z-30 p-6 pb-2 bg-transparent pointer-events-none">
|
||||
<div className="relative group max-w-3xl mx-auto w-full pointer-events-auto">
|
||||
<input placeholder="Search matches..." className="w-full bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-800 rounded-2xl p-4 pl-12 focus:ring-2 focus:ring-orange-500 outline-none transition shadow-sm text-zinc-900 dark:text-white font-bold" value={filter} onChange={e => setFilter(e.target.value)} />
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-400" size={20} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 pt-2 max-w-4xl mx-auto w-full space-y-3 pb-32">
|
||||
{filtered.map(m => (
|
||||
<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">
|
||||
<div className="flex gap-6 items-center">
|
||||
<div className="text-center min-w-[70px]">
|
||||
<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>
|
||||
<div className="flex-1 overflow-y-auto p-6 pt-28 pb-32 [mask-image:linear-gradient(to_bottom,transparent_0px,transparent_60px,black_110px)]">
|
||||
<div className="max-w-4xl mx-auto w-full space-y-3">
|
||||
{filtered.map(m => {
|
||||
const courtColor = stringToColor(m.court);
|
||||
const isFinished = m.isFinished;
|
||||
|
||||
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 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 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>
|
||||
<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 className="flex items-center gap-4">
|
||||
{m.winner ? (
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-orange-500 font-black text-[10px] uppercase tracking-wider mb-0.5">Finished</div>
|
||||
|
||||
<div className="ml-3 flex items-center shrink-0">
|
||||
{isFinished ? (
|
||||
<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>
|
||||
) : (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>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -1,37 +1,19 @@
|
||||
// frontend/src/components/Tournament/ScoreModal.jsx
|
||||
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import api from '../../services/api';
|
||||
import React, { useState } from 'react';
|
||||
import { Eraser, Clock, MapPin } from 'lucide-react';
|
||||
import { stringToColor } from '../../utils/helpers';
|
||||
import Modal from '../UI/Modal';
|
||||
|
||||
export default function ScoreModal({ isOpen, onClose, node, tournamentId, isAdmin }) {
|
||||
const match = node.match;
|
||||
|
||||
const initialSets = match.sets?.length ? match.sets : [{ p1: '', p2: '' }];
|
||||
const [sets, setSets] = useState(initialSets);
|
||||
const ScoreForm = ({ match, isAdmin, onSubmit, onClear }) => {
|
||||
// Safe init of sets
|
||||
const [sets, setSets] = useState(match.sets && match.sets.length ? match.sets : [{ p1: '', p2: '' }]);
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await api.post(`/tournaments/${tournamentId}/matches/${match.id}/score`, {
|
||||
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"); }
|
||||
try { await onSubmit(match.id, sets, code); }
|
||||
catch (err) { setError(typeof err.detail === 'string' ? err.detail : "Check code or scores"); }
|
||||
};
|
||||
|
||||
const removeSet = (idx) => {
|
||||
@@ -44,68 +26,132 @@ export default function ScoreModal({ isOpen, onClose, node, tournamentId, isAdmi
|
||||
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 (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={`Match Protocol #${node.display_number}`}>
|
||||
<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>}
|
||||
|
||||
<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="text-center">
|
||||
<div className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 mb-1">Time</div>
|
||||
<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}
|
||||
{/* 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 items-center gap-2 text-sm font-mono text-zinc-600 dark:text-zinc-300">
|
||||
<Clock className="text-orange-500" size={18} />
|
||||
<span>{match.time || "10:00"}</span>
|
||||
</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>
|
||||
|
||||
{!isAdmin && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400">Authorization</label>
|
||||
<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" />
|
||||
{error && (
|
||||
<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">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-center font-black text-zinc-800 dark:text-zinc-200 items-center">
|
||||
<div className="text-sm truncate uppercase tracking-tight">{p1Name}</div>
|
||||
<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>
|
||||
<div className="text-sm truncate uppercase tracking-tight">{p2Name}</div>
|
||||
{!isAdmin && (
|
||||
<div className="bg-zinc-50 dark:bg-zinc-950 p-4 rounded-lg border border-gray-200 dark:border-zinc-800">
|
||||
<label className="block text-xs font-bold text-orange-500 uppercase mb-2">Tournament Code</label>
|
||||
<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 className="space-y-4">
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||||
{sets.map((s, i) => (
|
||||
<div key={i} className="animate-in slide-in-from-top-1 px-1">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 flex items-center gap-3">
|
||||
<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" />
|
||||
<div className="w-4 h-0.5 bg-zinc-300 dark:bg-zinc-700 rounded-full shrink-0" />
|
||||
<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" />
|
||||
</div>
|
||||
<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">
|
||||
<Trash2 size={20} className="group-hover:scale-110 transition-transform" />
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="grid grid-cols-3 gap-2 items-center justify-items-center w-full">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
pattern="[0-9]*"
|
||||
value={s.p1}
|
||||
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>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
<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">
|
||||
{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>}
|
||||
<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>
|
||||
<div className="flex gap-2">
|
||||
{match.isFinished && (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
// frontend/src/components/UI/Modal.jsx
|
||||
|
||||
import React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export default function Modal({ isOpen, onClose, title, children }) {
|
||||
if (!isOpen) return null;
|
||||
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="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="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-black text-zinc-900 dark:text-white uppercase tracking-tight">{title}</h2>
|
||||
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition">
|
||||
<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-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} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,72 +1,112 @@
|
||||
// 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 { useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import TournamentForm from '../components/Forms/TournamentForm';
|
||||
import Modal from '../components/UI/Modal';
|
||||
import api from '../services/api';
|
||||
import api, { WS_URL } from '../services/api';
|
||||
|
||||
// --- EXACT COPY OF YOUR DASHCARD ---
|
||||
const DashCard = ({ t, isAdmin, onClick, 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 className="absolute top-0 left-0 w-2 h-full bg-orange-600 group-hover:w-3 transition-all"></div>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<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"
|
||||
// --- RESTORED DASHCARD DESIGN ---
|
||||
const DashCard = ({ t, isAdmin, onSelect, onEdit }) => (
|
||||
<div
|
||||
onClick={() => onSelect(t.id)}
|
||||
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"
|
||||
>
|
||||
<SlidersHorizontal size={18} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<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 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 className="flex items-center justify-between">
|
||||
<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">
|
||||
<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">
|
||||
{t.type}
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
|
||||
export default function Dashboard() {
|
||||
const { setNavTitle, isAdmin } = useOutletContext();
|
||||
const { setNavTitle, setNavSubtitle, isAdmin, showSettings, setShowSettings } = useOutletContext();
|
||||
|
||||
const [tournaments, setTournaments] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState(null);
|
||||
const [showPast, setShowPast] = useState(false);
|
||||
const [showAllFuture, setShowAllFuture] = useState(false);
|
||||
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(() => {
|
||||
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 () => {
|
||||
try {
|
||||
const data = await api.get('/tournaments');
|
||||
setTournaments(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const handleEdit = (t) => {
|
||||
setEditTarget(t);
|
||||
setShowSettings(true);
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
setShowSettings(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 groups = { live: [], future: [], past: [] };
|
||||
|
||||
@@ -78,20 +118,20 @@ export default function Dashboard() {
|
||||
else groups.past.push(t);
|
||||
});
|
||||
|
||||
// Sort
|
||||
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.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 (
|
||||
<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">
|
||||
{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
|
||||
</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
|
||||
</h2>
|
||||
<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>
|
||||
</section>
|
||||
)}
|
||||
@@ -113,12 +153,20 @@ export default function Dashboard() {
|
||||
<Calendar size={18} /> Upcoming
|
||||
</h2>
|
||||
{groups.future.length > 0 ? (
|
||||
<>
|
||||
<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 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>
|
||||
|
||||
{groups.past.length > 0 && (
|
||||
@@ -129,14 +177,19 @@ export default function Dashboard() {
|
||||
</button>
|
||||
{showPast && (
|
||||
<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>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title={editTarget ? "Modify Event" : "New Tournament"}>
|
||||
<TournamentForm initialData={editTarget} isEdit={!!editTarget} onSuccess={(newT) => { setShowCreate(false); loadTournaments(); }} />
|
||||
<Modal isOpen={showSettings} onClose={() => { setShowSettings(false); setEditTarget(null); }} title={editTarget ? 'Modify Event' : 'Initialize Event'}>
|
||||
<TournamentForm
|
||||
tournament={editTarget}
|
||||
onSuccess={handleSuccess}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// 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 { Volleyball, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
|
||||
export default function Login() {
|
||||
@@ -14,23 +14,21 @@ export default function Login() {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
try {
|
||||
// The backend expects x-www-form-urlencoded for OAuth2
|
||||
const res = await api.postForm('/auth/token', formData);
|
||||
localStorage.setItem('volleyToken', res.access_token);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError('Invalid credentials. Please try again.');
|
||||
setError('Invalid credentials.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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="p-8">
|
||||
<div className="flex justify-center mb-8">
|
||||
@@ -39,8 +37,8 @@ export default function Login() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-black text-center text-zinc-900 dark:text-white tracking-tight mb-2">Admin Access</h1>
|
||||
<p className="text-center text-zinc-500 text-sm font-medium mb-8">Enter your credentials to manage events</p>
|
||||
<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 administrative credentials</p>
|
||||
|
||||
{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">
|
||||
@@ -48,34 +46,23 @@ export default function Login() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400 ml-1">Username</label>
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500">Identity</label>
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400 ml-1">Password</label>
|
||||
<input
|
||||
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 className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500">Secret Key</label>
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +1,48 @@
|
||||
// frontend/src/services/api.js
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
const getBackendHost = () => {
|
||||
const host = window.location.hostname || 'localhost';
|
||||
return host;
|
||||
};
|
||||
|
||||
// Updated to include /api prefix
|
||||
export const API_BASE = `${window.location.protocol}//${getBackendHost()}:8000/api`;
|
||||
export const WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${getBackendHost()}:8000/ws`;
|
||||
const port = '8000';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
export const API_BASE = `${window.location.protocol}//${getBackendHost()}:${port}/api`;
|
||||
export const WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${getBackendHost()}:${port}/api/ws`;
|
||||
|
||||
// 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
|
||||
api.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
export const getToken = () => localStorage.getItem('volleyToken');
|
||||
|
||||
const api = {
|
||||
request: async (method, url, data = null, isFormData = false) => {
|
||||
const headers = {};
|
||||
const token = getToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
if (!isFormData) headers['Content-Type'] = 'application/json';
|
||||
|
||||
const opts = { method, headers };
|
||||
if (data) opts.body = isFormData ? data : JSON.stringify(data);
|
||||
|
||||
// Ensure clean URL concatenation
|
||||
const baseUrl = API_BASE.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');
|
||||
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;
|
||||
@@ -14,8 +14,7 @@ export default defineConfig({
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
"/": path.resolve(__dirname, "./public"),
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user