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