11 Commits
58 changed files with 6766 additions and 1247 deletions
+2 -1
View File
@@ -3,5 +3,6 @@
"backend"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
"python.testing.pytestEnabled": true,
"python-envs.defaultEnvManager": "ms-python.python:venv"
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 William Söderberg
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+19 -15
View File
@@ -6,7 +6,7 @@ from fastapi.security import OAuth2PasswordBearer
import jwt
from jwt.exceptions import PyJWTError
from .config import SECRET_KEY, ALGORITHM, ADMIN_USER
from .config import SECRET_KEY, ALGORITHM
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token")
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="auth/token", auto_error=False)
@@ -20,43 +20,47 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def get_current_user(token: str = Depends(oauth2_scheme)):
async def get_authenticated_user(token: str = Depends(oauth2_scheme)):
"""Allows both Admins and Refs"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if username is None or username != ADMIN_USER:
role = payload.get("role")
if role not in ["admin", "ref"]:
raise credentials_exception
return payload
except PyJWTError:
raise credentials_exception
return username
async def get_admin_user(token: str = Depends(oauth2_scheme)):
"""Strictly allows ONLY Admins"""
user = await get_authenticated_user(token)
if user.get("role") != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required"
)
return user
async def get_optional_user(
token: Optional[str] = Depends(oauth2_scheme_optional),
) -> Optional[str]:
) -> Optional[dict]:
"""Returns user payload if valid token exists, else None"""
if not token:
return None
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if username == ADMIN_USER:
return username
if payload.get("role") in ["admin", "ref"]:
return payload
except Exception:
pass
return None
+5
View File
@@ -13,8 +13,13 @@ ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 hours
ADMIN_USER = os.getenv("ADMIN_USER", "admin")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin")
# Ref Credentials
REF_USER = os.getenv("REF_USER", "ref")
REF_PASSWORD = os.getenv("REF_PASSWORD", "ref")
password_hash = PasswordHash.recommended()
ADMIN_HASH = password_hash.hash(ADMIN_PASSWORD)
REF_HASH = password_hash.hash(REF_PASSWORD)
def verify_password(plain_password, hashed_password):
-63
View File
@@ -1,63 +0,0 @@
# backend/app/core/utils.py
from .brackets import Match
class MermaidLive:
@staticmethod
def _get_visual_target(match: Match, is_win=True) -> Match | None:
current = match.next_win if is_win else match.next_loss
while current and current.is_bye:
current = current.next_win
return current
@classmethod
def export(cls, matches: list[Match]) -> None:
print("\n--- COPY TO MERMAID.LIVE ---")
print("graph LR")
print(" classDef wb stroke:#01579b,stroke-width:2px;")
print(" classDef lb stroke:#b71c1c,stroke-width:2px,stroke-dasharray: 5 5;")
print(" classDef final stroke:#e65100,stroke-width:4px;")
wb_nodes = [
m
for m in matches
if "WB" in m.name or "Semifinal" in m.name or "Winners Final" in m.name
]
lb_nodes = [m for m in matches if "LB" in m.name or "Losers Final" in m.name]
final_nodes = [
m for m in matches if "Grand Final" in m.name or "3rd Place" in m.name
]
print(" subgraph Winners Bracket")
for m in wb_nodes:
cls._print_node(m, "wb")
print(" end")
if lb_nodes:
print(" subgraph Losers Bracket")
for m in lb_nodes:
cls._print_node(m, "lb")
print(" end")
print(" subgraph Championship / 3rd Place")
for m in final_nodes:
cls._print_node(m, "final")
print(" end")
for m in matches:
target_win = cls._get_visual_target(m, is_win=True)
if target_win:
print(f" M{m.id} --> M{target_win.id}")
target_loss = cls._get_visual_target(m, is_win=False)
if target_loss:
print(f" M{m.id} -.-> M{target_loss.id}")
@staticmethod
def _print_node(m: Match, style: str) -> None:
label = m.name
if m.teams[0] and m.teams[1]:
label += f" ({m.teams[0]} vs {m.teams[1]})"
print(f' M{m.id}["{label}"]:::{style}')
+115
View File
@@ -173,6 +173,106 @@ def update_schedule_times(db: Session, t: models.Tournament):
else:
break
# ==========================================
# --- DYNAMIC REFEREE ASSIGNMENT ---
# ==========================================
all_matches = sorted(list(matches), key=lambda x: (x.start_time, x.court_id))
duty_counts = defaultdict(int)
active_refs = []
def is_busy(outcome_tuple, source_m, start, end):
next_m_id = source_m.winner_next_match_id if outcome_tuple[0] == "W" else source_m.loser_next_match_id
if next_m_id:
next_m = match_map[next_m_id]
next_start = next_m.start_time
if next_start:
next_end = next_start + timedelta(minutes=t.duration)
if not (next_end <= start or next_start >= end):
return True
for r_outcome, r_start, r_end in active_refs:
if r_outcome == outcome_tuple:
if not (r_end <= start or r_start >= end):
return True
return False
for m in all_matches:
if not m.start_time:
continue
m_start = m.start_time
m_end = m_start + timedelta(minutes=t.duration)
if m.bracket_type == BracketTypes.FINALS:
prev_final = next((p for p in all_matches if p.bracket_type == BracketTypes.FINALS and p.winner_next_match_id == m.id), None)
if prev_final:
m.ref_team_id = prev_final.ref_team_id
m.ref_label = prev_final.ref_label
identifier = f"TEAM_{m.ref_team_id}" if m.ref_team_id else prev_final.ref_label
active_refs.append((identifier, m_start, m_end))
continue
best_outcome = None
best_score = float('inf')
for prev_m in all_matches:
if not prev_m.start_time:
continue
prev_end = prev_m.start_time + timedelta(minutes=t.duration)
if prev_end <= m_start:
l_outcome = ("L", prev_m.id)
if not is_busy(l_outcome, prev_m, m_start, m_end):
wait_mins = (m_start - prev_end).total_seconds() / 60.0
score = (duty_counts[l_outcome] * 120) + wait_mins
if prev_m.court_id == m.court_id:
score -= 30
if score < best_score:
best_score = score
best_outcome = (l_outcome, prev_m)
w_outcome = ("W", prev_m.id)
if not is_busy(w_outcome, prev_m, m_start, m_end):
wait_mins = (m_start - prev_end).total_seconds() / 60.0
score = (duty_counts[w_outcome] * 120) + wait_mins + 60
if prev_m.court_id == m.court_id:
score -= 30
if score < best_score:
best_score = score
best_outcome = (w_outcome, prev_m)
if best_outcome:
outcome_tuple, prev_m = best_outcome
duty_counts[outcome_tuple] += 1
active_refs.append((outcome_tuple, m_start, m_end))
role = "Winner" if outcome_tuple[0] == "W" else "Loser"
m.ref_label = f"{role} of #{prev_m.match_number}"
else:
m.ref_label = "Staff / Volunteers"
for future_m in all_matches:
if future_m.start_time and future_m.start_time >= m_end:
assigned = False
for t_id in [future_m.p1_team_id, future_m.p2_team_id]:
if t_id:
team_identifier = f"TEAM_{t_id}"
is_team_busy = any(
r_outcome == team_identifier and not (r_end <= m_start or r_start >= m_end)
for r_outcome, r_start, r_end in active_refs
)
if not is_team_busy:
m.ref_team_id = t_id
m.ref_label = None
active_refs.append((team_identifier, m_start, m_end))
assigned = True
break
if assigned:
break
# ----------------------------------
db.commit()
@@ -182,6 +282,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
for m in match.tournament.matches:
if m.ref_label == f"Loser of #{match.match_number}":
m.ref_team_id = loser_id
db.add(m)
elif m.ref_label == f"Winner of #{match.match_number}":
m.ref_team_id = winner_id
db.add(m)
if (
match.bracket_type == BracketTypes.FINALS
and match.winner_next_match
@@ -246,6 +354,11 @@ def undo_advancement(db: Session, match: models.Match):
if not match.winner_team_id:
return
for m in match.tournament.matches:
if m.ref_label == f"Loser of #{match.match_number}" or m.ref_label == f"Winner of #{match.match_number}":
m.ref_team_id = None
db.add(m)
winner_id = match.winner_team_id
loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id
@@ -272,3 +385,5 @@ def undo_advancement(db: Session, match: models.Match):
clear_from_next(match.winner_next_match, match.winner_next_match_slot)
if match.loser_next_match and loser_id:
clear_from_next(match.loser_next_match, match.loser_next_match_slot)
db.commit()
+17 -36
View File
@@ -70,51 +70,32 @@ class Match(Base):
bracket_type: Mapped[BracketTypes] = mapped_column(SqlEnum(BracketTypes))
court_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("courts.id"), nullable=True
)
court_id: Mapped[Optional[int]] = mapped_column(ForeignKey("courts.id"), nullable=True)
start_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
status: Mapped[MatchStatus] = mapped_column(
SqlEnum(MatchStatus), default=MatchStatus.SCHEDULED
)
status: Mapped[MatchStatus] = mapped_column(SqlEnum(MatchStatus), default=MatchStatus.SCHEDULED)
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)
ref_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True)
ref_label: Mapped[Optional[str]] = mapped_column(String, nullable=True)
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
)
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
)
winner_next_match_slot: Mapped[Optional[int]] = mapped_column(
Integer, 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_id: Mapped[Optional[str]] = mapped_column(ForeignKey("matches.id"), nullable=True)
loser_next_match_slot: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
tournament: Mapped["Tournament"] = relationship(back_populates="matches")
court: Mapped[Optional["Court"]] = relationship()
p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id])
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]
)
ref_team: Mapped[Optional["Team"]] = relationship("Team", foreign_keys=[ref_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])
+19 -12
View File
@@ -6,11 +6,13 @@ from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from ..schemas import Token
from ..core.auth import create_access_token, get_current_user
from ..core.auth import create_access_token, get_authenticated_user
from ..core.config import (
ACCESS_TOKEN_EXPIRE_MINUTES,
ADMIN_HASH,
ADMIN_USER,
REF_HASH,
REF_USER,
verify_password,
)
@@ -21,14 +23,18 @@ router = APIRouter(prefix="/auth", tags=["Auth"])
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
):
if form_data.username != ADMIN_USER:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
role = None
if not verify_password(form_data.password, ADMIN_HASH):
if form_data.username == ADMIN_USER and verify_password(
form_data.password, ADMIN_HASH
):
role = "admin"
elif form_data.username == REF_USER and verify_password(
form_data.password, REF_HASH
):
role = "ref"
if not role:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
@@ -37,12 +43,13 @@ async def login_for_access_token(
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": form_data.username}, expires_delta=access_token_expires
data={"sub": form_data.username, "role": role},
expires_delta=access_token_expires,
)
return {"access_token": access_token, "token_type": "bearer"}
return {"access_token": access_token, "token_type": "bearer", "role": role}
@router.get("/check")
async def check_auth(user: str = Depends(get_current_user)):
return {"is_admin": True, "user": user}
async def check_auth(user: dict = Depends(get_authenticated_user)):
return {"role": user.get("role"), "user": user.get("sub")}
+4 -4
View File
@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
from ... import crud, schemas
from ...constants import SUCCESS
from ...core.auth import get_current_user
from ...core.auth import get_admin_user
from ...core.websocket_manager import send_ws_update
from ...database import get_db
from . import router
@@ -20,7 +20,7 @@ async def create_court(
id: str,
court: schemas.CourtCreate,
db: Session = Depends(get_db),
user: str = Depends(get_current_user),
user: dict = Depends(get_admin_user),
):
new_court = crud.create_court(db, id, court)
if not new_court:
@@ -35,7 +35,7 @@ async def update_courts(
id: str,
courts: list[str],
db: Session = Depends(get_db),
user: str = Depends(get_current_user),
user: dict = Depends(get_admin_user),
):
t = crud.update_tournament_courts(db, id, courts)
if not t:
@@ -50,7 +50,7 @@ async def delete_court(
id: str,
court_id: int,
db: Session = Depends(get_db),
user: str = Depends(get_current_user),
user: dict = Depends(get_admin_user),
):
success = crud.delete_court(db, id, court_id)
if not success:
+2 -2
View File
@@ -6,13 +6,13 @@ from sqlalchemy.orm import Session
from ... import crud, schemas
from ...database import get_db
from ...core.auth import get_current_user
from ...core.auth import get_admin_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)
id: str, db: Session = Depends(get_db), user: dict = Depends(get_admin_user)
):
t = crud.get_tournament(db, id)
if not t:
+4 -4
View File
@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
from ... import crud, schemas
from ...constants import SUCCESS
from ...core.auth import get_current_user
from ...core.auth import get_admin_user
from ...core.websocket_manager import send_ws_update
from ...database import get_db
from . import router
@@ -20,7 +20,7 @@ async def create_team(
id: str,
team: schemas.TeamCreate,
db: Session = Depends(get_db),
user: str = Depends(get_current_user),
user: dict = Depends(get_admin_user),
):
new_team = crud.create_team(db, id, team)
if not new_team:
@@ -35,7 +35,7 @@ async def update_teams(
id: str,
teams: list[str],
db: Session = Depends(get_db),
user: str = Depends(get_current_user),
user: dict = Depends(get_admin_user),
):
t = crud.update_tournament_teams(db, id, teams)
if not t:
@@ -50,7 +50,7 @@ async def delete_team(
id: str,
team_id: int,
db: Session = Depends(get_db),
user: str = Depends(get_current_user),
user: dict = Depends(get_admin_user),
):
success = crud.delete_team(db, id, team_id)
if not success:
@@ -6,7 +6,7 @@ from ... import crud, schemas
from ...constants import SUCCESS
from ...core.websocket_manager import send_ws_update
from ...database import get_db
from ...core.auth import get_current_user
from ...core.auth import get_admin_user
from . import router
@@ -14,7 +14,7 @@ from . import router
async def create_tournament(
data: schemas.TournamentCreate,
db: Session = Depends(get_db),
user: str = Depends(get_current_user),
user: dict = Depends(get_admin_user),
):
new_t = crud.create_tournament(db, data)
await send_ws_update(new_t.id)
@@ -39,7 +39,7 @@ async def update_settings(
id: str,
data: schemas.TournamentUpdate,
db: Session = Depends(get_db),
user: str = Depends(get_current_user),
user: dict = Depends(get_admin_user),
):
t = crud.update_tournament_details(db, id, data)
if not t:
@@ -51,7 +51,7 @@ async def update_settings(
@router.delete("/{id}")
async def delete_tournament(
id: str, db: Session = Depends(get_db), user: str = Depends(get_current_user)
id: str, db: Session = Depends(get_db), user: dict = Depends(get_admin_user)
):
success = crud.delete_tournament(db, id)
if not success:
+5
View File
@@ -50,6 +50,10 @@ class MatchOut(BaseModel):
p2_team_id: int | None = None
winner_team_id: int | None = None
ref_team_id: int | None = None
ref_label: str | None = None
ref_team: TeamSchema | None = None
winner_next_match_id: str | None = None
winner_next_match_slot: int | None = None
loser_next_match_id: str | None = None
@@ -110,3 +114,4 @@ class TournamentSettingsResponse(TournamentOut):
class Token(BaseModel):
access_token: str
token_type: str
role: str | None
+3 -3
View File
@@ -2,7 +2,7 @@
from typing import AsyncGenerator, Generator
import pytest
from app.core.auth import get_current_user, get_optional_user
from app.core.auth import get_admin_user, get_optional_user
from app.database import Base, get_db
# Import your app and models
@@ -52,7 +52,7 @@ async def client(db: Session) -> AsyncGenerator[AsyncClient, None]:
pass
# Strict Auth: Always requires a token (simulated by header presence)
def override_get_current_user(request: Request):
def override_get_admin_user(request: Request):
if "Authorization" not in request.headers:
# Let FastAPI raise the 401 naturally if header is missing
raise pytest.skip("Auth header missing in strict auth test")
@@ -65,7 +65,7 @@ async def client(db: Session) -> AsyncGenerator[AsyncClient, None]:
return None
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = override_get_current_user
app.dependency_overrides[get_admin_user] = override_get_admin_user
app.dependency_overrides[get_optional_user] = override_get_optional_user
async with AsyncClient(
+4
View File
@@ -1,5 +1,9 @@
ADMIN_USER=admin
ADMIN_PASSWORD=admin
REF_USER=ref
REF_PASSWORD=ref
SECRET_KEY=PLEASE_REPLACE_ME_WITH_A_SECRET_KEY
# Optional
+59 -2
View File
@@ -1,4 +1,4 @@
# React + Vite
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
@@ -13,4 +13,61 @@ The React Compiler is not enabled on this template because of its impact on dev
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+3 -9
View File
@@ -2,28 +2,22 @@ import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])
+2 -2
View File
@@ -5,7 +5,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="src/assets/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>VolleyManager</title>
@@ -13,7 +13,7 @@
<body class="transition bg-zinc-50 dark:bg-zinc-950">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5443 -573
View File
File diff suppressed because it is too large Load Diff
+9 -5
View File
@@ -5,9 +5,9 @@
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"build": "tsc -b && vite build",
"lint": "tsc -b && eslint .",
"preview": "vite preview --host",
"test": "vitest"
},
"dependencies": {
@@ -21,9 +21,10 @@
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@eslint/js": "^9.39.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.4.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
@@ -32,10 +33,13 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^28.0.0",
"jsdom": "^28.1.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript-eslint": "^8.57.0",
"vite": "^7.3.1",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-svgr": "^5.0.0",
"vitest": "^4.0.18"
}
}

Before

Width:  |  Height:  |  Size: 966 B

After

Width:  |  Height:  |  Size: 966 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -1,9 +1,10 @@
// frontend/src/App.test.jsx
// frontend/src/App.test.tsx
import { render, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import App from './App';
// Setup mocks
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
@@ -11,15 +12,18 @@ const localStorageMock = {
clear: vi.fn(),
theme: 'light',
};
global.localStorage = localStorageMock;
global.fetch = vi.fn(() =>
const fetchMock = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
})
);
// Use Vitest's stubGlobal for type-safe environment mocking
vi.stubGlobal('localStorage', localStorageMock);
vi.stubGlobal('fetch', fetchMock);
describe('App Component', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -27,7 +31,6 @@ describe('App Component', () => {
document.documentElement.style.backgroundColor = '';
});
// Notice the "async" keyword added here
it('renders the App and applies the light theme by default', async () => {
render(<App />);
@@ -38,7 +41,7 @@ describe('App Component', () => {
// 2. Wait for the asynchronous fetch to finish so React doesn't complain
await waitFor(() => {
expect(global.fetch).toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalled();
});
});
});
@@ -1,4 +1,4 @@
// frontend/src/App.jsx
// frontend/src/App.tsx
import { useEffect, useState } from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
@@ -8,7 +8,7 @@ import Login from './pages/Login';
import Tournament from './pages/Tournament';
export default function App() {
const [darkMode, setDarkMode] = useState(() => localStorage.theme === 'dark');
const [darkMode, setDarkMode] = useState<boolean>(() => localStorage.theme === 'dark');
useEffect(() => {
const root = window.document.documentElement;
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" fill="currentColor">
<path d="M400 64c8.8 0 16 7.2 16 16 0 17.7 14.3 32 32 32s32-14.3 32-32c0-8.8 7.2-16 16-16l112 0c17.7 0 32 14.3 32 32l0 70.3c0 15-10.4 28-25.1 31.2L413.8 242.2c1.4 9.7 2.2 19.6 2.2 29.8 0 114.9-93.1 208-208 208S0 386.9 0 272 93.1 64 208 64l192 0zM208 192a80 80 0 1 0 0 160 80 80 0 1 0 0-160z"/>
</svg>

After

Width:  |  Height:  |  Size: 386 B

@@ -1,146 +0,0 @@
// frontend/src/components/Bracket/BracketView.jsx
import { useEffect, useRef, useState } from 'react';
import MatchCard from "./MatchCard";
import Podium from "../Tournament/Podium";
export default function BracketView({ matches, onMatchClick }) {
const containerRef = useRef(null);
const [lines, setLines] = useState([]);
useEffect(() => {
const draw = () => {
if (!containerRef.current) return;
const container = containerRef.current.getBoundingClientRect();
const newLines = [];
matches.forEach(m => {
if (m.winner_next_match_id) {
const sEl = document.getElementById(`match-${m.id}`);
const eEl = document.getElementById(`match-${m.winner_next_match_id}`);
if (sEl && eEl) {
const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
const sx = r1.right - container.left, sy = r1.top + r1.height / 2 - container.top;
const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top;
const c1 = sx + (ex - sx) / 2;
newLines.push(
<path key={`w-${m.id}`} d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-400/70 dark:stroke-zinc-700/70 print:!stroke-zinc-400 fill-none stroke-[2px]" />
);
}
}
if (m.loser_next_match_id) {
const targetMatch = matches.find(x => x.id === m.loser_next_match_id);
if (targetMatch && targetMatch.bracket === 'Finals') {
const sEl = document.getElementById(`match-${m.id}`);
const eEl = document.getElementById(`match-${targetMatch.id}`);
if (sEl && eEl) {
const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
const sx = r1.right - container.left, sy = r1.top + r1.height / 2 - container.top;
const ex = r2.left - container.left, ey = r2.top + r2.height / 2 - container.top;
const c1 = sx + (ex - sx) / 2;
newLines.push(
<path key={`l-${m.id}`} strokeDasharray="6 6" d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-300 dark:stroke-zinc-700 print:!stroke-zinc-400 fill-none stroke-[1.5px]" />
);
}
}
}
});
setLines(newLines);
};
const t = setTimeout(draw, 100);
window.addEventListener('resize', draw);
const handlePrint = () => { draw(); setTimeout(draw, 100); };
const mql = window.matchMedia('print');
mql.addEventListener('change', handlePrint);
window.addEventListener('beforeprint', handlePrint);
window.addEventListener('afterprint', draw);
return () => {
clearTimeout(t);
window.removeEventListener('resize', draw);
mql.removeEventListener('change', handlePrint);
window.removeEventListener('beforeprint', handlePrint);
window.removeEventListener('afterprint', draw);
};
}, [matches]);
const renderRound = (list) => {
const rounds = {};
list.forEach(m => (rounds[m.round] = rounds[m.round] || []).push(m));
const roundKeys = Object.keys(rounds).sort((a, b) => Number(a) - Number(b));
return roundKeys.map((r) => {
let matchesInRound = rounds[r];
matchesInRound.sort((a, b) => a.number - b.number);
return (
<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 isDoubleElim = matches.some(m => m.bracket === 'Loser');
const wb = matches.filter(m => m.bracket === 'Winner');
const lb = matches.filter(m => m.bracket === 'Loser');
const finals = matches.filter(m => m.bracket === 'Finals');
let displayWb = [...wb];
let displayFinals = [...finals];
if (!isDoubleElim && displayWb.length > 0) {
const maxRound = Math.max(...displayWb.map(m => m.round));
const gfIndex = displayWb.findIndex(m => m.round === maxRound);
if (gfIndex !== -1) {
const gfMatch = displayWb.splice(gfIndex, 1)[0];
displayFinals.unshift(gfMatch);
}
}
return (
<div className="transition-colors w-full h-full overflow-auto print:overflow-visible print:h-auto print:w-auto bg-zinc-50 dark:bg-zinc-950 bg-[radial-gradient(theme(colors.zinc.300)_1px,transparent_1px)] dark:bg-[radial-gradient(theme(colors.zinc.800)_1px,transparent_1px)] [background-size:20px_20px] print:!bg-white print:!bg-none">
<style>
{`@media print {
@page { size: landscape; margin: 0.5cm; }
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; background: white !important; }
}`}
</style>
<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 print:overflow-visible">
{lines}
</svg>
<div className="flex flex-col gap-24">
<div className="relative">
<div className="absolute -top-8 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 print:!text-black">Winners Bracket</div>
<div className="flex gap-20">{renderRound(displayWb)}</div>
</div>
{isDoubleElim && (
<div className="relative pt-8 border-t border-dashed border-zinc-300 dark:border-zinc-800 print:!border-zinc-400">
<div className="absolute top-4 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 print:!text-black">Losers Bracket</div>
<div className="flex gap-20 mt-4">{renderRound(lb)}</div>
</div>
)}
</div>
<div className="flex flex-col justify-center gap-6 z-10">
{displayFinals.length > 0 && (
<div className="relative flex flex-col gap-6">
<div className="absolute -top-10 left-1/2 -translate-x-1/2 text-[10px] font-black uppercase bg-orange-100 dark:bg-orange-900/30 text-orange-600 print:!bg-transparent print:!border-black print:!text-black px-4 py-1.5 rounded-full border border-orange-200 dark:border-orange-800 shadow-sm whitespace-nowrap">
Championship
</div>
{displayFinals.map(m => <MatchCard key={m.id} match={m} onClick={onMatchClick} />)}
</div>
)}
</div>
<div className="flex flex-col justify-center gap-6 z-10">
<Podium matches={matches} />
</div>
</div>
</div>
);
}
@@ -0,0 +1,209 @@
// frontend/src/components/Bracket/BracketView.tsx
import React, { useEffect, useRef, useState } from 'react';
import { ZoomIn, ZoomOut } from 'lucide-react';
import { type MatchData } from '../../types';
import Podium from "../Tournament/Podium";
import MatchCard from "./MatchCard";
interface BracketViewProps {
matches: MatchData[];
onMatchClick: (match: MatchData) => void;
}
export default function BracketView({ matches, onMatchClick }: BracketViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [lines, setLines] = useState<React.ReactElement[]>([]);
const [zoom, setZoom] = useState<number>(1);
const [contentSize, setContentSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const draw = () => {
if (!containerRef.current) return;
// Measure the unscaled bracket size to fix scrollbars later
setContentSize({
width: containerRef.current.scrollWidth,
height: containerRef.current.scrollHeight
});
const container = containerRef.current.getBoundingClientRect();
const newLines: React.ReactElement[] = [];
matches.forEach(m => {
if (m.winner_next_match_id) {
const sEl = document.getElementById(`match-${m.id}`);
const eEl = document.getElementById(`match-${m.winner_next_match_id}`);
if (sEl && eEl) {
const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
const sx = (r1.right - container.left) / zoom;
const sy = (r1.top + r1.height / 2 - container.top) / zoom;
const ex = (r2.left - container.left) / zoom;
const ey = (r2.top + r2.height / 2 - container.top) / zoom;
const c1 = sx + (ex - sx) / 2;
newLines.push(
<path key={`w-${m.id}`} d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-400/70 dark:stroke-zinc-700/70 print:stroke-zinc-400! fill-none stroke-[2px]" />
);
}
}
if (m.loser_next_match_id) {
const targetMatch = matches.find(x => x.id === m.loser_next_match_id);
if (targetMatch && targetMatch.bracket === 'Finals') {
const sEl = document.getElementById(`match-${m.id}`);
const eEl = document.getElementById(`match-${targetMatch.id}`);
if (sEl && eEl) {
const r1 = sEl.getBoundingClientRect(), r2 = eEl.getBoundingClientRect();
const sx = (r1.right - container.left) / zoom;
const sy = (r1.top + r1.height / 2 - container.top) / zoom;
const ex = (r2.left - container.left) / zoom;
const ey = (r2.top + r2.height / 2 - container.top) / zoom;
const c1 = sx + (ex - sx) / 2;
newLines.push(
<path key={`l-${m.id}`} strokeDasharray="6 6" d={`M ${sx} ${sy} C ${c1} ${sy}, ${c1} ${ey}, ${ex} ${ey}`} className="stroke-zinc-300 dark:stroke-zinc-700 print:stroke-zinc-400! fill-none stroke-[1.5px]" />
);
}
}
}
});
setLines(newLines);
};
const t = setTimeout(draw, 50);
window.addEventListener('resize', draw);
const handlePrint = () => { draw(); setTimeout(draw, 100); };
const mql = window.matchMedia('print');
mql.addEventListener('change', handlePrint);
window.addEventListener('beforeprint', handlePrint);
window.addEventListener('afterprint', draw);
return () => {
clearTimeout(t);
window.removeEventListener('resize', draw);
mql.removeEventListener('change', handlePrint);
window.removeEventListener('beforeprint', handlePrint);
window.removeEventListener('afterprint', draw);
};
}, [matches, zoom]);
const renderRound = (list: MatchData[]) => {
const rounds: Record<number, MatchData[]> = {};
list.forEach(m => (rounds[m.round] = rounds[m.round] || []).push(m));
const roundKeys = Object.keys(rounds).map(Number).sort((a, b) => a - b);
return roundKeys.map((r) => {
const matchesInRound = rounds[r];
matchesInRound.sort((a, b) => a.number - b.number);
return (
<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 isDoubleElim = matches.some(m => m.bracket === 'Loser');
const wb = matches.filter(m => m.bracket === 'Winner');
const lb = matches.filter(m => m.bracket === 'Loser');
const finals = matches.filter(m => m.bracket === 'Finals');
const displayWb = [...wb];
const displayFinals = [...finals];
if (!isDoubleElim && displayWb.length > 0) {
const maxRound = Math.max(...displayWb.map(m => m.round));
const gfIndex = displayWb.findIndex(m => m.round === maxRound);
if (gfIndex !== -1) {
const gfMatch = displayWb.splice(gfIndex, 1)[0];
displayFinals.unshift(gfMatch);
}
}
return (
<div className="relative w-full h-full flex flex-col overflow-hidden bg-zinc-50 dark:bg-zinc-950 print:bg-white!">
{/* FLOATING ZOOM CONTROLS (Moved to top-6 to completely avoid theme button) */}
<div className="absolute top-6 right-6 flex flex-col gap-3 z-50 print:hidden">
<button
onClick={() => setZoom(z => Math.min(1, z + 0.1))}
disabled={zoom >= 1}
className="p-3 bg-white dark:bg-zinc-800 rounded-full shadow-lg shadow-black/5 border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-300 disabled:opacity-30 hover:text-orange-500 hover:border-orange-500 transition active:scale-90"
title="Zoom In"
>
<ZoomIn size={20} strokeWidth={2.5} />
</button>
<button
onClick={() => setZoom(z => Math.max(0.3, z - 0.1))}
disabled={zoom <= 0.3}
className="p-3 bg-white dark:bg-zinc-800 rounded-full shadow-lg shadow-black/5 border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-300 disabled:opacity-30 hover:text-orange-500 hover:border-orange-500 transition active:scale-90"
title="Zoom Out"
>
<ZoomOut size={20} strokeWidth={2.5} />
</button>
</div>
<div className="flex-1 overflow-auto bg-[radial-gradient(var(--color-zinc-300)_1px,transparent_1px)] dark:bg-[radial-gradient(var(--color-zinc-800)_1px,transparent_1px)] bg-size-[20px_20px] print:bg-none!">
<style>
{`@media print {
@page { size: landscape; margin: 0.5cm; }
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; background: white !important; }
}`}
</style>
{/* SIZER WRAPPER (Dynamically scales width/height to fix the scrollbar ghost space) */}
<div style={{
width: contentSize.width ? `${contentSize.width * zoom}px` : 'max-content',
height: contentSize.height ? `${contentSize.height * zoom}px` : 'max-content'
}}>
{/* SCALING WRAPPER */}
<div
className="origin-top-left print:transform-none! w-max h-max"
style={{ transform: `scale(${zoom})` }}
>
{/* Added pt-16 md:pt-20 to ensure absolute titles aren't clipped
*/}
<div ref={containerRef} className="relative min-w-max min-h-full pt-16 md:pt-20 px-6 md:px-12 pb-40 md:pb-32 flex gap-12 md:gap-20 items-center">
<svg className="absolute inset-0 w-full h-full pointer-events-none z-0 print:overflow-visible">
{lines}
</svg>
<div className="flex flex-col gap-24">
<div className="relative">
<div className="absolute -top-8 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 print:text-black!">Winners Bracket</div>
<div className="flex gap-20">{renderRound(displayWb)}</div>
</div>
{isDoubleElim && (
<div className="relative pt-8 border-t border-dashed border-zinc-300 dark:border-zinc-800 print:border-zinc-400!">
<div className="absolute top-4 left-0 text-[10px] font-black uppercase tracking-[0.2em] text-zinc-400 print:text-black!">Losers Bracket</div>
<div className="flex gap-20 mt-4">{renderRound(lb)}</div>
</div>
)}
</div>
<div className="flex flex-col justify-center gap-6 z-10">
{displayFinals.length > 0 && (
<div className="relative flex flex-col gap-6">
<div className="absolute -top-10 left-1/2 -translate-x-1/2 text-[10px] font-black uppercase bg-orange-100 dark:bg-orange-900/30 text-orange-600 print:bg-transparent! print:border-black! print:text-black! px-4 py-1.5 rounded-full border border-orange-200 dark:border-orange-800 shadow-sm whitespace-nowrap">
Championship
</div>
{displayFinals.map(m => <MatchCard key={m.id} match={m} onClick={onMatchClick} />)}
</div>
)}
</div>
<div className="flex flex-col justify-center gap-6 z-10">
<Podium matches={matches} />
</div>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -1,11 +1,18 @@
// frontend/src/components/Bracket/MatchCard.jsx
// frontend/src/components/Bracket/MatchCard.tsx
import { Check } from 'lucide-react';
import { type MatchData } from '../../types';
import { printName, stringToColor } from '../../utils/helpers';
import WhistleIcon from "../../assets/whistle.svg?react"
export default function MatchCard({ match, onClick }) {
interface MatchCardProps {
match: MatchData;
onClick: (match: MatchData) => void;
}
export default function MatchCard({ match, onClick }: MatchCardProps) {
const isFinished = match.status === "Finished";
const badgeColor = match.time ? stringToColor(match.court) : null;
const badgeColor = match.time ? stringToColor(match.court) : undefined;
let borderClass = 'border-zinc-300 dark:border-zinc-700';
if (isFinished) borderClass = 'border-orange-500 ring-2 ring-orange-500/10';
@@ -15,17 +22,19 @@ export default function MatchCard({ match, onClick }) {
? 'cursor-pointer hover:shadow-md hover:-translate-y-0.5'
: 'cursor-default opacity-100';
const refName = match.ref_team?.name || match.ref_label;
return (
<div
id={`match-${match.id}`}
onClick={() => canInteract && onClick(match)}
className={`transition-colors w-64 bg-white dark:bg-zinc-900 print:!bg-white rounded-lg border ${borderClass} print:!border-zinc-400 print:!shadow-none shadow-sm transition-all duration-200 print:transition-none relative z-10 flex flex-col ${cursorClass}`}
className={`transition-colors w-64 bg-white dark:bg-zinc-900 print:bg-white! rounded-lg border ${borderClass} print:border-zinc-400! print:shadow-none! shadow-sm transition-all duration-200 print:transition-none relative z-10 flex flex-col ${cursorClass}`}
>
<div className="transition-colors bg-zinc-50 dark:bg-zinc-900/50 print:!bg-transparent px-3 py-1.5 flex justify-between items-center border-b border-zinc-200 dark:border-zinc-800 print:!border-zinc-400 rounded-t-lg">
<div className="transition-colors bg-zinc-50 dark:bg-zinc-900/50 print:bg-transparent! px-3 py-1.5 flex justify-between items-center border-b border-zinc-200 dark:border-zinc-800 print:border-zinc-400! rounded-t-lg">
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] font-bold text-zinc-400 print:!text-zinc-600"># {match.number}</span>
<span className="font-mono text-[10px] font-bold text-zinc-400 print:text-zinc-600!"># {match.number}</span>
{match.time && (
<span className="text-[9px] font-black text-white print:!text-zinc-800 px-1.5 py-0.5 rounded-sm uppercase print:!border print:!border-zinc-400 print:!bg-transparent" style={{ background: badgeColor }}>
<span className="text-[9px] font-black text-white print:text-zinc-800! px-1.5 py-0.5 rounded-sm uppercase print:border! print:border-zinc-400! print:bg-transparent!" style={{ background: badgeColor }}>
{match.court}
</span>
)}
@@ -33,16 +42,16 @@ export default function MatchCard({ match, onClick }) {
{isFinished ? (
<Check className="text-orange-500 print:hidden" size={14} strokeWidth={3} />
) : (
<span className="text-[10px] font-bold text-zinc-500 print:!text-zinc-600 font-mono print:hidden">{match.time || 'TBD'}</span>
<span className="text-[10px] font-bold text-zinc-500 print:text-zinc-600! font-mono print:hidden">{match.time || 'TBD'}</span>
)}
</div>
<div className="p-2 space-y-1.5">
<div className="p-2 space-y-1.5 flex-1 flex flex-col justify-center">
{[
{ n: match.p1, s: match.p1_sets, win: match.winner_team_id !== null && match.winner_team_id === match.p1_team_id, real: match.p1_is_real },
{ n: match.p2, s: match.p2_sets, win: match.winner_team_id !== null && match.winner_team_id === match.p2_team_id, real: match.p2_is_real }
].map((p, i) => (
<div key={i} className={`flex justify-between items-center ${p.win ? 'text-zinc-800 dark:text-zinc-50 print:!text-black font-black' : p.real ? 'text-zinc-500 dark:text-zinc-400 print:!text-black' : 'text-zinc-400 print:!text-zinc-600 italic'}`}>
<div key={i} className={`flex justify-between items-center ${p.win ? 'text-zinc-800 dark:text-zinc-50 print:text-black! font-black' : p.real ? 'text-zinc-600 dark:text-zinc-400 print:text-black!' : 'text-zinc-400 dark:text-zinc-600 print:text-zinc-600! italic'}`}>
<span className={`truncate text-xs tracking-tight pr-2 print:hidden ${p.win ? ' font-black text-orange-500' : 'font-bold'}`}>{p.n}</span>
@@ -60,6 +69,14 @@ export default function MatchCard({ match, onClick }) {
</div>
))}
</div>
{/* --- FLOATING REF BADGE --- */}
{refName && !isFinished && (
<div className="absolute -bottom-2.5 left-1/2 -translate-x-1/2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 text-[8px] font-black uppercase tracking-widest px-2.5 py-0.5 rounded-full shadow-sm whitespace-nowrap z-20 flex items-center gap-1.5 print:hidden max-w-[90%]">
<WhistleIcon className="text-orange-500 shrink-0" width={12} height={12} />
<span className="truncate">{refName}</span>
</div>
)}
</div>
);
}
@@ -1,8 +1,24 @@
// frontend/src/components/Dashboard/DashCard.jsx
// frontend/src/components/Dashboard/DashCard.tsx
import { MapPin, SlidersHorizontal, Users } from 'lucide-react';
export default function DashCard({ t, isAdmin, onSelect, onEdit }) {
interface TournamentSummary {
id: string | number;
name: string;
timestamp: string;
type: string;
team_count: number;
court_count: number;
}
interface DashCardProps {
t: TournamentSummary;
isAdmin: boolean;
onSelect: (id: string | number) => void;
onEdit: (t: TournamentSummary) => void;
}
export default function DashCard({ t, isAdmin, onSelect, onEdit }: DashCardProps) {
return (
<div
onClick={() => onSelect(t.id)}
@@ -1,14 +1,31 @@
// frontend/src/components/Forms/TournamentForm.jsx
// frontend/src/components/Forms/TournamentForm.tsx
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import api from '../../services/api';
export default function TournamentForm({ tournamentId, onSuccess, onDelete }) {
const [initialData, setInitialData] = useState(null);
const [isLoading, setIsLoading] = useState(!!tournamentId);
const [error, setError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
interface TournamentSettings {
id: string | number;
name: string;
code: string;
type: string;
timestamp: string;
duration: number;
courts: { name?: string }[];
teams: { name?: string }[];
}
interface TournamentFormProps {
tournamentId?: string | number | null;
onSuccess: () => void;
onDelete?: (id: string | number) => void;
}
export default function TournamentForm({ tournamentId, onSuccess, onDelete }: TournamentFormProps) {
const [initialData, setInitialData] = useState<TournamentSettings | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(!!tournamentId);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
useEffect(() => {
if (!tournamentId) {
@@ -19,7 +36,7 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }) {
const fetchSettings = async () => {
try {
const data = await api.get(`/tournaments/${tournamentId}/settings`);
const data = await api.get<TournamentSettings>(`/tournaments/${tournamentId}/settings`);
setInitialData(data);
} catch (err) {
console.error(err);
@@ -32,21 +49,21 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }) {
fetchSettings();
}, [tournamentId]);
const handleSubmit = async (e) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
const formData = new FormData(e.target);
const formData = new FormData(e.currentTarget);
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 rawTeams = formData.get('teams') as string;
const rawCourts = formData.get('courts') as string;
const date = formData.get('date') as string;
const startTime = formData.get('start_time') as string;
const typeRaw = formData.get('type') as string;
const duration = formData.get('duration') as string;
const name = formData.get('name') as string;
const code = formData.get('code') as string;
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);
@@ -59,7 +76,7 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }) {
const timestamp = `${date}T${startTime}:00`;
const formattedType = typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1);
const parsedDuration = parseInt(duration);
const parsedDuration = parseInt(duration, 10);
try {
if (tournamentId) {
@@ -89,12 +106,13 @@ export default function TournamentForm({ tournamentId, onSuccess, onDelete }) {
await api.post('/tournaments', fullPayload);
}
onSuccess();
} catch (err) {
} catch (err: unknown) {
console.error(err);
if (Array.isArray(err.detail)) {
setError(err.detail.map(e => `${e.loc.join('.')}: ${e.msg}`).join(', '));
const error = err as { detail?: string | Array<{ loc: string[]; msg: string }> };
if (Array.isArray(error.detail)) {
setError(error.detail.map((e) => `${e.loc.join('.')}: ${e.msg}`).join(', '));
} else {
setError(typeof err.detail === 'string' ? err.detail : "Error saving tournament");
setError(typeof error.detail === 'string' ? error.detail : "Error saving tournament");
}
} finally {
setIsSubmitting(false);
-46
View File
@@ -1,46 +0,0 @@
// frontend/src/components/Layout/Layout.jsx
import { useEffect, useState } from 'react';
import { Outlet } from 'react-router-dom';
import { getToken } from '../../services/api';
import ThemeButton from "../UI/ThemeButton";
import Navbar from './Navbar';
export default function Layout({ darkMode, setDarkMode }) {
const [isAdmin, setIsAdmin] = useState(!!getToken());
const [navTitle, setNavTitle] = useState('');
const [navSubtitle, setNavSubtitle] = useState('');
const [showSettings, setShowSettings] = useState(false);
useEffect(() => {
if (navTitle) {
document.title = `${navTitle} | VolleyManager`;
} else {
document.title = 'VolleyManager';
}
}, [navTitle]);
const handleLogout = () => {
localStorage.removeItem('volleyToken');
window.location.reload();
};
return (
<div className="fixed inset-0 min-h-screen bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 transition-colors flex flex-col overflow-hidden print:static print:overflow-visible print:h-auto print:bg-white print:text-black">
<div className="print:hidden shrink-0">
<Navbar
title={navTitle}
subtitle={navSubtitle}
isAdmin={isAdmin}
onLogout={handleLogout}
/>
</div>
<main className="flex-1 overflow-hidden relative flex flex-col print:overflow-visible print:h-auto print:block">
<Outlet context={{ setNavTitle, setNavSubtitle, isAdmin, showSettings, setShowSettings }} />
</main>
<ThemeButton darkMode={darkMode} setDarkMode={setDarkMode} />
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
// frontend/src/components/Layout/Layout.tsx
import React, { useEffect, useState } from 'react';
import { Outlet } from 'react-router-dom';
import api, { getToken } from '../../services/api';
import ThemeButton from "../UI/ThemeButton";
import Navbar from './Navbar';
interface LayoutProps {
darkMode: boolean;
setDarkMode: (value: boolean) => void;
}
export interface OutletContextType {
setNavTitle: React.Dispatch<React.SetStateAction<string>>;
setNavSubtitle: React.Dispatch<React.SetStateAction<string>>;
role: 'admin' | 'ref' | null;
showSettings: boolean;
setShowSettings: React.Dispatch<React.SetStateAction<boolean>>;
}
export default function Layout({ darkMode, setDarkMode }: LayoutProps) {
const [role, setRole] = useState<'admin' | 'ref' | null>(null);
const [navTitle, setNavTitle] = useState<string>('');
const [navSubtitle, setNavSubtitle] = useState<string>('');
const [showSettings, setShowSettings] = useState<boolean>(false);
useEffect(() => {
const verifyAuth = async () => {
if (getToken()) {
try {
const res = await api.get<{ role: 'admin' | 'ref' }>('/auth/check');
setRole(res.role);
} catch {
setRole(null);
localStorage.removeItem('volleyToken');
}
}
};
verifyAuth();
}, []);
useEffect(() => {
if (navTitle) {
document.title = `${navTitle} | VolleyManager`;
} else {
document.title = 'VolleyManager';
}
}, [navTitle]);
const handleLogout = () => {
localStorage.removeItem('volleyToken');
window.location.reload();
};
const contextValue: OutletContextType = {
setNavTitle,
setNavSubtitle,
role,
showSettings,
setShowSettings
};
return (
<div className="fixed inset-0 min-h-screen bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 transition-colors flex flex-col overflow-hidden print:static print:overflow-visible print:h-auto print:bg-white print:text-black">
<div className="print:hidden shrink-0">
<Navbar
title={navTitle}
subtitle={navSubtitle}
isAuthenticated={!!role}
onLogout={handleLogout}
/>
</div>
<main className="flex-1 overflow-hidden relative flex flex-col print:overflow-visible print:h-auto print:block">
<Outlet context={contextValue} />
</main>
<ThemeButton darkMode={darkMode} setDarkMode={setDarkMode} />
</div>
);
}
@@ -1,11 +1,18 @@
// frontend/src/components/Layout/Navbar.jsx
// frontend/src/components/Layout/Navbar.tsx
import { Lock, LogOut, Volleyball } from 'lucide-react';
import { Link } from 'react-router-dom';
export default function Navbar({ title, subtitle, isAdmin, onLogout }) {
interface NavbarProps {
title: string;
subtitle: string;
isAuthenticated: boolean;
onLogout: () => void;
}
export default function Navbar({ title, subtitle, isAuthenticated, onLogout }: NavbarProps) {
return (
<nav className="transition-colors bg-zinc-50 dark:bg-zinc-900 border-b border-zinc-300 dark:border-zinc-800 sticky top-0 z-[100] px-3 sm:px-6 py-3 sm:py-4 flex justify-between items-center shadow-md shrink-0">
<nav className="transition-colors bg-zinc-50 dark:bg-zinc-900 border-b border-zinc-300 dark:border-zinc-800 sticky top-0 z-100 px-3 sm:px-6 py-3 sm:py-4 flex justify-between items-center shadow-md shrink-0">
<Link to="/" className="flex items-center gap-2 sm:gap-4 cursor-pointer group select-none shrink-0">
<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} />
@@ -16,8 +23,8 @@ export default function Navbar({ title, subtitle, isAdmin, onLogout }) {
</div>
</Link>
<div className="transition-colors absolute left-1/2 -translate-x-1/2 text-center pointer-events-none w-full max-w-[140px] xs:max-w-[180px] sm:max-w-[400px]">
<div className="font-black uppercase text-[10px] sm:text-sm tracking-[0.1em] sm:tracking-[0.3em] text-zinc-900 dark:text-white truncate leading-none mb-1">
<div className="transition-colors absolute left-1/2 -translate-x-1/2 text-center pointer-events-none w-full max-w-35 xs:max-w-[180px] sm:max-w-100">
<div className="font-black uppercase text-[10px] sm:text-sm tracking-widest sm:tracking-[0.3em] text-zinc-900 dark:text-white truncate leading-none mb-1">
{title || 'Dashboard'}
</div>
{subtitle && (
@@ -28,15 +35,15 @@ export default function Navbar({ title, subtitle, isAdmin, onLogout }) {
</div>
<div className="flex gap-2 sm:gap-4 items-center shrink-0">
{isAdmin ? (
{isAuthenticated ? (
<>
<button onClick={onLogout} title="Sign Out" className="text-zinc-400 hover:text-red-500 transition active:scale-90 shrink-0">
<LogOut size={18} className="sm:size-[22px]" />
<LogOut size={18} className="sm:size-5.5" />
</button>
</>
) : (
<Link to="/login" className="text-orange-600 font-black flex items-center gap-1.5 text-[9px] sm:text-[10px] uppercase tracking-widest hover:text-orange-500 transition group p-1.5 sm:p-2 rounded-xl hover:bg-orange-50 dark:hover:bg-orange-950/20">
<Lock size={12} className="sm:size-[14px] group-hover:-translate-y-0.5 transition-transform" /> <span className="hidden xs:inline">Login</span>
<Lock size={12} className="sm:size-3.5 group-hover:-translate-y-0.5 transition-transform" /> <span className="hidden xs:inline">Login</span>
</Link>
)}
</div>
@@ -1,22 +1,31 @@
// frontend/src/components/Schedule/ScheduleRow.jsx
// frontend/src/components/Schedule/ScheduleRow.tsx
import { CheckCircle, Pencil, Plus, Trophy } from 'lucide-react';
import React from 'react';
import { type MatchData } from '../../types';
import { printName, stringToColor } from '../../utils/helpers';
import WhistleIcon from "../../assets/whistle.svg?react"
export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }) {
interface ScheduleRowProps {
match: MatchData;
onMatchClick: (match: MatchData) => void;
badgeWidth: number;
}
export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }: ScheduleRowProps) {
const courtColor = stringToColor(m.court);
const isFinished = m.isFinished;
const refName = m.ref_team?.name || m.ref_label;
return (
<div className="transition-colors bg-white dark:bg-zinc-900 print:!bg-white p-4 print:p-3 rounded-2xl print:rounded-none border border-zinc-300 dark:border-zinc-800 print:!border-b print:!border-x-0 print:!border-t-0 print:!border-zinc-300 shadow-sm print:!shadow-none flex items-center justify-between transition-all hover:border-orange-500/30">
<div className="relative mb-5 transition-colors bg-white dark:bg-zinc-900 print:bg-white! p-4 print:p-3 rounded-2xl print:rounded-none border border-zinc-300 dark:border-zinc-800 print:border-b! print:border-x-0! print:border-t-0! print:border-zinc-300! shadow-sm print:shadow-none! flex items-center justify-between hover:border-orange-500/30">
<div className="flex gap-4 md:gap-6 print:gap-6 flex-1 min-w-0">
<div className="flex flex-col gap-1 items-center shrink-0" style={{ minWidth: badgeWidth }}>
<div className="text-lg md:text-xl print:text-xl font-black font-mono text-zinc-900 dark:text-white print:!text-black">{m.time}</div>
<div className="text-tiny font-black text-white print:!text-zinc-800 px-2 py-1 rounded uppercase w-full truncate text-center print:!border print:!border-zinc-400 print:!bg-transparent" style={{ background: courtColor }}>
<div className="text-lg md:text-xl print:text-xl font-black font-mono text-zinc-900 dark:text-white print:text-black!">{m.time}</div>
<div className="text-tiny font-black text-white print:text-zinc-800! px-2 py-1 rounded uppercase w-full truncate text-center print:border! print:border-zinc-400! print:bg-transparent!" style={{ background: courtColor }}>
{m.court}
</div>
<div className="transition-colors block md:hidden print:hidden text-tiny font-black bg-zinc-100 dark:bg-zinc-800 w-full truncate text-center print:!bg-transparent text-zinc-400 print:!text-zinc-500 px-2 py-1 rounded w-fit">Match #{m.number}</div>
<div className="transition-colors block md:hidden print:hidden text-tiny font-black bg-zinc-100 dark:bg-zinc-800 w-full truncate text-center print:bg-transparent! text-zinc-400 print:text-zinc-500! px-2 py-1 rounded">Match #{m.number}</div>
</div>
<div className="flex-1 flex flex-col gap-0.5 min-w-0 justify-center md:justify-between">
@@ -27,40 +36,37 @@ export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }) {
<div className="flex items-center gap-2 min-w-0">
{p.win && <Trophy size={14} className="text-orange-500 shrink-0 print:hidden" />}
<span className={`block print:hidden truncate text-sm md:text-base font-bold ${p.win ? 'text-orange-600' : p.real ? 'text-zinc-900 dark:text-zinc-100' : 'text-zinc-400 italic font-normal'}`}>{p.n}</span>
<span className={`hidden print:inline-block print:whitespace-normal print:overflow-visible print:text-base font-bold ${p.real ? 'print:!text-black' : 'print:!text-zinc-600 italic font-normal'}`}>
<span className={`hidden print:inline-block print:whitespace-normal print:overflow-visible print:text-base font-bold ${p.real ? 'print:text-black!' : 'print:text-zinc-600! italic font-normal'}`}>
{printName(p.n)}
</span>
</div>
{i === 0 && <span className="block print:block text-zinc-300 dark:text-zinc-700 print:!text-zinc-400 text-xs font-black md:pb-0.5">VS</span>}
{i === 0 && <span className="block print:block text-zinc-300 dark:text-zinc-700 print:text-zinc-400! text-xs font-black md:pb-0.5">VS</span>}
</React.Fragment>
))}
</div>
<div className="transition-colors hidden md:block print:block text-tiny font-black bg-zinc-100 dark:bg-zinc-800 print:!bg-transparent text-zinc-400 print:!text-zinc-500 px-2 py-1 rounded w-fit">Match #{m.number}</div>
<div className="flex flex-col md:flex-row md:items-center gap-0.5 md:gap-2 mt-0.5 md:mt-1">
<div className="transition-colors hidden md:block print:block text-tiny font-black bg-zinc-100 dark:bg-zinc-800 print:bg-transparent! text-zinc-400 print:text-zinc-500! px-2 py-1 rounded w-fit">Match #{m.number}</div>
</div>
</div>
</div>
{/* ACTION BUTTON AREA */}
{/* items-stretch ensures the button fills the height on mobile to push the scores apart */}
<div className="ml-3 flex items-stretch shrink-0 print:hidden py-0.5">
{isFinished ? (
<button
onClick={() => onMatchClick(m)}
className="flex flex-col justify-between items-center md:justify-center md:items-end hover:bg-zinc-50 dark:hover:bg-zinc-800 p-1.5 md:p-2 rounded-xl transition group/btn min-w-[32px] md:min-w-[80px] border border-transparent hover:border-zinc-200 dark:hover:border-zinc-700"
className="flex flex-col justify-between items-center md:justify-center md:items-end hover:bg-zinc-50 dark:hover:bg-zinc-800 p-1.5 md:p-2 rounded-xl transition group/btn min-w-8 md:min-w-20 border border-transparent hover:border-zinc-200 dark:hover:border-zinc-700"
title="Edit Score"
>
{/* --- MOBILE VERTICAL STACK --- */}
<div className={`${m.winnerName === m.p1 ? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-50'} px-2 py-0.5 rounded text-[10px] font-black font-mono border border-zinc-200 dark:border-zinc-700 md:hidden`}>
{m.p1_sets}
</div>
{/* Clean minimal vertical line for mobile */}
<div className="w-[2px] h-3 bg-zinc-200 dark:bg-zinc-700 rounded-full md:hidden my-1" />
<div className="w-0.5 h-3 bg-zinc-200 dark:bg-zinc-700 rounded-full md:hidden my-1" />
<div className={`${m.winnerName === m.p2 ? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-50'} px-2 py-0.5 rounded text-[10px] font-black font-mono border border-zinc-200 dark:border-zinc-700 md:hidden`}>
{m.p2_sets}
</div>
{/* --- DESKTOP VIEW (Default + Hover Edit) --- */}
<div className="hidden md:flex group-hover/btn:hidden flex-col items-end">
<div className="text-orange-500 font-black text-[10px] uppercase flex items-center gap-1">
<CheckCircle size={12} strokeWidth={3} /> Finished
@@ -96,6 +102,13 @@ export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }) {
{isFinished ? m.p2_sets : ''}
</div>
</div>
{refName && !isFinished && (
<div className="absolute -bottom-3 left-1/2 -translate-x-1/2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-300 text-[9px] md:text-[10px] font-black uppercase tracking-widest px-3 md:px-4 py-1 rounded-full shadow-md whitespace-nowrap z-20 flex items-center gap-1.5 print:hidden max-w-[90%]">
<WhistleIcon className="text-orange-500 shrink-0" width={12} height={12} />
<span className="truncate">{refName}</span>
</div>
)}
</div>
);
}
@@ -1,11 +1,17 @@
// frontend/src/components/Schedule/ScheduleView.jsx
// frontend/src/components/Schedule/ScheduleView.tsx
import { Search } from 'lucide-react';
import { useState } from 'react';
import { type MatchData } from '../../types';
import ScheduleRow from './ScheduleRow';
export default function ScheduleView({ schedule, onMatchClick }) {
const [filter, setFilter] = useState("");
interface ScheduleViewProps {
schedule: MatchData[];
onMatchClick: (match: MatchData) => void;
}
export default function ScheduleView({ schedule, onMatchClick }: ScheduleViewProps) {
const [filter, setFilter] = useState<string>("");
const longestCourt = schedule.reduce((max, m) => {
const c = m.court || "Court";
@@ -15,7 +21,7 @@ export default function ScheduleView({ schedule, onMatchClick }) {
const badgeWidth = Math.max(80, longestCourt.length * 10);
const filteredAndSorted = schedule
.filter(m => (m.p1 + m.p2 + m.number + `Match #${m.number}`).toLowerCase().includes(filter.toLowerCase()))
.filter(m => (m.p1 + m.p2 + m.number + `Match #${m.number}` + m.ref_label).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 || "";
@@ -46,7 +52,7 @@ export default function ScheduleView({ schedule, onMatchClick }) {
</div>
</div>
<div className="transition-colors absolute top-0 left-0 right-3 h-32 bg-gradient-to-b from-zinc-50 via-zinc-50/95 to-transparent dark:from-zinc-950 dark:via-zinc-950/95 dark:to-transparent pointer-events-none z-20 print:hidden" />
<div className="transition-colors absolute top-0 left-0 right-3 h-32 bg-linear-to-b from-zinc-50 via-zinc-50/95 to-transparent dark:from-zinc-950 dark:via-zinc-950/95 dark:to-transparent pointer-events-none z-20 print:hidden" />
<div className="flex-1 overflow-y-auto print:overflow-visible p-6 pt-28 pb-32 print:p-0 print:pt-12 relative z-10">
<div className="max-w-4xl mx-auto w-full space-y-3 print:space-y-0">
@@ -1,44 +1,57 @@
// frontend/src/components/Tournament/Podium.jsx
// frontend/src/components/Tournament/Podium.tsx
import { Trophy } from 'lucide-react';
import { printName } from '../../utils/helpers';
export default function Podium({ matches }) {
interface MatchInfo {
bracket: string;
round: number;
isFinished: boolean;
winnerName: string | null;
p1: string;
p2: string;
number: number;
hasTeams: boolean;
}
interface PodiumProps {
matches: MatchInfo[];
}
export default function Podium({ matches }: PodiumProps) {
const isDoubleElim = matches.some(m => m.bracket === 'Loser');
const 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;
let gfMatch: MatchInfo | null = null;
let resetMatch: MatchInfo | null = null;
let tpMatch: MatchInfo | null = null;
if (isDoubleElim) {
tpMatch = lb[lb.length - 1];
gfMatch = finals[0];
resetMatch = finals[1];
tpMatch = lb[lb.length - 1] || null;
gfMatch = finals[0] || null;
resetMatch = finals[1] || null;
} else {
gfMatch = wb[wb.length - 1];
tpMatch = finals[0];
gfMatch = wb[wb.length - 1] || null;
tpMatch = finals[0] || null;
}
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: 1, label: "1st", team: "TBD", isReal: false, color: "bg-yellow-400 text-yellow-900 dark:text-yellow-950 shadow-yellow-400/50", hidden: false },
{ rank: 2, label: "2nd", team: "TBD", isReal: false, color: "bg-zinc-300 dark:bg-zinc-400 text-zinc-800 dark:text-zinc-900 shadow-zinc-400/50", hidden: false },
{ rank: 3, label: "3rd", team: "TBD", isReal: false, color: "bg-amber-600 text-amber-50 dark:text-amber-50 shadow-amber-600/50", hidden: false }
];
// --- CALCULATE 3RD PLACE ---
if (tpMatch) {
if (tpMatch.isFinished && tpMatch.winnerName) {
// If match is done, grab the actual team name
podium[2].team = isDoubleElim
? (tpMatch.winnerName === tpMatch.p1 ? tpMatch.p2 : tpMatch.p1) // DE: Loser of LB Final
: tpMatch.winnerName; // SE: Winner of 3rd Place Match
? (tpMatch.winnerName === tpMatch.p1 ? tpMatch.p2 : tpMatch.p1)
: tpMatch.winnerName;
podium[2].isReal = true;
} else {
// Set the exact string expected by the print formatter
podium[2].team = isDoubleElim ? `Loser of #${tpMatch.number}` : `Winner of #${tpMatch.number}`;
}
} else {
@@ -53,46 +66,40 @@ export default function Podium({ matches }) {
podium[1].isReal = true;
} else if (gfMatch) {
if (gfMatch.isFinished && gfMatch.winnerName) {
// Has the loser bracket champ won, forcing a reset?
const isResetForced = resetMatch && resetMatch.hasTeams;
if (!isResetForced) {
// GF Winner is 1st, GF Loser is 2nd
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 {
// Reset forced, wait for the final match
podium[0].team = `Winner of #${resetMatch.number}`;
podium[1].team = `Loser of #${resetMatch.number}`;
podium[0].team = `Winner of #${resetMatch?.number || 'TBD'}`;
podium[1].team = `Loser of #${resetMatch?.number || 'TBD'}`;
}
} else {
// GF not finished yet
podium[0].team = `Winner of #${gfMatch.number}`;
podium[1].team = `Loser of #${gfMatch.number}`;
}
}
return (
<div className="bg-white dark:bg-zinc-900 print:!bg-white border border-zinc-200 dark:border-zinc-800 print:!border-zinc-400 rounded-xl shadow-sm w-64 overflow-hidden z-10 print:!shadow-none">
<div className="bg-zinc-50 dark:bg-zinc-900/50 print:!bg-transparent p-3 border-b border-zinc-200 dark:border-zinc-800 print:!border-zinc-400">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500 print:!text-zinc-600 text-center flex items-center justify-center gap-2">
<Trophy size={14} className="text-orange-500 print:!text-black" /> Final Standings
<div className="bg-white dark:bg-zinc-900 print:bg-white! border border-zinc-200 dark:border-zinc-800 print:border-zinc-400! rounded-xl shadow-sm w-64 overflow-hidden z-10 print:shadow-none!">
<div className="bg-zinc-50 dark:bg-zinc-900/50 print:bg-transparent! p-3 border-b border-zinc-200 dark:border-zinc-800 print:border-zinc-400!">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500 print:text-zinc-600! text-center flex items-center justify-center gap-2">
<Trophy size={14} className="text-orange-500 print:text-black!" /> Final Standings
</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 print:!shadow-none print:!bg-white print:!border print:!border-zinc-400 print:!text-black ${p.color}`}>
<div className={`w-7 h-7 rounded-full flex items-center justify-center font-black text-xs shrink-0 shadow-sm print:shadow-none! print:bg-white! print:border! print:border-zinc-400! print:text-black! ${p.color}`}>
{p.rank}
</div>
{/* Web Label */}
<div className={`text-sm truncate print:hidden ${p.isReal ? 'font-bold text-zinc-900 dark:text-white print:!text-black' : 'font-medium italic text-zinc-400 print:!text-zinc-600'}`} title={p.team}>
<div className={`text-sm truncate print:hidden ${p.isReal ? 'font-bold text-zinc-900 dark:text-white print:text-black!' : 'font-medium italic text-zinc-400 print:text-zinc-600!'}`} title={p.team}>
{p.team}
</div>
{/* Print Label */}
<div className={`hidden print:block text-sm print:whitespace-normal print:overflow-visible ${p.isReal ? 'font-bold print:!text-black' : 'font-medium italic print:!text-zinc-600'}`}>
<div className={`hidden print:block text-sm print:whitespace-normal print:overflow-visible ${p.isReal ? 'font-bold print:text-black!' : 'font-medium italic print:text-zinc-600!'}`}>
{printName(p.team)}
</div>
</div>
@@ -1,25 +1,53 @@
// frontend/src/components/Tournament/ScoreModal.jsx
// frontend/src/components/Tournament/ScoreModal.tsx
import { type SetData } from '../../types';
import { Clock, Eraser, MapPin, Trophy } from 'lucide-react';
import { useState } from 'react';
import Modal from '../UI/Modal';
import WhistleIcon from "../../assets/whistle.svg?react"
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);
interface MatchData {
id: string | number;
number: number;
time: string;
court: string;
p1?: string;
p1_label?: string;
p2?: string;
p2_label?: string;
ref_label?: string;
ref_team?: { name: string };
isFinished: boolean;
sets?: SetData[];
}
interface ScoreFormProps {
match: MatchData;
isAuthenticated: boolean;
onSubmit: (id: string | number, sets: SetData[], code: string) => Promise<void>;
onClear: (id: string | number, code: string) => Promise<void>;
}
const ScoreForm = ({ match, isAuthenticated, onSubmit, onClear }: ScoreFormProps) => {
const [sets, setSets] = useState<SetData[]>(match.sets && match.sets.length ? match.sets : [{ p1: '', p2: '' }]);
const [code, setCode] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const refName = match.ref_team?.name || match.ref_label;
const handleSubmit = async () => {
try { await onSubmit(match.id, sets, code); }
catch (err) { setError(typeof err.detail === 'string' ? err.detail : "Check code or scores"); }
try {
await onSubmit(match.id, sets, code);
} catch (err: unknown) {
const error = err as { detail?: string };
setError(typeof error?.detail === 'string' ? error.detail : "Check code or scores");
}
};
const removeSet = (idx) => {
const removeSet = (idx: number) => {
if (sets.length > 1) setSets(sets.filter((_, i) => i !== idx));
};
const updateSet = (idx, field, val) => {
const updateSet = (idx: number, field: keyof SetData, val: string) => {
const n = [...sets];
n[idx][field] = parseInt(val) || 0;
setSets(n);
@@ -27,26 +55,38 @@ const ScoreForm = ({ match, isAdmin, onSubmit, onClear }) => {
return (
<div className="space-y-6">
{/* Top Info Bar */}
<div className="flex flex-col gap-2">
{/* Time & Court Row */}
<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} />
<Clock className="text-orange-500" size={16} />
<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} />
<MapPin className="text-orange-500" size={16} />
<span>{match.court || "TBD"}</span>
</div>
</div>
{/* Dedicated Ref Row (Always visible) */}
{refName && (
<div className="flex justify-center items-center gap-2 bg-orange-50 dark:bg-orange-900/10 p-2.5 rounded-lg border border-orange-100 dark:border-orange-900/30">
<WhistleIcon className="text-orange-500 shrink-0" width={14} height={14} />
<span className="text-xs font-bold text-orange-700 dark:text-orange-500 uppercase tracking-wide truncate">
{refName}
</span>
</div>
)}
</div>
{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>
)}
{!isAdmin && (
{!isAuthenticated && (
<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
@@ -61,9 +101,9 @@ const ScoreForm = ({ match, isAdmin, onSubmit, onClear }) => {
)}
<div className="grid grid-cols-3 gap-2 text-center font-bold text-zinc-700 dark:text-zinc-200 items-center px-2">
<div className="break-words text-sm leading-tight uppercase">{match.p1 || match.p1_label}</div>
<div className="wrap-break-word text-sm leading-tight uppercase">{match.p1 || match.p1_label}</div>
<div className="text-zinc-400 dark:text-zinc-600 text-[10px] font-bold uppercase bg-zinc-100 dark:bg-zinc-950 px-3 py-1 rounded-full w-fit mx-auto shadow-sm">VS</div>
<div className="break-words text-sm leading-tight uppercase">{match.p2 || match.p2_label}</div>
<div className="wrap-break-word text-sm leading-tight uppercase">{match.p2 || match.p2_label}</div>
</div>
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
@@ -134,14 +174,23 @@ const ScoreForm = ({ match, isAdmin, onSubmit, onClear }) => {
);
};
export default function ScoreModal({ isOpen, onClose, match, isAdmin, onSubmit, onClear }) {
interface ScoreModalProps {
isOpen: boolean;
onClose: () => void;
match: MatchData | null;
isAuthenticated: boolean;
onSubmit: (id: string | number, sets: SetData[], code: string) => Promise<void>;
onClear: (id: string | number, code: string) => Promise<void>;
}
export default function ScoreModal({ isOpen, onClose, match, isAuthenticated, onSubmit, onClear }: ScoreModalProps) {
if (!isOpen || !match) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} title={`Match #${match.number}`} icon={Trophy}>
<ScoreForm
match={match}
isAdmin={isAdmin}
isAuthenticated={isAuthenticated}
onSubmit={async (id, sets, code) => {
await onSubmit(id, sets, code);
onClose();
@@ -1,15 +1,25 @@
// frontend/src/components/UI/Modal.jsx
// frontend/src/components/UI/Modal.tsx
import { X } from 'lucide-react';
import { type LucideIcon, X } from 'lucide-react';
import React from 'react';
export default function Modal({ isOpen, onClose, title, icon: Icon, children }) {
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
icon?: LucideIcon;
children: React.ReactNode;
}
export default function Modal({ isOpen, onClose, title, icon: Icon, children }: ModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/75 backdrop-blur-sm animate-in fade-in duration-200">
<div className="fixed inset-0 z-200 flex items-center justify-center bg-black/75 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-lg border border-zinc-300 dark:border-zinc-800 max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-black text-zinc-900 dark:text-white flex items-start gap-2">
{/* @ts-expect-error - 'weight' is a specific prop if using Phosphor icons, but Lucide doesn't natively use it. */}
{Icon && <Icon weight="duotone" className="text-orange-500 mt-1 shrink-0" size={24} />}
<span>{title}</span>
</h2>
@@ -1,14 +1,19 @@
// frontend/src/components/UI/ThemeButton.jsx
// frontend/src/components/UI/ThemeButton.tsx
import { Moon, Sun } from 'lucide-react';
export default function ThemeButton({ darkMode, setDarkMode }) {
interface ThemeButtonProps {
darkMode: boolean;
setDarkMode: (value: boolean) => void;
}
export default function ThemeButton({ darkMode, setDarkMode }: ThemeButtonProps) {
return (
<div className="fixed bottom-6 sm:bottom-8 right-6 sm:right-8 z-40 print:hidden">
<button
onClick={() => setDarkMode(!darkMode)}
title="Toggle Theme"
className="p-4 sm:p-5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-[1.5rem] sm:rounded-[2rem] shadow-2xl transition hover:scale-110 active:scale-95 border-2 border-zinc-700 dark:border-zinc-300 group"
className="p-4 sm:p-5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-3xl sm:rounded-4xl shadow-2xl transition hover:scale-110 active:scale-95 border-2 border-zinc-700 dark:border-zinc-300 group"
>
{darkMode ? <Sun size={24} strokeWidth={2.5} className="sm:size-7" /> : <Moon size={24} strokeWidth={2.5} className="sm:size-7" />}
</button>
+1
View File
@@ -1,4 +1,5 @@
/* frontend/src/index.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
-11
View File
@@ -1,11 +0,0 @@
// frontend/src/main.jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
+12
View File
@@ -0,0 +1,12 @@
// frontend/src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
@@ -1,52 +1,74 @@
// frontend/src/pages/Dashboard.jsx
// frontend/src/pages/Dashboard.tsx
import { Calendar, ChevronDown, ChevronUp, History, Plus } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Calendar, ChevronDown, ChevronUp, History, Plus, PlusCircle, SlidersHorizontal } from 'lucide-react';
import { useEffect, useState, useCallback } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom';
import DashCard from '../components/Dashboard/DashCard';
import TournamentForm from '../components/Forms/TournamentForm';
import Modal from '../components/UI/Modal';
import api, { WS_URL } from '../services/api';
import { SlidersHorizontal, PlusCircle } from 'lucide-react';
export interface TournamentType {
id: string | number;
name: string;
timestamp: string;
type: string;
team_count: number;
court_count: number;
}
interface OutletContextType {
setNavTitle: (title: string) => void;
setNavSubtitle: (subtitle: string) => void;
role: 'admin' | 'ref' | null;
showSettings: boolean;
setShowSettings: (show: boolean) => void;
}
export default function Dashboard() {
const { setNavTitle, setNavSubtitle, isAdmin, showSettings, setShowSettings } = useOutletContext();
const { setNavTitle, setNavSubtitle, role, showSettings, setShowSettings } = useOutletContext<OutletContextType>();
const [tournaments, setTournaments] = useState([]);
const [editTarget, setEditTarget] = useState(null);
const [showPast, setShowPast] = useState(false);
const [showAllFuture, setShowAllFuture] = useState(false);
const [tournaments, setTournaments] = useState<TournamentType[]>([]);
const [editTarget, setEditTarget] = useState<TournamentType | null>(null);
const [showPast, setShowPast] = useState<boolean>(false);
const [showAllFuture, setShowAllFuture] = useState<boolean>(false);
const navigate = useNavigate();
const loadDashboard = async () => {
const loadDashboard = useCallback(async () => {
try {
const res = await api.get('/tournaments');
const list = Array.isArray(res) ? res : (res.items || []);
setTournaments(list);
} catch (e) { console.error(e); }
};
const res = await api.get<{ items?: TournamentType[] } | TournamentType[]>('/tournaments');
const list = Array.isArray(res) ? res : (res?.items || []);
setTournaments(list as TournamentType[]);
} catch (e) {
console.error(e);
}
}, []);
useEffect(() => {
setNavTitle('Dashboard');
setNavSubtitle('');
loadDashboard();
void loadDashboard();
localStorage.removeItem('volley_view');
let ws;
let ws: WebSocket;
const connect = () => {
try {
ws = new WebSocket(WS_URL);
ws.onmessage = (e) => {
ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data);
if (msg.type === 'dashboard_update') loadDashboard();
};
} catch (err) { }
} catch (err) {
console.error("WebSocket connection failed", err);
}
};
connect();
return () => { if (ws) ws.close(); };
}, []);
const handleEdit = (t) => {
return () => { if (ws) ws.close(); };
}, [setNavTitle, setNavSubtitle, loadDashboard]);
const handleEdit = (t: TournamentType) => {
setEditTarget(t);
setShowSettings(true);
};
@@ -57,28 +79,32 @@ export default function Dashboard() {
loadDashboard();
};
const handleDelete = async (id) => {
const handleDelete = async (id: string | number) => {
if (window.confirm("Purge this tournament and all its history?")) {
await api.delete(`/tournaments/${id}`);
handleSuccess();
}
};
// Grouping Logic
const now = new Date();
const groups = { live: [], future: [], past: [] };
const groups: { live: TournamentType[]; future: TournamentType[]; past: TournamentType[] } = {
live: [],
future: [],
past: []
};
tournaments.forEach(t => {
const tDate = new Date(t.timestamp);
const isToday = tDate.toDateString() === now.toDateString();
if (tDate > now && !isToday) groups.future.push(t);
else if (isToday) groups.live.push(t);
else groups.past.push(t);
});
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));
groups.future.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
groups.live.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
groups.past.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
const futureShow = showAllFuture ? groups.future : groups.future.slice(0, 4);
@@ -86,8 +112,7 @@ export default function Dashboard() {
<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 */}
{isAdmin && (
{role === 'admin' && (
<div className="flex justify-center md:justify-end">
<button onClick={() => { setEditTarget(null); setShowSettings(true); }} className="bg-orange-600 hover:bg-orange-500 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 text-[10px] font-black uppercase tracking-wider shadow-xl shadow-orange-600/20 active:scale-95 transition">
<Plus size={16} strokeWidth={4} /> Create
@@ -98,13 +123,13 @@ export default function Dashboard() {
{groups.live.length > 0 && (
<section>
<h2 className="text-xs font-black text-green-500 uppercase tracking-[0.2em] mb-6 flex items-center gap-3">
<span class="relative flex size-3">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex size-3 rounded-full bg-green-500"></span>
<span className="relative flex size-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex size-3 rounded-full bg-green-500"></span>
</span> 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} onSelect={(id) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)}
{groups.live.map(t => <DashCard key={t.id} t={t} isAdmin={role === 'admin'} onSelect={(id) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)}
</div>
</section>
)}
@@ -116,11 +141,11 @@ export default function Dashboard() {
{groups.future.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{futureShow.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onSelect={(id) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)}
{futureShow.map(t => <DashCard key={t.id} t={t} isAdmin={role === 'admin'} onSelect={(id) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)}
</div>
{groups.future.length > 4 && (
<div className="mt-8 text-center">
<button onClick={() => setShowAllFuture(!showAllFuture)} className="text-xs font-black uppercase tracking-[0.1em] text-zinc-500 hover:text-orange-500 transition border-b-2 border-transparent hover:border-orange-500 pb-1 flex items-center justify-center gap-1 mx-auto">
<button onClick={() => setShowAllFuture(!showAllFuture)} className="text-xs font-black uppercase tracking-widest text-zinc-500 hover:text-orange-500 transition border-b-2 border-transparent hover:border-orange-500 pb-1 flex items-center justify-center gap-1 mx-auto">
{showAllFuture ? 'Show Less' : `Show All (${groups.future.length})`}
{showAllFuture ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
@@ -138,7 +163,7 @@ export default function Dashboard() {
</button>
{showPast && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 opacity-75 hover:opacity-100 transition-opacity">
{groups.past.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} onSelect={(id) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)}
{groups.past.map(t => <DashCard key={t.id} t={t} isAdmin={role === 'admin'} onSelect={(id) => navigate(`/tournaments/${id}`)} onEdit={handleEdit} />)}
</div>
)}
</section>
@@ -1,26 +1,32 @@
// frontend/src/pages/Login.jsx
// frontend/src/pages/Login.tsx
import { Loader2, Volleyball } from 'lucide-react';
import { useState } from 'react';
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import api from '../services/api';
interface TokenResponse {
access_token: string;
token_type?: string;
}
export default function Login() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const handleSubmit = async (e) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.target);
const formData = new FormData(e.currentTarget);
try {
const res = await api.postForm('/auth/token', formData);
const res = await api.postForm<TokenResponse>('/auth/token', formData);
localStorage.setItem('volleyToken', res.access_token);
navigate('/');
} catch (err) {
} catch {
setError('Invalid credentials.');
} finally {
setLoading(false);
@@ -37,8 +43,12 @@ 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 administrative credentials</p>
<h1 className="text-2xl font-black text-center text-zinc-900 dark:text-white tracking-tight mb-2">
Staff Login
</h1>
<p className="text-center text-zinc-500 text-sm font-medium mb-8">
Enter staff 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">
@@ -1,7 +1,8 @@
// frontend/src/pages/Tournament.jsx
// frontend/src/pages/Tournament.tsx
import { type SetData } from '../types';
import { CalendarDays, Loader2, Network, SlidersHorizontal } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useOutletContext, useParams } from 'react-router-dom';
import BracketView from '../components/Bracket/BracketView';
import TournamentForm from '../components/Forms/TournamentForm';
@@ -10,23 +11,67 @@ import ScoreModal from '../components/Tournament/ScoreModal';
import Modal from '../components/UI/Modal';
import api, { WS_URL } from '../services/api';
// --- Type Definitions ---
interface Team { id: string | number; name: string; }
interface Court { id: string | number; name: string; }
interface RawMatch {
id: string | number;
bracket_type: string;
round_number: number;
match_number: number;
winner_next_match_id?: string | number | null;
loser_next_match_id?: string | number | null;
p1_team_id?: string | number | null;
p2_team_id?: string | number | null;
winner_team_id?: string | number | null;
status: string;
court_id: string | number;
start_time?: string;
sets?: { p1: number; p2: number }[];
}
export interface ProcessedMatch extends RawMatch {
bracket: string;
round: number;
number: number;
p1: string;
p2: string;
winnerName: string | null;
p1_is_real: boolean;
p2_is_real: boolean;
isReady: boolean;
court: string;
time: string;
p1_sets: number;
p2_sets: number;
hasTeams: boolean;
isFinished: boolean;
}
interface OutletContextType {
setNavTitle: (title: string) => void;
setNavSubtitle: (subtitle: string) => void;
role: 'admin' | 'ref' | null;
}
export default function Tournament() {
const { id } = useParams();
const { setNavTitle, setNavSubtitle, isAdmin } = useOutletContext();
const { id } = useParams<{ id: string }>();
const { setNavTitle, setNavSubtitle, role } = useOutletContext<OutletContextType>();
const [matches, setMatches] = useState([]);
const [view, setView] = useState(() => localStorage.getItem('volley_view') || 'bracket');
const [loading, setLoading] = useState(true);
const [showSettings, setShowSettings] = useState(false);
const [scoreMatch, setScoreMatch] = useState(null);
const wsRef = useRef(null);
const [matches, setMatches] = useState<ProcessedMatch[]>([]);
const [view, setView] = useState<string>(() => localStorage.getItem('volley_view') || 'bracket');
const [loading, setLoading] = useState<boolean>(true);
const [showSettings, setShowSettings] = useState<boolean>(false);
const [scoreMatch, setScoreMatch] = useState<ProcessedMatch | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const handleViewChange = (newView) => {
const handleViewChange = (newView: string) => {
setView(newView);
localStorage.setItem('volley_view', newView);
};
const processMatches = (rawMatches, courts, teams) => {
const processMatches = (rawMatches: RawMatch[], courts: Court[], teams: Team[]): ProcessedMatch[] => {
if (!rawMatches) return [];
const gf1 = rawMatches.find(m =>
@@ -35,15 +80,16 @@ export default function Tournament() {
m.winner_next_match_id === m.loser_next_match_id
);
let skippedResetMatchId = null;
let skippedResetMatchId: string | number | null = null;
if (gf1 && gf1.status === 'Finished' && gf1.winner_team_id === gf1.p1_team_id) {
skippedResetMatchId = gf1.winner_next_match_id;
skippedResetMatchId = gf1.winner_next_match_id || null;
}
const courtMap = Object.fromEntries(courts.map(c => [c.id, c.name]));
const teamMap = Object.fromEntries(teams.map(t => [t.id, t.name]));
const courtMap: Record<string | number, string> = Object.fromEntries(courts.map(c => [c.id, c.name]));
const teamMap: Record<string | number, string> = Object.fromEntries(teams.map(t => [t.id, t.name]));
const incoming: Record<string | number, { label: string; id: string | number }[]> = {};
const incoming = {};
rawMatches.forEach(m => {
const num = m.match_number;
if (m.winner_next_match_id) {
@@ -89,32 +135,42 @@ export default function Tournament() {
});
};
const fetchData = async () => {
const fetchData = React.useCallback(async () => {
try {
const res = await api.get(`/tournaments/${id}`);
const res = await api.get<{ name: string, timestamp: string, matches: RawMatch[], courts: Court[], teams: Team[] }>(`/tournaments/${id}`);
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); }
};
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, [id, setNavTitle, setNavSubtitle]);
useEffect(() => {
fetchData();
void fetchData();
if (wsRef.current) return;
const connect = () => {
const ws = new WebSocket(WS_URL);
wsRef.current = ws;
ws.onmessage = (e) => {
ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data);
if (msg.type === 'tournament_update' && msg.id === id) fetchData();
if (msg.type === 'tournament_update' && msg.id === id) void fetchData();
};
ws.onclose = () => { wsRef.current = null; };
};
connect();
return () => { if (wsRef.current?.readyState === 1) wsRef.current.close(); wsRef.current = null; };
}, [id]);
const handleDeleteTournament = async (tId) => {
connect();
return () => {
if (wsRef.current?.readyState === 1) wsRef.current.close();
wsRef.current = null;
};
}, [id, fetchData]);
const handleDeleteTournament = async (tId: string | number) => {
if (window.confirm("Purge this tournament?")) {
await api.delete(`/tournaments/${tId}`);
window.location.href = '/';
@@ -134,13 +190,17 @@ export default function Tournament() {
<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"><SlidersHorizontal size={20} /></button>}
{role === 'admin' && (
<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">
<SlidersHorizontal size={20} />
</button>
)}
</div>
<div className="flex-1 overflow-hidden relative print:overflow-visible print:h-auto print:block">
{view === 'bracket'
? <BracketView matches={matches} onMatchClick={setScoreMatch} />
: <ScheduleView schedule={matches} onMatchClick={setScoreMatch} />
? <BracketView matches={matches} onMatchClick={(m) => setScoreMatch(m as ProcessedMatch)} />
: <ScheduleView schedule={matches} onMatchClick={(m) => setScoreMatch(m as ProcessedMatch)} />
}
</div>
@@ -149,12 +209,12 @@ export default function Tournament() {
isOpen={!!scoreMatch}
onClose={() => setScoreMatch(null)}
match={scoreMatch}
isAdmin={isAdmin}
onClear={async (mid, c) => {
isAuthenticated={!!role}
onClear={async (mid: string | number, c: string) => {
await api.delete(`/tournaments/${id}/matches/${mid}/score?code=${encodeURIComponent(c || '')}`);
setScoreMatch(null);
}}
onSubmit={async (mid, s, c) => {
onSubmit={async (mid: string | number, s: SetData[], c: string) => {
const method = scoreMatch.isFinished ? 'patch' : 'post';
await api[method](`/tournaments/${id}/matches/${mid}/score`, { sets: s, code: c });
setScoreMatch(null);
-48
View File
@@ -1,48 +0,0 @@
// frontend/src/services/api.js
const getBackendHost = () => {
const host = window.location.hostname || 'localhost';
return window.location.protocol === 'https:' ? host : `${host}:8000`;
};
export const API_BASE = `${window.location.protocol}//${getBackendHost()}/api`;
export const WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${getBackendHost()}/api/ws`;
export const getToken = () => localStorage.getItem('volleyToken');
const api = {
request: async (method, url, data = null, isFormData = false) => {
const headers = {};
const token = getToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
if (!isFormData) headers['Content-Type'] = 'application/json';
const opts = { method, headers };
if (data) opts.body = isFormData ? data : JSON.stringify(data);
// Ensure clean URL concatenation
const baseUrl = API_BASE.replace(/\/$/, '');
const endpoint = url.startsWith('/') ? url : `/${url}`;
const res = await fetch(`${baseUrl}${endpoint}`, opts);
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem('volleyToken');
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
const errorData = await res.json().catch(() => ({ detail: 'An error occurred' }));
throw errorData;
}
return res.json();
},
get: (url) => api.request('GET', url),
post: (url, data) => api.request('POST', url, data),
postForm: (url, data) => api.request('POST', url, data, true),
put: (url, data) => api.request('PUT', url, data),
patch: (url, data) => api.request('PATCH', url, data),
delete: (url) => api.request('DELETE', url)
};
export default api;
+66
View File
@@ -0,0 +1,66 @@
// frontend/src/services/api.ts
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
const getBackendHost = (): string => {
const host: string = window.location.hostname || 'localhost';
return window.location.protocol === 'https:' ? host : `${host}:8000`;
};
export const API_BASE: string = `${window.location.protocol}//${getBackendHost()}/api`;
export const WS_URL: string = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${getBackendHost()}/api/ws`;
export const getToken = (): string | null => localStorage.getItem('volleyToken');
const api = {
request: async <T>(
method: HttpMethod,
url: string,
data: unknown = null,
isFormData: boolean = false
): Promise<T> => {
const headers: Record<string, string> = {};
const token = getToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
if (!isFormData) headers['Content-Type'] = 'application/json';
const opts: RequestInit = {
method,
headers
};
if (data) {
opts.body = isFormData ? (data as FormData) : JSON.stringify(data);
}
// Ensure clean URL concatenation
const baseUrl = API_BASE.replace(/\/$/, '');
const endpoint = url.startsWith('/') ? url : `/${url}`;
const res = await fetch(`${baseUrl}${endpoint}`, opts);
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem('volleyToken');
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
// Attempt to parse error detail, fallback to generic message
const errorData = await res.json().catch(() => ({ detail: 'An error occurred' }));
throw errorData;
}
return res.json() as Promise<T>;
},
get: <T>(url: string) => api.request<T>('GET', url),
post: <T>(url: string, data?: unknown) => api.request<T>('POST', url, data),
postForm: <T>(url: string, data: FormData) => api.request<T>('POST', url, data, true),
put: <T>(url: string, data?: unknown) => api.request<T>('PUT', url, data),
patch: <T>(url: string, data?: unknown) => api.request<T>('PATCH', url, data),
delete: <T>(url: string) => api.request<T>('DELETE', url)
};
export default api;
+41
View File
@@ -0,0 +1,41 @@
// frontend/src/types.ts
export interface MatchData {
id: string | number;
number: number;
round: number;
bracket: string;
time: string;
court: string;
status: string;
hasTeams: boolean;
isReady: boolean;
isFinished: boolean;
p1: string;
p2: string;
p1_sets: number;
p2_sets: number;
p1_is_real: boolean;
p2_is_real: boolean;
winnerName: string | null;
bracket_type?: string;
round_number?: number;
match_number?: number;
court_id?: string | number;
winner_team_id?: string | number | null;
p1_team_id?: string | number | null;
p2_team_id?: string | number | null;
winner_next_match_id?: string | number | null;
loser_next_match_id?: string | number | null;
timestamp?: string;
start_time?: string;
ref_label?: string;
ref_team?: { id: string | number; name: string };
}
export interface SetData {
p1: number | string;
p2: number | string;
}
-19
View File
@@ -1,19 +0,0 @@
// frontend/src/utils/helpers.js
export const stringToColor = (str) => {
if (!str) return '#71717a';
const normalized = str.trim().toLowerCase();
const salt = 'volley-standard-salt-v5';
const COURT_COLORS = ['#ea580c', '#0284c7', '#059669', '#ca8a04', '#dc2626', '#0891b2', '#e11d48', '#65a30d'];
let hash = 0;
const combined = normalized + salt;
for (let i = 0; i < combined.length; i++) hash = combined.charCodeAt(i) + ((hash << 5) - hash);
return COURT_COLORS[Math.abs(hash) % COURT_COLORS.length];
};
export const printName = (name) => {
if (!name) return '';
if (name.startsWith('Winner of #')) return name.replace('Winner of #', 'W') + ': _______________';
if (name.startsWith('Loser of #')) return name.replace('Loser of #', 'L') + ': _______________';
return name;
};
+25
View File
@@ -0,0 +1,25 @@
// frontend/src/utils/helpers.ts
export const stringToColor = (str: string | null | undefined): string => {
if (!str) return '#71717a';
const normalized: string = str.trim().toLowerCase();
const salt: string = 'volley-standard-salt-v5';
const COURT_COLORS: string[] = ['#ea580c', '#0284c7', '#059669', '#ca8a04', '#dc2626', '#0891b2', '#e11d48', '#65a30d'];
let hash: number = 0;
const combined: string = normalized + salt;
for (let i = 0; i < combined.length; i++) {
hash = combined.charCodeAt(i) + ((hash << 5) - hash);
}
return COURT_COLORS[Math.abs(hash) % COURT_COLORS.length];
};
export const printName = (name: string | null | undefined): string => {
if (!name) return '';
if (name.startsWith('Winner of #')) return name.replace('Winner of #', 'W') + ': _______________';
if (name.startsWith('Loser of #')) return name.replace('Loser of #', 'L') + ': _______________';
return name;
};
+4
View File
@@ -0,0 +1,4 @@
// frontend/src/vite-env.d.ts
/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />
+35
View File
@@ -0,0 +1,35 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"types": [
"vite/client",
"vitest/globals"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"src"
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
-20
View File
@@ -1,20 +0,0 @@
// frontend/vite.config.js
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import path from 'path'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './vitest.setup.js',
css: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})
+50
View File
@@ -0,0 +1,50 @@
// frontend/vite.config.ts
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
import { VitePWA } from 'vite-plugin-pwa';
import svgr from 'vite-plugin-svgr';
export default defineConfig({
plugins: [
react(),
tailwindcss(),
svgr(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg'],
manifest: {
name: 'VolleyManager',
short_name: 'VolleyManager',
description: 'Tournament Operations Manager',
theme_color: '#09090b',
background_color: '#fafafa',
display: 'standalone',
orientation: 'portrait-primary',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable',
},
],
}
})
],
test: {
globals: true,
environment: 'jsdom',
},
})
-3
View File
@@ -1,3 +0,0 @@
// frontend/vitest.setup.js
import '@testing-library/jest-dom/vitest';