Compare commits
@@ -0,0 +1,80 @@
|
|||||||
|
name: Test and Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["main", "master"]
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
- name: Test with pytest
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
pip install pytest pytest-cov
|
||||||
|
pytest
|
||||||
|
|
||||||
|
test-frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20.x"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
- name: Test with NPM
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm test -- run --exclude "**/*.live.test.ts"
|
||||||
|
|
||||||
|
build-docker:
|
||||||
|
needs: [test-backend, test-frontend]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'release'
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
service: [backend, frontend]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Log in to Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.stws.cc
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: git.stws.cc/${{ github.repository }}/${{ matrix.service }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=tag
|
||||||
|
type=raw,value=latest
|
||||||
|
- name: Build and Push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./${{ matrix.service }}
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -51,7 +51,12 @@ workflow:
|
|||||||
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
|
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
|
||||||
script:
|
script:
|
||||||
- cd backend
|
- cd backend
|
||||||
- docker build -t $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_TAG -t $CI_REGISTRY_IMAGE/backend:latest .
|
- >
|
||||||
|
docker build
|
||||||
|
--label "org.opencontainers.image.url=$CI_PROJECT_URL"
|
||||||
|
--label "org.opencontainers.image.source=$CI_PROJECT_URL"
|
||||||
|
-t $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_TAG
|
||||||
|
-t $CI_REGISTRY_IMAGE/backend:latest .
|
||||||
- docker push $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_TAG
|
- docker push $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_TAG
|
||||||
- docker push $CI_REGISTRY_IMAGE/backend:latest
|
- docker push $CI_REGISTRY_IMAGE/backend:latest
|
||||||
|
|
||||||
@@ -64,6 +69,11 @@ workflow:
|
|||||||
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
|
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
|
||||||
script:
|
script:
|
||||||
- cd frontend
|
- cd frontend
|
||||||
- docker build -t $CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_TAG -t $CI_REGISTRY_IMAGE/frontend:latest .
|
- >
|
||||||
|
docker build
|
||||||
|
--label "org.opencontainers.image.url=$CI_PROJECT_URL"
|
||||||
|
--label "org.opencontainers.image.source=$CI_PROJECT_URL"
|
||||||
|
-t $CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_TAG
|
||||||
|
-t $CI_REGISTRY_IMAGE/frontend:latest .
|
||||||
- docker push $CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_TAG
|
- docker push $CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_TAG
|
||||||
- docker push $CI_REGISTRY_IMAGE/frontend:latest
|
- docker push $CI_REGISTRY_IMAGE/frontend:latest
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
"backend"
|
"backend"
|
||||||
],
|
],
|
||||||
"python.testing.unittestEnabled": false,
|
"python.testing.unittestEnabled": false,
|
||||||
"python.testing.pytestEnabled": true
|
"python.testing.pytestEnabled": true,
|
||||||
|
"python-envs.defaultEnvManager": "ms-python.python:venv"
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
<br>
|
<br>
|
||||||
|
|
||||||
<img src="https://img.shields.io/badge/Python-3.14-blue?style=for-the-badge&logo=python" height="25">
|
<img src="https://img.shields.io/badge/Python-3.14-blue?style=for-the-badge&logo=python" height="25">
|
||||||
<img src="https://img.shields.io/badge/Flask-3.1.2-lightgrey?style=for-the-badge&logo=flask" height="25">
|
<img src="https://img.shields.io/badge/Flask-3.1.3-lightgrey?style=for-the-badge&logo=flask" height="25">
|
||||||
<img src="https://img.shields.io/badge/Tailwind_CSS-4.1.17-38B2AC?style=for-the-badge&logo=tailwind-css" height="25">
|
<img src="https://img.shields.io/badge/Tailwind_CSS-4.1.18-38B2AC?style=for-the-badge&logo=tailwind-css" height="25">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -20,15 +20,17 @@
|
|||||||
|
|
||||||
The central hub for managing tournaments.
|
The central hub for managing tournaments.
|
||||||
|
|
||||||
<img src="./docs/dashboard-admin.png" width="100%" alt="Dashboard Admin">
|
<img src="./docs/bracket-light.png" width="100%" alt="Brackets">
|
||||||
|
|
||||||
### Appearance Modes
|
---
|
||||||
|
|
||||||
Seamless support for both light and dark environments.
|
### Mobile Friendly
|
||||||
|
|
||||||
|
Seamless support for both PC and mobile users.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="./docs/dashboard-dark.png" width="48%" alt="Dark Mode">
|
<img src="./docs/dashboard.png" width="70%" alt="Dashboard">
|
||||||
<img src="./docs/dashboard-light.png" width="48%" alt="Light Mode">
|
<img src="./docs/mobile-dashboard.png" width="16%" alt="Mobile Friendly">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -38,12 +40,13 @@ Seamless support for both light and dark environments.
|
|||||||
### Setup & Creation
|
### Setup & Creation
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="./docs/settings.png" height="400px" alt="Settings">
|
<img src="./docs/tournament-new.png" width="48%" alt="New Tournament">
|
||||||
|
<img src="./docs/tournament-edit.png" width="48%" alt="Edit Tournament">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### Bracket Visualization
|
### Bracket Visualization
|
||||||
|
|
||||||
Dynamic bracket generation handling various tournament sizes.
|
Dynamic bracket generation handling various tournament sizes and with seamless support for both light and dark environments.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="./docs/bracket-dark.png" width="48%" alt="Bracket Dark">
|
<img src="./docs/bracket-dark.png" width="48%" alt="Bracket Dark">
|
||||||
@@ -55,8 +58,8 @@ Dynamic bracket generation handling various tournament sizes.
|
|||||||
**Single Elimination & Live Scoring**
|
**Single Elimination & Live Scoring**
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="./docs/bracket-single.png" width="48%" alt="Single Elim">
|
<img src="./docs/single-elimination.png" width="48%" alt="Single Elimination">
|
||||||
<img src="./docs/bracket-scoring.png" width="48%" alt="Scoring">
|
<img src="./docs/scoring.png" width="48%" alt="Scoring">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -64,8 +67,17 @@ Dynamic bracket generation handling various tournament sizes.
|
|||||||
## Scheduling & Results
|
## Scheduling & Results
|
||||||
|
|
||||||
Automated scheduling views with real-time score updates.
|
Automated scheduling views with real-time score updates.
|
||||||
|
<div align="center">
|
||||||
|
<img src="./docs/schedule.png" width="70%" alt="Schedule">
|
||||||
|
<img src="./docs/mobile-schedule.png" width="16%" alt="Schedule - Mobile Friendly">
|
||||||
|
</div>
|
||||||
|
|
||||||
<img src="./docs/schedule.png" width="100%" alt="Schedule">
|
---
|
||||||
|
|
||||||
|
## Court Overview
|
||||||
|
|
||||||
|
Keep up with all courts all at once in one place.
|
||||||
|
<img src="./docs/courts.png" width="100%" alt="Schedule">
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -102,6 +114,11 @@ Secure scoring interfaces for Referees and Administrators.
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
## Admin login
|
||||||
|
|
||||||
|
Secure settings behind a admin login.
|
||||||
|
<img src="./docs/admin.png" width="100%" alt="Dashboard Admin">
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
Clone the repo and run the docker-compose or create your own. Create a .env file with your custom values there is an example.env available.
|
Clone the repo and run the docker-compose or create your own. Create a .env file with your custom values there is an example.env available.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from fastapi.security import OAuth2PasswordBearer
|
|||||||
import jwt
|
import jwt
|
||||||
from jwt.exceptions import PyJWTError
|
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 = OAuth2PasswordBearer(tokenUrl="auth/token")
|
||||||
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="auth/token", auto_error=False)
|
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)
|
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||||
|
|
||||||
to_encode.update({"exp": expire})
|
to_encode.update({"exp": expire})
|
||||||
|
|
||||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
return encoded_jwt
|
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(
|
credentials_exception = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Could not validate credentials",
|
detail="Could not validate credentials",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
username = payload.get("sub")
|
role = payload.get("role")
|
||||||
|
if role not in ["admin", "ref"]:
|
||||||
if username is None or username != ADMIN_USER:
|
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
|
return payload
|
||||||
except PyJWTError:
|
except PyJWTError:
|
||||||
raise credentials_exception
|
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(
|
async def get_optional_user(
|
||||||
token: Optional[str] = Depends(oauth2_scheme_optional),
|
token: Optional[str] = Depends(oauth2_scheme_optional),
|
||||||
) -> Optional[str]:
|
) -> Optional[dict]:
|
||||||
|
"""Returns user payload if valid token exists, else None"""
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
username = payload.get("sub")
|
if payload.get("role") in ["admin", "ref"]:
|
||||||
if username == ADMIN_USER:
|
return payload
|
||||||
return username
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 hours
|
|||||||
ADMIN_USER = os.getenv("ADMIN_USER", "admin")
|
ADMIN_USER = os.getenv("ADMIN_USER", "admin")
|
||||||
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "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()
|
password_hash = PasswordHash.recommended()
|
||||||
ADMIN_HASH = password_hash.hash(ADMIN_PASSWORD)
|
ADMIN_HASH = password_hash.hash(ADMIN_PASSWORD)
|
||||||
|
REF_HASH = password_hash.hash(REF_PASSWORD)
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password, hashed_password):
|
def verify_password(plain_password, hashed_password):
|
||||||
|
|||||||
@@ -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}')
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# backend/app/crud.py
|
# backend/app/crud.py
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from . import logic, models, schemas
|
from . import logic, models, schemas
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ def create_tournament(db: Session, data: schemas.TournamentCreate):
|
|||||||
type=data.type,
|
type=data.type,
|
||||||
)
|
)
|
||||||
new_t.teams = [models.Team(name=n) for n in data.teams]
|
new_t.teams = [models.Team(name=n) for n in data.teams]
|
||||||
new_t.courts = [models.Court(name=n) for n in data.courts]
|
new_t.courts = db.query(models.Court).filter(models.Court.id.in_(data.courts)).all()
|
||||||
|
|
||||||
db.add(new_t)
|
db.add(new_t)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -45,17 +45,14 @@ def create_tournament(db: Session, data: schemas.TournamentCreate):
|
|||||||
def get_tournaments(db: Session):
|
def get_tournaments(db: Session):
|
||||||
return db.query(models.Tournament).all()
|
return db.query(models.Tournament).all()
|
||||||
|
|
||||||
|
def get_tournament(db: Session, tournament_id: str, lock: bool = False):
|
||||||
def get_tournament(db: Session, tournament_id: str):
|
query = db.query(models.Tournament).filter(models.Tournament.id == tournament_id)
|
||||||
return (
|
if lock:
|
||||||
db.query(models.Tournament)
|
query = query.with_for_update()
|
||||||
.filter(models.Tournament.id == tournament_id)
|
return query.first()
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_tournament(db: Session, tournament_id: str) -> bool:
|
def delete_tournament(db: Session, tournament_id: str) -> bool:
|
||||||
t = get_tournament(db, tournament_id)
|
t = get_tournament(db, tournament_id, lock=True)
|
||||||
if not t:
|
if not t:
|
||||||
return False
|
return False
|
||||||
db.delete(t)
|
db.delete(t)
|
||||||
@@ -66,7 +63,7 @@ def delete_tournament(db: Session, tournament_id: str) -> bool:
|
|||||||
def update_tournament_details(
|
def update_tournament_details(
|
||||||
db: Session, tournament_id: str, data: schemas.TournamentUpdate
|
db: Session, tournament_id: str, data: schemas.TournamentUpdate
|
||||||
):
|
):
|
||||||
t = get_tournament(db, tournament_id)
|
t = get_tournament(db, tournament_id, lock=True)
|
||||||
if not t:
|
if not t:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -94,7 +91,7 @@ def get_teams(db: Session, tournament_id: str):
|
|||||||
|
|
||||||
|
|
||||||
def create_team(db: Session, tournament_id: str, team_data: schemas.TeamCreate):
|
def create_team(db: Session, tournament_id: str, team_data: schemas.TeamCreate):
|
||||||
t = get_tournament(db, tournament_id)
|
t = get_tournament(db, tournament_id, lock=True)
|
||||||
if not t:
|
if not t:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -110,7 +107,7 @@ def create_team(db: Session, tournament_id: str, team_data: schemas.TeamCreate):
|
|||||||
|
|
||||||
|
|
||||||
def update_tournament_teams(db: Session, tournament_id: str, new_team_names: list[str]):
|
def update_tournament_teams(db: Session, tournament_id: str, new_team_names: list[str]):
|
||||||
t = get_tournament(db, tournament_id)
|
t = get_tournament(db, tournament_id, lock=True)
|
||||||
if not t:
|
if not t:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -125,7 +122,7 @@ def update_tournament_teams(db: Session, tournament_id: str, new_team_names: lis
|
|||||||
|
|
||||||
|
|
||||||
def delete_team(db: Session, tournament_id: str, team_id: int):
|
def delete_team(db: Session, tournament_id: str, team_id: int):
|
||||||
t = get_tournament(db, tournament_id)
|
t = get_tournament(db, tournament_id, lock=True)
|
||||||
if not t:
|
if not t:
|
||||||
return None
|
return None
|
||||||
team = db.get(models.Team, team_id)
|
team = db.get(models.Team, team_id)
|
||||||
@@ -141,53 +138,52 @@ def delete_team(db: Session, tournament_id: str, team_id: int):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def get_courts(db: Session, tournament_id: str):
|
def get_courts(db: Session):
|
||||||
return (
|
return db.query(models.Court).all()
|
||||||
db.query(models.Court).filter(models.Court.tournament_id == tournament_id).all()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_court(db: Session, tournament_id: str, court_data: schemas.CourtCreate):
|
def create_court(db: Session, court_data: schemas.CourtCreate):
|
||||||
t = get_tournament(db, tournament_id)
|
new_court = models.Court(name=court_data.name)
|
||||||
if not t:
|
|
||||||
return None
|
|
||||||
|
|
||||||
new_court = models.Court(name=court_data.name, tournament_id=tournament_id)
|
|
||||||
db.add(new_court)
|
db.add(new_court)
|
||||||
db.flush()
|
|
||||||
|
|
||||||
logic.update_schedule_times(db, t)
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_court)
|
db.refresh(new_court)
|
||||||
return new_court
|
return new_court
|
||||||
|
|
||||||
|
|
||||||
def delete_court(db: Session, tournament_id: str, court_id: int):
|
def delete_court(db: Session, court_id: int):
|
||||||
t = get_tournament(db, tournament_id)
|
|
||||||
if not t:
|
|
||||||
return None
|
|
||||||
court = db.get(models.Court, court_id)
|
court = db.get(models.Court, court_id)
|
||||||
if not court or court.tournament_id != tournament_id:
|
if not court:
|
||||||
return None
|
return False
|
||||||
|
|
||||||
|
affected_tournaments = list(court.tournaments)
|
||||||
|
|
||||||
|
matches = db.query(models.Match).filter(models.Match.court_id == court_id).all()
|
||||||
|
for m in matches:
|
||||||
|
if m.status == models.MatchStatus.FINISHED:
|
||||||
|
m.court_id = None
|
||||||
|
else:
|
||||||
|
m.court_id = None
|
||||||
|
m.start_time = None
|
||||||
|
|
||||||
db.delete(court)
|
db.delete(court)
|
||||||
db.flush()
|
|
||||||
|
|
||||||
logic.update_schedule_times(db, t)
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
processed_days = set()
|
||||||
|
for t in affected_tournaments:
|
||||||
|
day = t.timestamp.date() if t.timestamp else None
|
||||||
|
if day and day not in processed_days:
|
||||||
|
logic.update_schedule_times(db, t)
|
||||||
|
processed_days.add(day)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def update_tournament_courts(
|
def update_tournament_courts(db: Session, tournament_id: str, new_court_ids: list[int]):
|
||||||
db: Session, tournament_id: str, new_court_names: list[str]
|
t = get_tournament(db, tournament_id, lock=True)
|
||||||
):
|
|
||||||
t = get_tournament(db, tournament_id)
|
|
||||||
if not t:
|
if not t:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
t.courts = [models.Court(name=c, tournament_id=t.id) for c in new_court_names]
|
t.courts = db.query(models.Court).filter(models.Court.id.in_(new_court_ids)).all()
|
||||||
db.flush()
|
db.flush()
|
||||||
logic.update_schedule_times(db, t)
|
logic.update_schedule_times(db, t)
|
||||||
|
|
||||||
@@ -202,10 +198,12 @@ def get_tournament_matches(db: Session, tournament_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_match(db: Session, tournament_id: str, match_id: str):
|
def get_match(db: Session, match_id: str):
|
||||||
return (
|
return (
|
||||||
db.query(models.Match)
|
db.query(models.Match)
|
||||||
.filter(models.Match.tournament_id == tournament_id)
|
.options(
|
||||||
|
joinedload(models.Match.tournament).joinedload(models.Tournament.matches)
|
||||||
|
)
|
||||||
.filter(models.Match.id == match_id)
|
.filter(models.Match.id == match_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# backend/app/logic.py
|
# backend/app/logic.py
|
||||||
|
import heapq
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -9,6 +10,7 @@ from sqlalchemy.orm.attributes import flag_modified
|
|||||||
from . import models
|
from . import models
|
||||||
from .constants import BracketTypes, MatchStatus, TournamentTypes
|
from .constants import BracketTypes, MatchStatus, TournamentTypes
|
||||||
from .core.brackets import BracketGenerator
|
from .core.brackets import BracketGenerator
|
||||||
|
from .core.structures import Match
|
||||||
|
|
||||||
|
|
||||||
def generate_bracket(db: Session, t: models.Tournament):
|
def generate_bracket(db: Session, t: models.Tournament):
|
||||||
@@ -21,7 +23,7 @@ def generate_bracket(db: Session, t: models.Tournament):
|
|||||||
abstract_matches = gen.generate(len(teams), double_elimination=is_double)
|
abstract_matches = gen.generate(len(teams), double_elimination=is_double)
|
||||||
id_map = {m.id: str(uuid4()) for m in abstract_matches}
|
id_map = {m.id: str(uuid4()) for m in abstract_matches}
|
||||||
|
|
||||||
def resolve_target(match_node, is_winner_path):
|
def resolve_target(match_node: Match, is_winner_path):
|
||||||
if is_winner_path:
|
if is_winner_path:
|
||||||
curr = match_node.next_win
|
curr = match_node.next_win
|
||||||
slot = getattr(match_node, "next_win_slot", None)
|
slot = getattr(match_node, "next_win_slot", None)
|
||||||
@@ -79,59 +81,107 @@ def generate_bracket(db: Session, t: models.Tournament):
|
|||||||
|
|
||||||
|
|
||||||
def update_schedule_times(db: Session, t: models.Tournament):
|
def update_schedule_times(db: Session, t: models.Tournament):
|
||||||
if not t.courts or not t.matches:
|
if not t.timestamp:
|
||||||
return
|
return
|
||||||
|
|
||||||
matches = t.matches
|
# 1. Grab the entire day of tournaments
|
||||||
match_map = {m.id: m for m in matches}
|
target_date = t.timestamp.date()
|
||||||
|
start_of_day = datetime.combine(target_date, datetime.min.time())
|
||||||
|
end_of_day = start_of_day + timedelta(days=1)
|
||||||
|
|
||||||
|
tournaments = (
|
||||||
|
db.query(models.Tournament)
|
||||||
|
.filter(
|
||||||
|
models.Tournament.timestamp >= start_of_day,
|
||||||
|
models.Tournament.timestamp < end_of_day,
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not tournaments:
|
||||||
|
return
|
||||||
|
|
||||||
|
all_matches: list[models.Match] = []
|
||||||
|
match_map: dict[str, models.Match] = {}
|
||||||
prereqs = defaultdict(list)
|
prereqs = defaultdict(list)
|
||||||
for m in matches:
|
depth_cache = {}
|
||||||
|
|
||||||
|
# 2. Gather all matches and prereqs globally
|
||||||
|
for t_item in tournaments:
|
||||||
|
for m in t_item.matches:
|
||||||
|
all_matches.append(m)
|
||||||
|
match_map[m.id] = m
|
||||||
if m.winner_next_match_id:
|
if m.winner_next_match_id:
|
||||||
prereqs[m.winner_next_match_id].append(m.id)
|
prereqs[m.winner_next_match_id].append(m.id)
|
||||||
if m.loser_next_match_id:
|
if m.loser_next_match_id:
|
||||||
prereqs[m.loser_next_match_id].append(m.id)
|
prereqs[m.loser_next_match_id].append(m.id)
|
||||||
|
|
||||||
depth_cache = {}
|
# 3. Calculate depths
|
||||||
|
|
||||||
def get_depth(m_id):
|
def get_depth(m_id):
|
||||||
if m_id not in match_map:
|
if m_id not in match_map:
|
||||||
return 0
|
return 0
|
||||||
if m_id in depth_cache:
|
if m_id in depth_cache:
|
||||||
return depth_cache[m_id]
|
return depth_cache[m_id]
|
||||||
|
|
||||||
m = match_map[m_id]
|
m = match_map[m_id]
|
||||||
child_depths = [0]
|
child_depths = [0]
|
||||||
if m.winner_next_match_id:
|
if m.winner_next_match_id:
|
||||||
child_depths.append(get_depth(m.winner_next_match_id))
|
child_depths.append(get_depth(m.winner_next_match_id))
|
||||||
if m.loser_next_match_id:
|
if m.loser_next_match_id:
|
||||||
child_depths.append(get_depth(m.loser_next_match_id))
|
child_depths.append(get_depth(m.loser_next_match_id))
|
||||||
|
|
||||||
depth = 1 + max(child_depths)
|
depth = 1 + max(child_depths)
|
||||||
depth_cache[m_id] = depth
|
depth_cache[m_id] = depth
|
||||||
return depth
|
return depth
|
||||||
|
|
||||||
for m in matches:
|
for m in all_matches:
|
||||||
get_depth(m.id)
|
get_depth(m.id)
|
||||||
|
|
||||||
tournament_start = (
|
# 4. Initialize timers
|
||||||
t.timestamp.replace(tzinfo=None) if t.timestamp.tzinfo else t.timestamp
|
all_courts: set[models.Court] = set()
|
||||||
)
|
court_sharing_count = defaultdict(int)
|
||||||
court_timers = {c.id: tournament_start for c in t.courts}
|
|
||||||
|
|
||||||
|
for t_item in tournaments:
|
||||||
|
for c in t_item.courts:
|
||||||
|
all_courts.add(c)
|
||||||
|
court_sharing_count[c.id] += 1
|
||||||
|
|
||||||
|
court_timers = {c.id: start_of_day for c in all_courts}
|
||||||
planned_finish_times = {}
|
planned_finish_times = {}
|
||||||
unscheduled = list(matches)
|
unscheduled: list[models.Match] = []
|
||||||
|
|
||||||
|
for m in all_matches:
|
||||||
|
if m.status == MatchStatus.FINISHED and m.start_time and m.court_id:
|
||||||
|
fin = m.start_time + timedelta(minutes=m.tournament.duration)
|
||||||
|
planned_finish_times[m.id] = fin
|
||||||
|
if court_timers.get(m.court_id, start_of_day) < fin:
|
||||||
|
court_timers[m.court_id] = fin
|
||||||
|
else:
|
||||||
|
unscheduled.append(m)
|
||||||
|
|
||||||
|
tournament_match_counts = {t_item.id: 0 for t_item in tournaments}
|
||||||
|
|
||||||
|
# 5. Critical Path Time-Stepping Scheduler
|
||||||
|
loop_limit = max(5000, len(unscheduled) * 50)
|
||||||
|
|
||||||
loop_limit = len(matches) * 2
|
|
||||||
while unscheduled and loop_limit > 0:
|
while unscheduled and loop_limit > 0:
|
||||||
loop_limit -= 1
|
loop_limit -= 1
|
||||||
best_court_id = min(court_timers, key=lambda k: court_timers[k])
|
|
||||||
|
best_court_id = min(
|
||||||
|
court_timers.keys(), key=lambda k: (court_timers[k], court_sharing_count[k])
|
||||||
|
)
|
||||||
current_time = court_timers[best_court_id]
|
current_time = court_timers[best_court_id]
|
||||||
|
|
||||||
ready: list[models.Match] = []
|
ready: list[models.Match] = []
|
||||||
for m in unscheduled:
|
for m in unscheduled:
|
||||||
|
if best_court_id not in [c.id for c in m.tournament.courts]:
|
||||||
|
continue
|
||||||
is_ready = True
|
is_ready = True
|
||||||
max_prereq_time = tournament_start
|
max_prereq_time = (
|
||||||
|
m.tournament.timestamp.replace(tzinfo=None)
|
||||||
|
if m.tournament.timestamp.tzinfo
|
||||||
|
else m.tournament.timestamp
|
||||||
|
)
|
||||||
|
|
||||||
for p_id in prereqs[m.id]:
|
for p_id in prereqs[m.id]:
|
||||||
if p_id not in planned_finish_times:
|
if p_id not in planned_finish_times:
|
||||||
is_ready = False
|
is_ready = False
|
||||||
@@ -143,27 +193,41 @@ def update_schedule_times(db: Session, t: models.Tournament):
|
|||||||
|
|
||||||
if ready:
|
if ready:
|
||||||
ready.sort(
|
ready.sort(
|
||||||
key=lambda x: (-depth_cache[x.id], x.round_number, x.match_number)
|
key=lambda x: (
|
||||||
|
len(x.tournament.courts),
|
||||||
|
tournament_match_counts[x.tournament_id],
|
||||||
|
-depth_cache[x.id],
|
||||||
|
x.round_number,
|
||||||
|
x.match_number,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
cand = ready[0]
|
cand = ready[0]
|
||||||
|
|
||||||
cand.court_id = best_court_id
|
cand.court_id = best_court_id
|
||||||
cand.start_time = current_time
|
cand.start_time = current_time
|
||||||
|
|
||||||
fin = current_time + timedelta(minutes=t.duration)
|
fin = current_time + timedelta(minutes=cand.tournament.duration)
|
||||||
planned_finish_times[cand.id] = fin
|
planned_finish_times[cand.id] = fin
|
||||||
court_timers[best_court_id] = fin
|
court_timers[best_court_id] = fin
|
||||||
|
tournament_match_counts[cand.tournament_id] += 1
|
||||||
unscheduled.remove(cand)
|
unscheduled.remove(cand)
|
||||||
else:
|
else:
|
||||||
next_wake = None
|
next_wake = None
|
||||||
for m in unscheduled:
|
for m in unscheduled:
|
||||||
|
if best_court_id not in [c.id for c in m.tournament.courts]:
|
||||||
|
continue
|
||||||
is_ready = True
|
is_ready = True
|
||||||
max_prereq_time = tournament_start
|
max_prereq_time = (
|
||||||
|
m.tournament.timestamp.replace(tzinfo=None)
|
||||||
|
if m.tournament.timestamp.tzinfo
|
||||||
|
else m.tournament.timestamp
|
||||||
|
)
|
||||||
for p_id in prereqs[m.id]:
|
for p_id in prereqs[m.id]:
|
||||||
if p_id not in planned_finish_times:
|
if p_id not in planned_finish_times:
|
||||||
is_ready = False
|
is_ready = False
|
||||||
break
|
break
|
||||||
max_prereq_time = max(max_prereq_time, planned_finish_times[p_id])
|
max_prereq_time = max(max_prereq_time, planned_finish_times[p_id])
|
||||||
|
|
||||||
if is_ready and max_prereq_time > current_time:
|
if is_ready and max_prereq_time > current_time:
|
||||||
if next_wake is None or max_prereq_time < next_wake:
|
if next_wake is None or max_prereq_time < next_wake:
|
||||||
next_wake = max_prereq_time
|
next_wake = max_prereq_time
|
||||||
@@ -171,8 +235,124 @@ def update_schedule_times(db: Session, t: models.Tournament):
|
|||||||
if next_wake:
|
if next_wake:
|
||||||
court_timers[best_court_id] = next_wake
|
court_timers[best_court_id] = next_wake
|
||||||
else:
|
else:
|
||||||
|
court_timers[best_court_id] += timedelta(minutes=5)
|
||||||
|
|
||||||
|
# 6. Ultra-Fast Smart Referee Assignment
|
||||||
|
all_matches.sort(key=lambda x: (x.start_time or start_of_day, x.court_id or 0))
|
||||||
|
duty_counts = defaultdict(int)
|
||||||
|
|
||||||
|
team_first_match = {}
|
||||||
|
for m in all_matches:
|
||||||
|
if m.start_time:
|
||||||
|
if m.p1_team_id and m.p1_team_id not in team_first_match:
|
||||||
|
team_first_match[m.p1_team_id] = m.start_time
|
||||||
|
if m.p2_team_id and m.p2_team_id not in team_first_match:
|
||||||
|
team_first_match[m.p2_team_id] = m.start_time
|
||||||
|
|
||||||
|
active_refs = []
|
||||||
|
match_outcome_next_start = {}
|
||||||
|
|
||||||
|
for m in all_matches:
|
||||||
|
if m.winner_next_match_id:
|
||||||
|
nm = match_map.get(m.winner_next_match_id)
|
||||||
|
if nm and nm.start_time:
|
||||||
|
match_outcome_next_start[("W", m.id)] = nm.start_time
|
||||||
|
if m.loser_next_match_id:
|
||||||
|
nm = match_map.get(m.loser_next_match_id)
|
||||||
|
if nm and nm.start_time:
|
||||||
|
match_outcome_next_start[("L", m.id)] = nm.start_time
|
||||||
|
|
||||||
|
def is_ref_busy(identifier, start, end):
|
||||||
|
for ref_id, r_start, r_end in active_refs:
|
||||||
|
if ref_id == identifier and start < r_end and end > r_start:
|
||||||
|
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=m.tournament.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 prev_m.tournament_id != m.tournament_id:
|
||||||
|
continue
|
||||||
|
if not prev_m.start_time or prev_m.start_time >= m_start:
|
||||||
|
continue
|
||||||
|
prev_end = prev_m.start_time + timedelta(minutes=prev_m.tournament.duration)
|
||||||
|
if prev_end > m_start:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for outcome in ["W", "L"]:
|
||||||
|
out_key = (outcome, prev_m.id)
|
||||||
|
next_start = match_outcome_next_start.get(out_key)
|
||||||
|
if next_start and next_start < m_end:
|
||||||
|
continue
|
||||||
|
identifier = f"{outcome}:{prev_m.id}"
|
||||||
|
if is_ref_busy(identifier, m_start, m_end):
|
||||||
|
continue
|
||||||
|
|
||||||
|
wait_mins = (m_start - prev_end).total_seconds() / 60.0
|
||||||
|
score = (duty_counts[identifier] * 120) + wait_mins
|
||||||
|
if outcome == "W":
|
||||||
|
score += 60
|
||||||
|
if prev_m.court_id == m.court_id:
|
||||||
|
score -= 30
|
||||||
|
|
||||||
|
if score < best_score:
|
||||||
|
best_score = score
|
||||||
|
best_outcome = identifier
|
||||||
|
|
||||||
|
if best_outcome:
|
||||||
|
m.ref_label = best_outcome
|
||||||
|
m.ref_team_id = None
|
||||||
|
duty_counts[best_outcome] += 1
|
||||||
|
active_refs.append((best_outcome, m_start, m_end))
|
||||||
|
else:
|
||||||
|
assigned_bye_team = False
|
||||||
|
sorted_teams = sorted(
|
||||||
|
m.tournament.teams, key=lambda t: duty_counts[f"TEAM_{t.id}"]
|
||||||
|
)
|
||||||
|
|
||||||
|
for team in sorted_teams:
|
||||||
|
t_id = team.id
|
||||||
|
t_first_start = team_first_match.get(t_id)
|
||||||
|
identifier = f"TEAM_{t_id}"
|
||||||
|
if t_first_start and t_first_start >= m_end:
|
||||||
|
if not is_ref_busy(identifier, m_start, m_end):
|
||||||
|
m.ref_team_id = t_id
|
||||||
|
m.ref_label = None
|
||||||
|
duty_counts[identifier] += 1
|
||||||
|
active_refs.append((identifier, m_start, m_end))
|
||||||
|
assigned_bye_team = True
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if not assigned_bye_team:
|
||||||
|
m.ref_label = "Staff / Volunteers"
|
||||||
|
m.ref_team_id = None
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -182,6 +362,14 @@ def advance_winner(db: Session, match: models.Match, winner_id: int):
|
|||||||
|
|
||||||
loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id
|
loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id
|
||||||
|
|
||||||
|
for m in match.tournament.matches:
|
||||||
|
if m.ref_label == f"L:{match.id}":
|
||||||
|
m.ref_team_id = loser_id
|
||||||
|
db.add(m)
|
||||||
|
elif m.ref_label == f"W:{match.id}":
|
||||||
|
m.ref_team_id = winner_id
|
||||||
|
db.add(m)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
match.bracket_type == BracketTypes.FINALS
|
match.bracket_type == BracketTypes.FINALS
|
||||||
and match.winner_next_match
|
and match.winner_next_match
|
||||||
@@ -246,6 +434,11 @@ def undo_advancement(db: Session, match: models.Match):
|
|||||||
if not match.winner_team_id:
|
if not match.winner_team_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
for m in match.tournament.matches:
|
||||||
|
if m.ref_label in [f"L:{match.id}", f"W:{match.id}"]:
|
||||||
|
m.ref_team_id = None
|
||||||
|
db.add(m)
|
||||||
|
|
||||||
winner_id = match.winner_team_id
|
winner_id = match.winner_team_id
|
||||||
loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id
|
loser_id = match.p1_team_id if match.p1_team_id != winner_id else match.p2_team_id
|
||||||
|
|
||||||
@@ -272,3 +465,5 @@ def undo_advancement(db: Session, match: models.Match):
|
|||||||
clear_from_next(match.winner_next_match, match.winner_next_match_slot)
|
clear_from_next(match.winner_next_match, match.winner_next_match_slot)
|
||||||
if match.loser_next_match and loser_id:
|
if match.loser_next_match and loser_id:
|
||||||
clear_from_next(match.loser_next_match, match.loser_next_match_slot)
|
clear_from_next(match.loser_next_match, match.loser_next_match_slot)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from .database import Base, engine
|
from .database import Base, engine
|
||||||
from .routes import auth, tournaments, websocket
|
from .routes import auth, tournaments, websocket, courts
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -15,6 +15,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(title="Tournament Bracket API", lifespan=lifespan, root_path="/api")
|
app = FastAPI(title="Tournament Bracket API", lifespan=lifespan, root_path="/api")
|
||||||
app.include_router(tournaments.router)
|
app.include_router(tournaments.router)
|
||||||
|
app.include_router(courts.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(websocket.router)
|
app.include_router(websocket.router)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import JSON, DateTime
|
from sqlalchemy import JSON, DateTime, Table, Column
|
||||||
from sqlalchemy import Enum as SqlEnum
|
from sqlalchemy import Enum as SqlEnum
|
||||||
from sqlalchemy import ForeignKey, Integer, String
|
from sqlalchemy import ForeignKey, Integer, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
@@ -10,6 +10,33 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|||||||
from .constants import MatchStatus, TournamentTypes, BracketTypes
|
from .constants import MatchStatus, TournamentTypes, BracketTypes
|
||||||
from .database import Base
|
from .database import Base
|
||||||
|
|
||||||
|
tournament_courts = Table(
|
||||||
|
"tournament_courts",
|
||||||
|
Base.metadata,
|
||||||
|
Column(
|
||||||
|
"tournament_id",
|
||||||
|
String(8),
|
||||||
|
ForeignKey("tournaments.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
"court_id",
|
||||||
|
Integer,
|
||||||
|
ForeignKey("courts.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Court(Base):
|
||||||
|
__tablename__ = "courts"
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String, nullable=False, unique=True)
|
||||||
|
|
||||||
|
tournaments: Mapped[list["Tournament"]] = relationship(
|
||||||
|
"Tournament", secondary=tournament_courts, back_populates="courts"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Tournament(Base):
|
class Tournament(Base):
|
||||||
__tablename__ = "tournaments"
|
__tablename__ = "tournaments"
|
||||||
@@ -25,7 +52,7 @@ class Tournament(Base):
|
|||||||
"Team", back_populates="tournament", cascade="all, delete-orphan"
|
"Team", back_populates="tournament", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
courts: Mapped[list["Court"]] = relationship(
|
courts: Mapped[list["Court"]] = relationship(
|
||||||
"Court", back_populates="tournament", cascade="all, delete-orphan"
|
"Court", secondary=tournament_courts, back_populates="tournaments"
|
||||||
)
|
)
|
||||||
|
|
||||||
matches: Mapped[list["Match"]] = relationship(
|
matches: Mapped[list["Match"]] = relationship(
|
||||||
@@ -50,15 +77,6 @@ class Team(Base):
|
|||||||
tournament: Mapped["Tournament"] = relationship(back_populates="teams")
|
tournament: Mapped["Tournament"] = relationship(back_populates="teams")
|
||||||
|
|
||||||
|
|
||||||
class Court(Base):
|
|
||||||
__tablename__ = "courts"
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
|
||||||
tournament_id: Mapped[str] = mapped_column(ForeignKey("tournaments.id"))
|
|
||||||
|
|
||||||
tournament: Mapped["Tournament"] = relationship(back_populates="courts")
|
|
||||||
|
|
||||||
|
|
||||||
class Match(Base):
|
class Match(Base):
|
||||||
__tablename__ = "matches"
|
__tablename__ = "matches"
|
||||||
|
|
||||||
@@ -85,16 +103,16 @@ class Match(Base):
|
|||||||
p2_team_id: Mapped[Optional[int]] = mapped_column(
|
p2_team_id: Mapped[Optional[int]] = mapped_column(
|
||||||
ForeignKey("teams.id"), nullable=True
|
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)
|
||||||
|
|
||||||
sets: Mapped[list[dict]] = mapped_column(JSON, default=list)
|
sets: Mapped[list[dict]] = mapped_column(JSON, default=list)
|
||||||
winner_team_id: Mapped[Optional[int]] = mapped_column(
|
winner_team_id: Mapped[Optional[int]] = mapped_column(
|
||||||
ForeignKey("teams.id"), nullable=True
|
ForeignKey("teams.id"), nullable=True
|
||||||
)
|
)
|
||||||
winner_next_match_id: Mapped[Optional[str]] = mapped_column(
|
|
||||||
ForeignKey("matches.id"), nullable=True
|
|
||||||
)
|
|
||||||
loser_next_match_id: Mapped[Optional[str]] = mapped_column(
|
|
||||||
ForeignKey("matches.id"), nullable=True
|
|
||||||
)
|
|
||||||
|
|
||||||
winner_next_match_id: Mapped[Optional[str]] = mapped_column(
|
winner_next_match_id: Mapped[Optional[str]] = mapped_column(
|
||||||
ForeignKey("matches.id"), nullable=True
|
ForeignKey("matches.id"), nullable=True
|
||||||
@@ -112,6 +130,11 @@ class Match(Base):
|
|||||||
court: Mapped[Optional["Court"]] = relationship()
|
court: Mapped[Optional["Court"]] = relationship()
|
||||||
p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id])
|
p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id])
|
||||||
p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_id])
|
p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_id])
|
||||||
|
|
||||||
|
ref_team: Mapped[Optional["Team"]] = relationship(
|
||||||
|
"Team", foreign_keys=[ref_team_id]
|
||||||
|
)
|
||||||
|
|
||||||
winner_next_match: Mapped["Match"] = relationship(
|
winner_next_match: Mapped["Match"] = relationship(
|
||||||
"Match", remote_side=[id], foreign_keys=[winner_next_match_id]
|
"Match", remote_side=[id], foreign_keys=[winner_next_match_id]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
|
||||||
from ..schemas import Token
|
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 (
|
from ..core.config import (
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||||
ADMIN_HASH,
|
ADMIN_HASH,
|
||||||
ADMIN_USER,
|
ADMIN_USER,
|
||||||
|
REF_HASH,
|
||||||
|
REF_USER,
|
||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,14 +23,18 @@ router = APIRouter(prefix="/auth", tags=["Auth"])
|
|||||||
async def login_for_access_token(
|
async def login_for_access_token(
|
||||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||||
):
|
):
|
||||||
if form_data.username != ADMIN_USER:
|
role = None
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Incorrect username or password",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Incorrect username or password",
|
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_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
access_token = create_access_token(
|
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")
|
@router.get("/check")
|
||||||
async def check_auth(user: str = Depends(get_current_user)):
|
async def check_auth(user: dict = Depends(get_authenticated_user)):
|
||||||
return {"is_admin": True, "user": user}
|
return {"role": user.get("role"), "user": user.get("sub")}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# backend/app/routes/courts.py
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
from .. import crud, schemas, models
|
||||||
|
from ..constants import SUCCESS
|
||||||
|
from ..core.auth import get_admin_user
|
||||||
|
from ..database import get_db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/courts", tags=["Courts"])
|
||||||
|
|
||||||
|
@router.get("", response_model=list[schemas.CourtSchema])
|
||||||
|
def get_all_courts(db: Session = Depends(get_db)):
|
||||||
|
"""Public route to list all global courts"""
|
||||||
|
return db.query(models.Court).all()
|
||||||
|
|
||||||
|
@router.post("", response_model=schemas.CourtSchema)
|
||||||
|
def create_global_court(
|
||||||
|
data: schemas.CourtCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: dict = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
"""Admin route to register a new physical court"""
|
||||||
|
return crud.create_court(db, data)
|
||||||
|
|
||||||
|
@router.delete("/{court_id}")
|
||||||
|
def delete_global_court(
|
||||||
|
court_id: int, db: Session = Depends(get_db), user: dict = Depends(get_admin_user)
|
||||||
|
):
|
||||||
|
success = crud.delete_court(db, court_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(404, "Court not found")
|
||||||
|
return SUCCESS
|
||||||
|
|
||||||
|
@router.get("/{court_id}/schedule")
|
||||||
|
def get_court_schedule(court_id: int, db: Session = Depends(get_db)):
|
||||||
|
court = db.query(models.Court).filter(models.Court.id == court_id).first()
|
||||||
|
if not court:
|
||||||
|
raise HTTPException(404, "Court not found")
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
matches = (
|
||||||
|
db.query(models.Match)
|
||||||
|
.join(models.Tournament)
|
||||||
|
.filter(models.Match.court_id == court_id)
|
||||||
|
.filter(
|
||||||
|
models.Tournament.timestamp >= datetime.combine(today, datetime.min.time())
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
models.Tournament.timestamp < datetime.combine(today, datetime.max.time())
|
||||||
|
)
|
||||||
|
.order_by(models.Match.start_time)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
t_ids = {m.tournament_id for m in matches}
|
||||||
|
if t_ids:
|
||||||
|
all_t_matches = db.query(models.Match).filter(models.Match.tournament_id.in_(t_ids)).all()
|
||||||
|
else:
|
||||||
|
all_t_matches = []
|
||||||
|
|
||||||
|
match_by_id = {str(tm.id).lower(): tm for tm in all_t_matches}
|
||||||
|
parent_map = {}
|
||||||
|
|
||||||
|
for tm in all_t_matches:
|
||||||
|
if tm.winner_next_match_id:
|
||||||
|
parent_map[(str(tm.winner_next_match_id).lower(), tm.winner_next_match_slot)] = f"Winner of #{tm.match_number}"
|
||||||
|
if tm.loser_next_match_id:
|
||||||
|
parent_map[(str(tm.loser_next_match_id).lower(), tm.loser_next_match_slot)] = f"Loser of #{tm.match_number}"
|
||||||
|
|
||||||
|
def resolve_ref(m: models.Match):
|
||||||
|
if m.ref_team:
|
||||||
|
return m.ref_team.name
|
||||||
|
|
||||||
|
if m.ref_label and ":" in m.ref_label:
|
||||||
|
outcome, ref_id = m.ref_label.split(":")
|
||||||
|
ref_match = match_by_id.get(ref_id.strip().lower())
|
||||||
|
if ref_match:
|
||||||
|
role = "Winner" if outcome.upper() == "W" else "Loser"
|
||||||
|
return f"{role} of #{ref_match.match_number}"
|
||||||
|
|
||||||
|
return m.ref_label or "TBD"
|
||||||
|
|
||||||
|
def get_team_label(m: models.Match, slot: int, team):
|
||||||
|
if team:
|
||||||
|
return team.name
|
||||||
|
return parent_map.get((str(m.id).lower(), slot), "TBD")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"court": court.name,
|
||||||
|
"matches": [
|
||||||
|
{
|
||||||
|
"id": m.id,
|
||||||
|
"tournament_id": m.tournament.id,
|
||||||
|
"tournament_name": m.tournament.name,
|
||||||
|
"duration": m.tournament.duration,
|
||||||
|
"time": m.start_time.strftime("%H:%M") if m.start_time else None,
|
||||||
|
"status": m.status.value if hasattr(m.status, 'value') else m.status,
|
||||||
|
"match_number": m.match_number,
|
||||||
|
"p1": get_team_label(m, 0, m.p1_team),
|
||||||
|
"p2": get_team_label(m, 1, m.p2_team),
|
||||||
|
"p1_is_real": bool(m.p1_team),
|
||||||
|
"p2_is_real": bool(m.p2_team),
|
||||||
|
"p1_sets": len([s for s in m.sets if s.get("p1", 0) > s.get("p2", 0)]),
|
||||||
|
"p2_sets": len([s for s in m.sets if s.get("p2", 0) > s.get("p1", 0)]),
|
||||||
|
"ref_name": resolve_ref(m),
|
||||||
|
}
|
||||||
|
for m in matches
|
||||||
|
if m.start_time
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -3,4 +3,4 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
router = APIRouter(prefix="/tournaments", tags=["Tournaments"])
|
router = APIRouter(prefix="/tournaments", tags=["Tournaments"])
|
||||||
|
|
||||||
from . import tournaments, settings, teams, courts, matches, report
|
from . import tournaments, settings, teams, matches, report
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
# backend/app/routes/tournaments/courts.py
|
|
||||||
from fastapi import Depends, HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from ... import crud, schemas
|
|
||||||
from ...constants import SUCCESS
|
|
||||||
from ...core.auth import get_current_user
|
|
||||||
from ...core.websocket_manager import send_ws_update
|
|
||||||
from ...database import get_db
|
|
||||||
from . import router
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{id}/courts", response_model=list[schemas.CourtSchema])
|
|
||||||
def get_courts(id: str, db: Session = Depends(get_db)):
|
|
||||||
return crud.get_courts(db, id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{id}/courts", response_model=schemas.CourtSchema)
|
|
||||||
async def create_court(
|
|
||||||
id: str,
|
|
||||||
court: schemas.CourtCreate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user: str = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
new_court = crud.create_court(db, id, court)
|
|
||||||
if not new_court:
|
|
||||||
raise HTTPException(404, "Tournament not found")
|
|
||||||
|
|
||||||
await send_ws_update(id)
|
|
||||||
return new_court
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{id}/courts", response_model=schemas.TournamentDetail)
|
|
||||||
async def update_courts(
|
|
||||||
id: str,
|
|
||||||
courts: list[str],
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user: str = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
t = crud.update_tournament_courts(db, id, courts)
|
|
||||||
if not t:
|
|
||||||
raise HTTPException(404, "Not found")
|
|
||||||
|
|
||||||
await send_ws_update(id)
|
|
||||||
return t
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{id}/courts/{court_id}")
|
|
||||||
async def delete_court(
|
|
||||||
id: str,
|
|
||||||
court_id: int,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user: str = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
success = crud.delete_court(db, id, court_id)
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(404, "Court or Tournament not found")
|
|
||||||
|
|
||||||
await send_ws_update(id)
|
|
||||||
return SUCCESS
|
|
||||||
@@ -16,7 +16,7 @@ def get_tournament_matches(id: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
@router.get("/{id}/matches/{match_id}", response_model=schemas.MatchOut)
|
@router.get("/{id}/matches/{match_id}", response_model=schemas.MatchOut)
|
||||||
def get_match_details(id: str, match_id: str, db: Session = Depends(get_db)):
|
def get_match_details(id: str, match_id: str, db: Session = Depends(get_db)):
|
||||||
match = crud.get_match(db, id, match_id)
|
match = crud.get_match(db, match_id)
|
||||||
|
|
||||||
if not match:
|
if not match:
|
||||||
raise HTTPException(404, "Match not found")
|
raise HTTPException(404, "Match not found")
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ def _check_auth(t: models.Tournament, user: Optional[str], code: Optional[str]):
|
|||||||
|
|
||||||
if not is_admin and not code_matches:
|
if not is_admin and not code_matches:
|
||||||
raise HTTPException(403, "Invalid tournament code or admin privileges required")
|
raise HTTPException(403, "Invalid tournament code or admin privileges required")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{id}/matches/{match_id}/score")
|
@router.post("/{id}/matches/{match_id}/score")
|
||||||
async def report_score(
|
async def report_score(
|
||||||
id: str,
|
id: str,
|
||||||
@@ -29,13 +27,13 @@ async def report_score(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: Optional[str] = Depends(get_optional_user),
|
user: Optional[str] = Depends(get_optional_user),
|
||||||
):
|
):
|
||||||
t = crud.get_tournament(db, id)
|
t = crud.get_tournament(db, id, lock=True)
|
||||||
if not t:
|
if not t:
|
||||||
raise HTTPException(404, "Tournament not found")
|
raise HTTPException(404, "Tournament not found")
|
||||||
|
|
||||||
_check_auth(t, user, report.code)
|
_check_auth(t, user, report.code)
|
||||||
|
|
||||||
match = crud.get_match(db, id, match_id)
|
match = crud.get_match(db, match_id)
|
||||||
if not match:
|
if not match:
|
||||||
raise HTTPException(404, "Match not found")
|
raise HTTPException(404, "Match not found")
|
||||||
|
|
||||||
@@ -65,13 +63,13 @@ async def edit_score(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: Optional[str] = Depends(get_optional_user),
|
user: Optional[str] = Depends(get_optional_user),
|
||||||
):
|
):
|
||||||
t = crud.get_tournament(db, id)
|
t = crud.get_tournament(db, id, lock=True)
|
||||||
if not t:
|
if not t:
|
||||||
raise HTTPException(404, "Tournament not found")
|
raise HTTPException(404, "Tournament not found")
|
||||||
|
|
||||||
_check_auth(t, user, report.code)
|
_check_auth(t, user, report.code)
|
||||||
|
|
||||||
match = crud.get_match(db, id, match_id)
|
match = crud.get_match(db, match_id)
|
||||||
if not match:
|
if not match:
|
||||||
raise HTTPException(404, "Match not found")
|
raise HTTPException(404, "Match not found")
|
||||||
|
|
||||||
@@ -99,13 +97,13 @@ async def clear_score(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: Optional[str] = Depends(get_optional_user),
|
user: Optional[str] = Depends(get_optional_user),
|
||||||
):
|
):
|
||||||
t = crud.get_tournament(db, id)
|
t = crud.get_tournament(db, id, lock=True)
|
||||||
if not t:
|
if not t:
|
||||||
raise HTTPException(404, "Tournament not found")
|
raise HTTPException(404, "Tournament not found")
|
||||||
|
|
||||||
_check_auth(t, user, code)
|
_check_auth(t, user, code)
|
||||||
|
|
||||||
match = crud.get_match(db, id, match_id)
|
match = crud.get_match(db, match_id)
|
||||||
if not match:
|
if not match:
|
||||||
raise HTTPException(404, "Match not found")
|
raise HTTPException(404, "Match not found")
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from ... import crud, schemas
|
from ... import crud, schemas
|
||||||
from ...database import get_db
|
from ...database import get_db
|
||||||
from ...core.auth import get_current_user
|
from ...core.auth import get_admin_user
|
||||||
from . import router
|
from . import router
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{id}/settings", response_model=schemas.TournamentSettingsResponse)
|
@router.get("/{id}/settings", response_model=schemas.TournamentSettingsResponse)
|
||||||
def get_tournament(
|
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)
|
t = crud.get_tournament(db, id)
|
||||||
if not t:
|
if not t:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from ... import crud, schemas
|
from ... import crud, schemas
|
||||||
from ...constants import SUCCESS
|
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 ...core.websocket_manager import send_ws_update
|
||||||
from ...database import get_db
|
from ...database import get_db
|
||||||
from . import router
|
from . import router
|
||||||
@@ -20,7 +20,7 @@ async def create_team(
|
|||||||
id: str,
|
id: str,
|
||||||
team: schemas.TeamCreate,
|
team: schemas.TeamCreate,
|
||||||
db: Session = Depends(get_db),
|
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)
|
new_team = crud.create_team(db, id, team)
|
||||||
if not new_team:
|
if not new_team:
|
||||||
@@ -35,7 +35,7 @@ async def update_teams(
|
|||||||
id: str,
|
id: str,
|
||||||
teams: list[str],
|
teams: list[str],
|
||||||
db: Session = Depends(get_db),
|
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)
|
t = crud.update_tournament_teams(db, id, teams)
|
||||||
if not t:
|
if not t:
|
||||||
@@ -50,7 +50,7 @@ async def delete_team(
|
|||||||
id: str,
|
id: str,
|
||||||
team_id: int,
|
team_id: int,
|
||||||
db: Session = Depends(get_db),
|
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)
|
success = crud.delete_team(db, id, team_id)
|
||||||
if not success:
|
if not success:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from ... import crud, schemas
|
|||||||
from ...constants import SUCCESS
|
from ...constants import SUCCESS
|
||||||
from ...core.websocket_manager import send_ws_update
|
from ...core.websocket_manager import send_ws_update
|
||||||
from ...database import get_db
|
from ...database import get_db
|
||||||
from ...core.auth import get_current_user
|
from ...core.auth import get_admin_user
|
||||||
from . import router
|
from . import router
|
||||||
|
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ from . import router
|
|||||||
async def create_tournament(
|
async def create_tournament(
|
||||||
data: schemas.TournamentCreate,
|
data: schemas.TournamentCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: str = Depends(get_current_user),
|
user: dict = Depends(get_admin_user),
|
||||||
):
|
):
|
||||||
new_t = crud.create_tournament(db, data)
|
new_t = crud.create_tournament(db, data)
|
||||||
await send_ws_update(new_t.id)
|
await send_ws_update(new_t.id)
|
||||||
@@ -39,7 +39,7 @@ async def update_settings(
|
|||||||
id: str,
|
id: str,
|
||||||
data: schemas.TournamentUpdate,
|
data: schemas.TournamentUpdate,
|
||||||
db: Session = Depends(get_db),
|
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)
|
t = crud.update_tournament_details(db, id, data)
|
||||||
if not t:
|
if not t:
|
||||||
@@ -51,7 +51,7 @@ async def update_settings(
|
|||||||
|
|
||||||
@router.delete("/{id}")
|
@router.delete("/{id}")
|
||||||
async def delete_tournament(
|
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)
|
success = crud.delete_tournament(db, id)
|
||||||
if not success:
|
if not success:
|
||||||
@@ -60,3 +60,17 @@ async def delete_tournament(
|
|||||||
await send_ws_update(id)
|
await send_ws_update(id)
|
||||||
|
|
||||||
return SUCCESS
|
return SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{id}/courts", response_model=schemas.TournamentOut)
|
||||||
|
def update_tournament_courts(
|
||||||
|
id: str,
|
||||||
|
court_ids: list[int],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: dict = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
"""Updates the global courts assigned to a specific tournament"""
|
||||||
|
t = crud.update_tournament_courts(db, id, court_ids)
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(404, "Tournament not found")
|
||||||
|
return t
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ class MatchOut(BaseModel):
|
|||||||
p2_team_id: int | None = None
|
p2_team_id: int | None = None
|
||||||
winner_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_id: str | None = None
|
||||||
winner_next_match_slot: int | None = None
|
winner_next_match_slot: int | None = None
|
||||||
loser_next_match_id: str | None = None
|
loser_next_match_id: str | None = None
|
||||||
@@ -67,7 +71,7 @@ class TournamentCreate(BaseModel):
|
|||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
duration: int
|
duration: int
|
||||||
teams: list[str]
|
teams: list[str]
|
||||||
courts: list[str]
|
courts: list[int]
|
||||||
|
|
||||||
|
|
||||||
class TournamentUpdate(BaseModel):
|
class TournamentUpdate(BaseModel):
|
||||||
@@ -110,3 +114,4 @@ class TournamentSettingsResponse(TournamentOut):
|
|||||||
class Token(BaseModel):
|
class Token(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str
|
token_type: str
|
||||||
|
role: str | None
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
from typing import AsyncGenerator, Generator
|
from typing import AsyncGenerator, Generator
|
||||||
|
|
||||||
import pytest
|
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
|
from app.database import Base, get_db
|
||||||
|
|
||||||
# Import your app and models
|
# Import your app and models
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
from app import models
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@@ -43,6 +44,15 @@ def db(prepare_db) -> Generator[Session, None, None]:
|
|||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clean_db(db):
|
||||||
|
db.query(models.Match).delete()
|
||||||
|
db.query(models.Team).delete()
|
||||||
|
db.query(models.Court).delete()
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
async def client(db: Session) -> AsyncGenerator[AsyncClient, None]:
|
async def client(db: Session) -> AsyncGenerator[AsyncClient, None]:
|
||||||
def override_get_db():
|
def override_get_db():
|
||||||
@@ -52,7 +62,7 @@ async def client(db: Session) -> AsyncGenerator[AsyncClient, None]:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Strict Auth: Always requires a token (simulated by header presence)
|
# 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:
|
if "Authorization" not in request.headers:
|
||||||
# Let FastAPI raise the 401 naturally if header is missing
|
# Let FastAPI raise the 401 naturally if header is missing
|
||||||
raise pytest.skip("Auth header missing in strict auth test")
|
raise pytest.skip("Auth header missing in strict auth test")
|
||||||
@@ -65,7 +75,7 @@ async def client(db: Session) -> AsyncGenerator[AsyncClient, None]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
app.dependency_overrides[get_db] = override_get_db
|
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
|
app.dependency_overrides[get_optional_user] = override_get_optional_user
|
||||||
|
|
||||||
async with AsyncClient(
|
async with AsyncClient(
|
||||||
@@ -85,13 +95,20 @@ def auth_headers(client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def valid_tournament_payload():
|
async def valid_tournament_payload(db):
|
||||||
|
# 1. Create global courts first
|
||||||
|
c1 = models.Court(name="Plan 1")
|
||||||
|
c2 = models.Court(name="Plan 2")
|
||||||
|
db.add_all([c1, c2])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 2. Return payload with IDs
|
||||||
return {
|
return {
|
||||||
"name": "Test Tournament",
|
"name": "Test Tournament",
|
||||||
"code": "1234",
|
"code": "1234",
|
||||||
"type": "Double",
|
"type": "Double",
|
||||||
"timestamp": "2024-01-01T10:00:00",
|
"timestamp": "2026-05-24T11:00:00",
|
||||||
"duration": 15,
|
"duration": 15,
|
||||||
"teams": ["Team A", "Team B", "Team C", "Team D"],
|
"teams": ["Team A", "Team B", "Team C", "Team D"],
|
||||||
"courts": ["Court 1", "Court 2"],
|
"courts": [c1.id, c2.id],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ async def test_manage_teams(
|
|||||||
|
|
||||||
# Verify the teams are actually in the first match
|
# Verify the teams are actually in the first match
|
||||||
first_match = final_matches[0]
|
first_match = final_matches[0]
|
||||||
print(first_match)
|
assert first_match["p1_team_id"] is not None
|
||||||
|
assert first_match["p2_team_id"] is not None
|
||||||
|
|
||||||
p1_name = first_match["p1_team_id"]
|
p1_name = first_match["p1_team_id"]
|
||||||
p2_name = first_match["p2_team_id"]
|
p2_name = first_match["p2_team_id"]
|
||||||
|
|
||||||
@@ -60,21 +62,21 @@ async def test_manage_courts(
|
|||||||
)
|
)
|
||||||
t_id = res.json()["id"]
|
t_id = res.json()["id"]
|
||||||
|
|
||||||
# Get initial courts
|
# 1. Get initial courts from the tournament detail
|
||||||
courts_res = await client.get(f"/tournaments/{t_id}")
|
courts_res = await client.get(f"/tournaments/{t_id}")
|
||||||
initial_courts = courts_res.json()["courts"]
|
initial_courts = courts_res.json()["courts"]
|
||||||
assert len(initial_courts) == 2
|
assert len(initial_courts) == 2
|
||||||
|
|
||||||
# Delete
|
# 2. Extract the ID from the list so the variable is defined!
|
||||||
court_id = initial_courts[0]["id"]
|
court_to_delete_id = initial_courts[0]["id"]
|
||||||
del_res = await client.delete(
|
|
||||||
f"/tournaments/{t_id}/courts/{court_id}", headers=auth_headers
|
# 3. Now use that variable in your delete call
|
||||||
)
|
del_res = await client.delete(f"/courts/{court_to_delete_id}", headers=auth_headers)
|
||||||
assert del_res.status_code == 200
|
assert del_res.status_code == 200
|
||||||
|
|
||||||
# Create
|
# CREATE: Change this from a tournament-specific route to the global one
|
||||||
create_res = await client.post(
|
create_res = await client.post(
|
||||||
f"/tournaments/{t_id}/courts", json={"name": "New Court"}, headers=auth_headers
|
"/courts", json={"name": "New Court"}, headers=auth_headers # GLOBAL ROUTE
|
||||||
)
|
)
|
||||||
assert create_res.status_code == 200
|
assert create_res.status_code == 200
|
||||||
assert create_res.json()["name"] == "New Court"
|
assert create_res.json()["name"] == "New Court"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -1,5 +1,9 @@
|
|||||||
ADMIN_USER=admin
|
ADMIN_USER=admin
|
||||||
ADMIN_PASSWORD=admin
|
ADMIN_PASSWORD=admin
|
||||||
|
|
||||||
|
REF_USER=ref
|
||||||
|
REF_PASSWORD=ref
|
||||||
|
|
||||||
SECRET_KEY=PLEASE_REPLACE_ME_WITH_A_SECRET_KEY
|
SECRET_KEY=PLEASE_REPLACE_ME_WITH_A_SECRET_KEY
|
||||||
|
|
||||||
# Optional
|
# Optional
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|||||||
@@ -2,28 +2,22 @@ import js from '@eslint/js'
|
|||||||
import globals from 'globals'
|
import globals from 'globals'
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{js,jsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
reactHooks.configs.flat.recommended,
|
reactHooks.configs.flat.recommended,
|
||||||
reactRefresh.configs.vite,
|
reactRefresh.configs.vite,
|
||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 'latest',
|
|
||||||
ecmaFeatures: { jsx: true },
|
|
||||||
sourceType: 'module',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
|
||||||
<title>VolleyManager</title>
|
<title>VolleyManager</title>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<body class="transition bg-zinc-50 dark:bg-zinc-950">
|
<body class="transition bg-zinc-50 dark:bg-zinc-950">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -5,9 +5,9 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
"build": "vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "tsc -b && eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview --host",
|
||||||
"test": "vitest"
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -21,9 +21,10 @@
|
|||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.4",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/node": "^25.4.0",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
@@ -32,10 +33,13 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.1.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript-eslint": "^8.57.0",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
|
"vite-plugin-svgr": "^5.0.0",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 966 B After Width: | Height: | Size: 966 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
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 { render, waitFor } from '@testing-library/react';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
|
// Setup mocks
|
||||||
const localStorageMock = {
|
const localStorageMock = {
|
||||||
getItem: vi.fn(),
|
getItem: vi.fn(),
|
||||||
setItem: vi.fn(),
|
setItem: vi.fn(),
|
||||||
@@ -11,15 +12,18 @@ const localStorageMock = {
|
|||||||
clear: vi.fn(),
|
clear: vi.fn(),
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
};
|
};
|
||||||
global.localStorage = localStorageMock;
|
|
||||||
|
|
||||||
global.fetch = vi.fn(() =>
|
const fetchMock = vi.fn(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({}),
|
json: () => Promise.resolve({}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Use Vitest's stubGlobal for type-safe environment mocking
|
||||||
|
vi.stubGlobal('localStorage', localStorageMock);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
describe('App Component', () => {
|
describe('App Component', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -27,7 +31,6 @@ describe('App Component', () => {
|
|||||||
document.documentElement.style.backgroundColor = '';
|
document.documentElement.style.backgroundColor = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notice the "async" keyword added here
|
|
||||||
it('renders the App and applies the light theme by default', async () => {
|
it('renders the App and applies the light theme by default', async () => {
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
@@ -38,7 +41,7 @@ describe('App Component', () => {
|
|||||||
|
|
||||||
// 2. Wait for the asynchronous fetch to finish so React doesn't complain
|
// 2. Wait for the asynchronous fetch to finish so React doesn't complain
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(global.fetch).toHaveBeenCalled();
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
// frontend/src/App.jsx
|
// frontend/src/App.tsx
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import Layout from './components/Layout/Layout';
|
import Layout from './components/Layout/Layout';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
|
import Courts from './pages/Courts';
|
||||||
import Tournament from './pages/Tournament';
|
import Tournament from './pages/Tournament';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [darkMode, setDarkMode] = useState(() => localStorage.theme === 'dark');
|
const [darkMode, setDarkMode] = useState<boolean>(() => localStorage.theme === 'dark');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = window.document.documentElement;
|
const root = window.document.documentElement;
|
||||||
@@ -34,6 +35,7 @@ export default function App() {
|
|||||||
{/* Main App Layout */}
|
{/* Main App Layout */}
|
||||||
<Route element={<Layout darkMode={darkMode} setDarkMode={setDarkMode} />}>
|
<Route element={<Layout darkMode={darkMode} setDarkMode={setDarkMode} />}>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/courts" element={<Courts />} />
|
||||||
<Route path="/tournaments/:id" element={<Tournament />} />
|
<Route path="/tournaments/:id" element={<Tournament />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<path d="M431.789,47.747H272.387V32.384c0-9.043-7.331-16.383-16.383-16.383s-16.383,7.34-16.383,16.383v15.362H80.211 C35.988,47.747,0,83.734,0,127.967v256.067c0,44.232,35.988,80.22,80.211,80.22h159.41v15.354 c0,9.052,7.331,16.392,16.383,16.392c9.043,0,16.374-7.339,16.383-16.392v-15.354h159.401c44.232,0,80.211-35.988,80.211-80.22 V127.967C512,83.734,476.012,47.747,431.789,47.747z M80.211,431.496c-26.172,0-47.454-21.291-47.454-47.454V127.976 c0-26.172,21.291-47.454,47.454-47.454v-0.009h159.41v350.983H80.211z M479.243,384.042c0,26.172-21.291,47.454-47.454,47.454 H272.387V80.513h159.401c26.172,0,47.454,21.291,47.454,47.454V384.042z"/>
|
||||||
|
<path d="M351.738,93.994c-9.052,0-16.383,7.331-16.383,16.383v291.238c0,9.052,7.331,16.392,16.383,16.392 c9.044,0,16.383-7.34,16.383-16.392V110.377C368.121,101.333,360.79,93.994,351.738,93.994z"/>
|
||||||
|
<path d="M160.271,93.994c-9.052,0-16.383,7.331-16.383,16.383v291.238c0,9.052,7.331,16.392,16.383,16.392 c9.043,0,16.374-7.34,16.383-16.392V110.377C176.654,101.333,169.324,93.994,160.271,93.994z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -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 { Check } from 'lucide-react';
|
||||||
|
import { type MatchData } from '../../types';
|
||||||
import { printName, stringToColor } from '../../utils/helpers';
|
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 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';
|
let borderClass = 'border-zinc-300 dark:border-zinc-700';
|
||||||
if (isFinished) borderClass = 'border-orange-500 ring-2 ring-orange-500/10';
|
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-pointer hover:shadow-md hover:-translate-y-0.5'
|
||||||
: 'cursor-default opacity-100';
|
: 'cursor-default opacity-100';
|
||||||
|
|
||||||
|
const refName = match.ref_team?.name || match.ref_label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={`match-${match.id}`}
|
id={`match-${match.id}`}
|
||||||
onClick={() => canInteract && onClick(match)}
|
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">
|
<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 && (
|
{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}
|
{match.court}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -33,16 +42,16 @@ export default function MatchCard({ match, onClick }) {
|
|||||||
{isFinished ? (
|
{isFinished ? (
|
||||||
<Check className="text-orange-500 print:hidden" size={14} strokeWidth={3} />
|
<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>
|
||||||
|
|
||||||
<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.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 }
|
{ 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) => (
|
].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>
|
<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>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,25 @@
|
|||||||
// frontend/src/components/Dashboard/DashCard.jsx
|
// frontend/src/components/Dashboard/DashCard.tsx
|
||||||
|
|
||||||
import { MapPin, SlidersHorizontal, Users } from 'lucide-react';
|
import { SlidersHorizontal, Users } from 'lucide-react';
|
||||||
|
import CourtIcon from '../../assets/court.svg?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 (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => onSelect(t.id)}
|
onClick={() => onSelect(t.id)}
|
||||||
@@ -29,7 +46,7 @@ export default function DashCard({ t, isAdmin, onSelect, onEdit }) {
|
|||||||
{t.team_count} Teams
|
{t.team_count} Teams
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 font-medium">
|
<div className="flex items-center gap-1.5 font-medium">
|
||||||
<MapPin size={16} className="text-orange-500" />
|
<CourtIcon width={16} height={16} className="text-orange-500" />
|
||||||
{t.court_count} Courts
|
{t.court_count} Courts
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
// frontend/src/components/Forms/TournamentForm.jsx
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Loader2 } from 'lucide-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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!tournamentId) {
|
|
||||||
setInitialData(null);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchSettings = async () => {
|
|
||||||
try {
|
|
||||||
const data = await api.get(`/tournaments/${tournamentId}/settings`);
|
|
||||||
setInitialData(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setError("Failed to load tournament settings.");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchSettings();
|
|
||||||
}, [tournamentId]);
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const formData = new FormData(e.target);
|
|
||||||
|
|
||||||
const rawTeams = formData.get('teams');
|
|
||||||
const rawCourts = formData.get('courts');
|
|
||||||
const date = formData.get('date');
|
|
||||||
const startTime = formData.get('start_time');
|
|
||||||
const typeRaw = formData.get('type');
|
|
||||||
const duration = formData.get('duration');
|
|
||||||
const name = formData.get('name');
|
|
||||||
const code = formData.get('code');
|
|
||||||
|
|
||||||
const teams = rawTeams.split('\n').map(t => t.trim()).filter(t => t.length > 0);
|
|
||||||
const courts = rawCourts.split(',').map(c => c.trim()).filter(c => c.length > 0);
|
|
||||||
|
|
||||||
if (teams.length < 2) {
|
|
||||||
setError("At least 2 teams required.");
|
|
||||||
setIsSubmitting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timestamp = `${date}T${startTime}:00`;
|
|
||||||
const formattedType = typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1);
|
|
||||||
const parsedDuration = parseInt(duration);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (tournamentId) {
|
|
||||||
const basePayload = {
|
|
||||||
name,
|
|
||||||
code,
|
|
||||||
type: formattedType,
|
|
||||||
timestamp,
|
|
||||||
duration: parsedDuration
|
|
||||||
};
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
api.patch(`/tournaments/${tournamentId}`, basePayload),
|
|
||||||
api.patch(`/tournaments/${tournamentId}/teams`, teams),
|
|
||||||
api.patch(`/tournaments/${tournamentId}/courts`, courts)
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
const fullPayload = {
|
|
||||||
name,
|
|
||||||
code,
|
|
||||||
type: formattedType,
|
|
||||||
timestamp,
|
|
||||||
duration: parsedDuration,
|
|
||||||
teams,
|
|
||||||
courts
|
|
||||||
};
|
|
||||||
await api.post('/tournaments', fullPayload);
|
|
||||||
}
|
|
||||||
onSuccess();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
if (Array.isArray(err.detail)) {
|
|
||||||
setError(err.detail.map(e => `${e.loc.join('.')}: ${e.msg}`).join(', '));
|
|
||||||
} else {
|
|
||||||
setError(typeof err.detail === 'string' ? err.detail : "Error saving tournament");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center items-center p-12">
|
|
||||||
<Loader2 className="animate-spin text-orange-600" size={32} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let defaultDate = new Date().toISOString().split('T')[0];
|
|
||||||
let defaultTime = "09:00";
|
|
||||||
|
|
||||||
if (initialData?.timestamp) {
|
|
||||||
const [d, t] = initialData.timestamp.split('T');
|
|
||||||
defaultDate = d;
|
|
||||||
defaultTime = t ? t.substring(0, 5) : "09:00";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form key={initialData?.id || 'new'} onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
{error && (
|
|
||||||
<div className="text-xs text-red-500 dark:text-red-400 text-center mb-4 bg-red-50 dark:bg-red-900/10 p-2 rounded border border-red-200 dark:border-red-900/30">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-bold text-zinc-500 uppercase">Name</label>
|
|
||||||
<input
|
|
||||||
name="name"
|
|
||||||
defaultValue={initialData?.name}
|
|
||||||
required
|
|
||||||
placeholder="My Awesome Tournament"
|
|
||||||
autoFocus
|
|
||||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="font-bold text-zinc-500 text-xs uppercase">Code</label>
|
|
||||||
<input
|
|
||||||
name="code"
|
|
||||||
defaultValue={initialData?.code}
|
|
||||||
required
|
|
||||||
placeholder="••••"
|
|
||||||
autoComplete="off"
|
|
||||||
className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 text-center font-mono focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="font-bold text-zinc-500 text-xs uppercase">Type</label>
|
|
||||||
<select
|
|
||||||
name="type"
|
|
||||||
defaultValue={initialData?.type?.toLowerCase() || "double"}
|
|
||||||
className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
|
||||||
>
|
|
||||||
<option value="double">Double Elimination</option>
|
|
||||||
<option value="single">Single Elimination</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-4">
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Duration</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
name="duration"
|
|
||||||
defaultValue={initialData?.duration || 30}
|
|
||||||
min="0"
|
|
||||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Start Time</label>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
name="start_time"
|
|
||||||
defaultValue={defaultTime}
|
|
||||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Date</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
name="date"
|
|
||||||
defaultValue={defaultDate}
|
|
||||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-bold text-zinc-500 uppercase">Courts</label>
|
|
||||||
<input
|
|
||||||
name="courts"
|
|
||||||
placeholder="Center Court, Court 1"
|
|
||||||
defaultValue={initialData?.courts?.map(c => c.name || c).join(', ')}
|
|
||||||
required
|
|
||||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-bold text-zinc-500 uppercase">Teams</label>
|
|
||||||
<textarea
|
|
||||||
name="teams"
|
|
||||||
placeholder="One team per line..."
|
|
||||||
defaultValue={initialData?.teams?.map(t => t.name || t).join('\n')}
|
|
||||||
rows={5}
|
|
||||||
required
|
|
||||||
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 font-mono text-sm focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between mt-4 pt-4 border-t border-zinc-200 dark:border-zinc-800 flex-wrap gap-y-4">
|
|
||||||
{tournamentId && onDelete && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onDelete(tournamentId)}
|
|
||||||
className="text-red-500 text-sm hover:underline h-5 self-end"
|
|
||||||
>
|
|
||||||
Delete Tournament
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!tournamentId && <div className="hidden"></div>}
|
|
||||||
|
|
||||||
<button
|
|
||||||
disabled={isSubmitting}
|
|
||||||
type="submit"
|
|
||||||
className="bg-orange-600 hover:bg-orange-500 text-white px-6 py-2 rounded font-bold shadow-lg shadow-orange-900/20 ml-auto transition active:scale-95 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isSubmitting ? 'Saving...' : (tournamentId ? 'Save Changes' : 'Create')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
// frontend/src/components/Forms/TournamentForm.tsx
|
||||||
|
|
||||||
|
import { Loader2, Plus } from 'lucide-react';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface Court {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TournamentSettings {
|
||||||
|
id: string | number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type: string;
|
||||||
|
timestamp: string;
|
||||||
|
duration: number;
|
||||||
|
courts: Court[];
|
||||||
|
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 [globalCourts, setGlobalCourts] = useState<Court[]>([]);
|
||||||
|
const [selectedCourts, setSelectedCourts] = useState<number[]>([]);
|
||||||
|
const [newCourtName, setNewCourtName] = useState('');
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDependencies = async () => {
|
||||||
|
try {
|
||||||
|
const courtsRes = await api.get<Court[]>('/courts');
|
||||||
|
setGlobalCourts(courtsRes);
|
||||||
|
|
||||||
|
if (tournamentId) {
|
||||||
|
const data = await api.get<TournamentSettings>(`/tournaments/${tournamentId}/settings`);
|
||||||
|
setInitialData(data);
|
||||||
|
setSelectedCourts(data.courts.map(c => c.id));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError("Failed to load tournament data.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void fetchDependencies();
|
||||||
|
}, [tournamentId]);
|
||||||
|
|
||||||
|
const handleAddCourt = async () => {
|
||||||
|
if (!newCourtName.trim()) return;
|
||||||
|
try {
|
||||||
|
const added = await api.post<Court>('/courts', { name: newCourtName.trim() });
|
||||||
|
setGlobalCourts([...globalCourts, added]);
|
||||||
|
setSelectedCourts([...selectedCourts, added.id]);
|
||||||
|
setNewCourtName('');
|
||||||
|
} catch {
|
||||||
|
setError("Failed to create new court.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCourt = (id: number) => {
|
||||||
|
setSelectedCourts(prev =>
|
||||||
|
prev.includes(id) ? prev.filter(c => c !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
|
||||||
|
const rawTeams = formData.get('teams') 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);
|
||||||
|
|
||||||
|
if (teams.length < 2) {
|
||||||
|
setError("At least 2 teams required.");
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCourts.length === 0) {
|
||||||
|
setError("Please select at least one court.");
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = `${date}T${startTime}:00`;
|
||||||
|
const formattedType = typeRaw.charAt(0).toUpperCase() + typeRaw.slice(1);
|
||||||
|
const parsedDuration = parseInt(duration, 10);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (tournamentId) {
|
||||||
|
const basePayload = {
|
||||||
|
name, code, type: formattedType, timestamp, duration: parsedDuration
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
api.patch(`/tournaments/${tournamentId}`, basePayload),
|
||||||
|
api.patch(`/tournaments/${tournamentId}/teams`, teams),
|
||||||
|
api.patch(`/tournaments/${tournamentId}/courts`, selectedCourts)
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
const fullPayload = {
|
||||||
|
name, code, type: formattedType, timestamp, duration: parsedDuration,
|
||||||
|
teams, courts: selectedCourts
|
||||||
|
};
|
||||||
|
await api.post('/tournaments', fullPayload);
|
||||||
|
}
|
||||||
|
onSuccess();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(err);
|
||||||
|
setError("Error saving tournament");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center p-12">
|
||||||
|
<Loader2 className="animate-spin text-orange-600" size={32} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaultDate = new Date().toISOString().split('T')[0];
|
||||||
|
let defaultTime = "09:00";
|
||||||
|
|
||||||
|
if (initialData?.timestamp) {
|
||||||
|
const [d, t] = initialData.timestamp.split('T');
|
||||||
|
defaultDate = d;
|
||||||
|
defaultTime = t ? t.substring(0, 5) : "09:00";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form key={initialData?.id || 'new'} onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="text-xs text-red-500 dark:text-red-400 text-center mb-4 bg-red-50 dark:bg-red-900/10 p-2 rounded border border-red-200 dark:border-red-900/30">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-zinc-500 uppercase">Name</label>
|
||||||
|
<input name="name" defaultValue={initialData?.name} required placeholder="My Awesome Tournament" autoFocus className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="font-bold text-zinc-500 text-xs uppercase">Code</label>
|
||||||
|
<input name="code" defaultValue={initialData?.code} required placeholder="••••" autoComplete="off" className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 text-center font-mono focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="font-bold text-zinc-500 text-xs uppercase">Type</label>
|
||||||
|
<select name="type" defaultValue={initialData?.type?.toLowerCase() || "double"} className="w-full h-10 bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white">
|
||||||
|
<option value="double">Double Elimination</option>
|
||||||
|
<option value="single">Single Elimination</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-4">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Duration</label>
|
||||||
|
<input type="number" name="duration" defaultValue={initialData?.duration || 30} min="0" className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Start Time</label>
|
||||||
|
<input type="time" name="start_time" defaultValue={defaultTime} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<label className="text-xs font-bold text-zinc-500 uppercase mb-1 block">Date</label>
|
||||||
|
<input type="date" name="date" defaultValue={defaultDate} className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-800 rounded p-2 h-10 text-base appearance-none focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- NEW: GLOBAL COURT SELECTOR --- */}
|
||||||
|
<div className="p-3 bg-zinc-50 dark:bg-zinc-900/50 rounded-lg border border-zinc-200 dark:border-zinc-800">
|
||||||
|
<label className="text-xs font-bold text-zinc-500 uppercase mb-2 block">Venue Courts</label>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
{globalCourts.map(c => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleCourt(c.id)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-xs font-bold uppercase tracking-wider transition ${selectedCourts.includes(c.id)
|
||||||
|
? 'bg-orange-500 text-white shadow-md'
|
||||||
|
: 'bg-zinc-200 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-300 dark:hover:bg-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{globalCourts.length === 0 && <span className="text-xs italic text-zinc-400">No courts registered yet.</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newCourtName}
|
||||||
|
onChange={(e) => setNewCourtName(e.target.value)}
|
||||||
|
placeholder="Add new court..."
|
||||||
|
className="flex-1 bg-white dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-700 rounded px-3 py-1 text-sm focus:border-orange-500 outline-none text-zinc-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={handleAddCourt} className="bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300 px-3 py-1 rounded transition flex items-center">
|
||||||
|
<Plus size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-zinc-500 uppercase">Teams</label>
|
||||||
|
<textarea
|
||||||
|
name="teams"
|
||||||
|
placeholder="One team per line..."
|
||||||
|
defaultValue={initialData?.teams?.map(t => t.name || t).join('\n')}
|
||||||
|
rows={5}
|
||||||
|
required
|
||||||
|
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-600 rounded p-2 font-mono text-sm focus:border-orange-500 outline-none transition text-zinc-900 dark:text-white resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between mt-4 pt-4 border-t border-zinc-200 dark:border-zinc-800 flex-wrap gap-y-4">
|
||||||
|
{tournamentId && onDelete && (
|
||||||
|
<button type="button" onClick={() => onDelete(tournamentId)} className="text-red-500 text-sm hover:underline h-5 self-end">
|
||||||
|
Delete Tournament
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!tournamentId && <div className="hidden"></div>}
|
||||||
|
|
||||||
|
<button disabled={isSubmitting} type="submit" className="bg-orange-600 hover:bg-orange-500 text-white px-6 py-2 rounded font-bold shadow-lg shadow-orange-900/20 ml-auto transition active:scale-95 disabled:opacity-50">
|
||||||
|
{isSubmitting ? 'Saving...' : (tournamentId ? 'Save Changes' : 'Create')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,45 +0,0 @@
|
|||||||
// frontend/src/components/Layout/Navbar.jsx
|
|
||||||
|
|
||||||
import { Lock, LogOut, Volleyball } from 'lucide-react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
export default function Navbar({ title, subtitle, isAdmin, onLogout }) {
|
|
||||||
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">
|
|
||||||
<Link to="/" className="flex items-center gap-2 sm:gap-4 cursor-pointer group select-none shrink-0">
|
|
||||||
<div className="p-1.5 sm:p-2.5 bg-orange-600 rounded-xl group-hover:rotate-12 transition-transform shadow-lg shadow-orange-600/30 active:scale-90">
|
|
||||||
<Volleyball className="text-white" size={20} />
|
|
||||||
</div>
|
|
||||||
<div className="hidden sm:block">
|
|
||||||
<h1 className="text-2xl font-black tracking-tighter leading-none text-zinc-900 dark:text-white">VolleyManager</h1>
|
|
||||||
<p className="text-[9px] font-black text-zinc-500 dark:text-zinc-400 uppercase tracking-widest mt-0.5">Tournament Ops</p>
|
|
||||||
</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">
|
|
||||||
{title || 'Dashboard'}
|
|
||||||
</div>
|
|
||||||
{subtitle && (
|
|
||||||
<div className="text-[8px] sm:text-[10px] font-black text-zinc-400 dark:text-zinc-500 uppercase tracking-widest leading-none">
|
|
||||||
{subtitle}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 sm:gap-4 items-center shrink-0">
|
|
||||||
{isAdmin ? (
|
|
||||||
<>
|
|
||||||
<button onClick={onLogout} title="Sign Out" className="text-zinc-400 hover:text-red-500 transition active:scale-90 shrink-0">
|
|
||||||
<LogOut size={18} className="sm:size-[22px]" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Link to="/login" className="text-orange-600 font-black flex items-center gap-1.5 text-[9px] sm:text-[10px] uppercase tracking-widest hover:text-orange-500 transition group p-1.5 sm:p-2 rounded-xl hover:bg-orange-50 dark:hover:bg-orange-950/20">
|
|
||||||
<Lock size={12} className="sm:size-[14px] group-hover:-translate-y-0.5 transition-transform" /> <span className="hidden xs:inline">Login</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// frontend/src/components/Layout/Navbar.tsx
|
||||||
|
|
||||||
|
import { Lock, LogOut, Volleyball } from 'lucide-react';
|
||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import CourtIcon from '../../assets/court.svg?react';
|
||||||
|
|
||||||
|
interface NavbarProps {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Navbar({ title, subtitle, isAuthenticated, onLogout }: NavbarProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="bg-white dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-800 px-4 py-3 flex items-center justify-between shadow-sm sticky top-0 z-50">
|
||||||
|
|
||||||
|
{/* Left: Logo & Main Navigation */}
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<Link to="/" className="flex items-center gap-2 shrink-0">
|
||||||
|
<div className="p-2 bg-orange-600 rounded-xl shadow-lg shadow-orange-600/20">
|
||||||
|
<Volleyball className="text-white" size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<h1 className="text-lg font-black text-zinc-900 dark:text-white leading-none">VolleyManager</h1>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link to="/courts" className="md:hidden p-2 text-zinc-600 dark:text-zinc-400 bg-zinc-100 hover:bg-zinc-300 dark:hover:bg-zinc-800 rounded-lg">
|
||||||
|
<CourtIcon width={20} height={20} />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="hidden md:flex items-center gap-1 bg-zinc-100 dark:bg-zinc-800 p-1 rounded-lg">
|
||||||
|
<Link to="/" className={`px-4 py-1.5 rounded-md text-xs font-black uppercase tracking-wider transition ${location.pathname === '/' ? 'bg-white dark:bg-zinc-700 shadow-sm text-orange-600' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-200'}`}>
|
||||||
|
Tournaments
|
||||||
|
</Link>
|
||||||
|
<Link to="/courts" className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-xs font-black uppercase tracking-wider transition ${location.pathname === '/courts' ? 'bg-orange-600 text-white shadow-md' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-200'}`}>
|
||||||
|
Courts
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center: Title (Responsive truncation) */}
|
||||||
|
<div className="absolute left-1/2 -translate-x-1/2 text-center hidden sm:block pointer-events-none">
|
||||||
|
<div className="font-black uppercase text-xs tracking-[0.2em] text-zinc-900 dark:text-white truncate max-w-37.5 md:max-w-62.5">
|
||||||
|
{title || 'Dashboard'}
|
||||||
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div className="text-[9px] font-bold text-zinc-400 uppercase tracking-widest mt-0.5">
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Actions */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<button onClick={onLogout} className="p-2 text-zinc-400 hover:text-red-500 transition rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||||
|
<LogOut size={20} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link to="/login" className="flex items-center gap-2 px-4 py-2 bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400 rounded-lg text-xs font-black uppercase tracking-wider hover:bg-orange-100 transition">
|
||||||
|
<Lock size={14} /> Login
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { CheckCircle, Pencil, Plus, Trophy } from 'lucide-react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { type MatchData } from '../../types';
|
||||||
import { printName, stringToColor } from '../../utils/helpers';
|
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 courtColor = stringToColor(m.court);
|
||||||
const isFinished = m.isFinished;
|
const isFinished = m.isFinished;
|
||||||
|
const refName = m.ref_team?.name || m.ref_label;
|
||||||
|
|
||||||
return (
|
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 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="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-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-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}
|
{m.court}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col gap-0.5 min-w-0 justify-center md:justify-between">
|
<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">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{p.win && <Trophy size={14} className="text-orange-500 shrink-0 print:hidden" />}
|
{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={`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)}
|
{printName(p.n)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ACTION BUTTON AREA */}
|
{/* 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">
|
<div className="ml-3 flex items-stretch shrink-0 print:hidden py-0.5">
|
||||||
{isFinished ? (
|
{isFinished ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => onMatchClick(m)}
|
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"
|
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`}>
|
<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}
|
{m.p1_sets}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-0.5 h-3 bg-zinc-200 dark:bg-zinc-700 rounded-full md:hidden my-1" />
|
||||||
{/* 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={`${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`}>
|
<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}
|
{m.p2_sets}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* --- DESKTOP VIEW (Default + Hover Edit) --- */}
|
|
||||||
<div className="hidden md:flex group-hover/btn:hidden flex-col items-end">
|
<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">
|
<div className="text-orange-500 font-black text-[10px] uppercase flex items-center gap-1">
|
||||||
<CheckCircle size={12} strokeWidth={3} /> Finished
|
<CheckCircle size={12} strokeWidth={3} /> Finished
|
||||||
@@ -96,6 +102,13 @@ export default function ScheduleRow({ match: m, onMatchClick, badgeWidth }) {
|
|||||||
{isFinished ? m.p2_sets : ''}
|
{isFinished ? m.p2_sets : ''}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
// frontend/src/components/Schedule/ScheduleView.jsx
|
// frontend/src/components/Schedule/ScheduleView.tsx
|
||||||
|
|
||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { type MatchData } from '../../types';
|
||||||
import ScheduleRow from './ScheduleRow';
|
import ScheduleRow from './ScheduleRow';
|
||||||
|
|
||||||
export default function ScheduleView({ schedule, onMatchClick }) {
|
interface ScheduleViewProps {
|
||||||
const [filter, setFilter] = useState("");
|
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 longestCourt = schedule.reduce((max, m) => {
|
||||||
const c = m.court || "Court";
|
const c = m.court || "Court";
|
||||||
@@ -15,7 +21,7 @@ export default function ScheduleView({ schedule, onMatchClick }) {
|
|||||||
const badgeWidth = Math.max(80, longestCourt.length * 10);
|
const badgeWidth = Math.max(80, longestCourt.length * 10);
|
||||||
|
|
||||||
const filteredAndSorted = schedule
|
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) => {
|
.sort((a, b) => {
|
||||||
const timeA = a.start_time || a.timestamp || a.time || "";
|
const timeA = a.start_time || a.timestamp || a.time || "";
|
||||||
const timeB = b.start_time || b.timestamp || b.time || "";
|
const timeB = b.start_time || b.timestamp || b.time || "";
|
||||||
@@ -46,7 +52,7 @@ export default function ScheduleView({ schedule, onMatchClick }) {
|
|||||||
</div>
|
</div>
|
||||||
</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="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">
|
<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 { Trophy } from 'lucide-react';
|
||||||
import { printName } from '../../utils/helpers';
|
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 isDoubleElim = matches.some(m => m.bracket === 'Loser');
|
||||||
|
|
||||||
const wb = matches.filter(m => m.bracket === 'Winner').sort((a, b) => a.round - b.round);
|
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 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);
|
const finals = matches.filter(m => m.bracket === 'Finals').sort((a, b) => a.round - b.round);
|
||||||
|
|
||||||
let gfMatch = null;
|
let gfMatch: MatchInfo | null = null;
|
||||||
let resetMatch = null;
|
let resetMatch: MatchInfo | null = null;
|
||||||
let tpMatch = null;
|
let tpMatch: MatchInfo | null = null;
|
||||||
|
|
||||||
if (isDoubleElim) {
|
if (isDoubleElim) {
|
||||||
tpMatch = lb[lb.length - 1];
|
tpMatch = lb[lb.length - 1] || null;
|
||||||
gfMatch = finals[0];
|
gfMatch = finals[0] || null;
|
||||||
resetMatch = finals[1];
|
resetMatch = finals[1] || null;
|
||||||
} else {
|
} else {
|
||||||
gfMatch = wb[wb.length - 1];
|
gfMatch = wb[wb.length - 1] || null;
|
||||||
tpMatch = finals[0];
|
tpMatch = finals[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const podium = [
|
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: 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" },
|
{ 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 }
|
{ 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 ---
|
// --- CALCULATE 3RD PLACE ---
|
||||||
if (tpMatch) {
|
if (tpMatch) {
|
||||||
if (tpMatch.isFinished && tpMatch.winnerName) {
|
if (tpMatch.isFinished && tpMatch.winnerName) {
|
||||||
// If match is done, grab the actual team name
|
|
||||||
podium[2].team = isDoubleElim
|
podium[2].team = isDoubleElim
|
||||||
? (tpMatch.winnerName === tpMatch.p1 ? tpMatch.p2 : tpMatch.p1) // DE: Loser of LB Final
|
? (tpMatch.winnerName === tpMatch.p1 ? tpMatch.p2 : tpMatch.p1)
|
||||||
: tpMatch.winnerName; // SE: Winner of 3rd Place Match
|
: tpMatch.winnerName;
|
||||||
podium[2].isReal = true;
|
podium[2].isReal = true;
|
||||||
} else {
|
} else {
|
||||||
// Set the exact string expected by the print formatter
|
|
||||||
podium[2].team = isDoubleElim ? `Loser of #${tpMatch.number}` : `Winner of #${tpMatch.number}`;
|
podium[2].team = isDoubleElim ? `Loser of #${tpMatch.number}` : `Winner of #${tpMatch.number}`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -53,46 +66,40 @@ export default function Podium({ matches }) {
|
|||||||
podium[1].isReal = true;
|
podium[1].isReal = true;
|
||||||
} else if (gfMatch) {
|
} else if (gfMatch) {
|
||||||
if (gfMatch.isFinished && gfMatch.winnerName) {
|
if (gfMatch.isFinished && gfMatch.winnerName) {
|
||||||
// Has the loser bracket champ won, forcing a reset?
|
|
||||||
const isResetForced = resetMatch && resetMatch.hasTeams;
|
const isResetForced = resetMatch && resetMatch.hasTeams;
|
||||||
|
|
||||||
if (!isResetForced) {
|
if (!isResetForced) {
|
||||||
// GF Winner is 1st, GF Loser is 2nd
|
|
||||||
podium[0].team = gfMatch.winnerName;
|
podium[0].team = gfMatch.winnerName;
|
||||||
podium[0].isReal = true;
|
podium[0].isReal = true;
|
||||||
podium[1].team = gfMatch.winnerName === gfMatch.p1 ? gfMatch.p2 : gfMatch.p1;
|
podium[1].team = gfMatch.winnerName === gfMatch.p1 ? gfMatch.p2 : gfMatch.p1;
|
||||||
podium[1].isReal = true;
|
podium[1].isReal = true;
|
||||||
} else {
|
} else {
|
||||||
// Reset forced, wait for the final match
|
podium[0].team = `Winner of #${resetMatch?.number || 'TBD'}`;
|
||||||
podium[0].team = `Winner of #${resetMatch.number}`;
|
podium[1].team = `Loser of #${resetMatch?.number || 'TBD'}`;
|
||||||
podium[1].team = `Loser of #${resetMatch.number}`;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// GF not finished yet
|
|
||||||
podium[0].team = `Winner of #${gfMatch.number}`;
|
podium[0].team = `Winner of #${gfMatch.number}`;
|
||||||
podium[1].team = `Loser of #${gfMatch.number}`;
|
podium[1].team = `Loser of #${gfMatch.number}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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-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">
|
<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">
|
<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
|
<Trophy size={14} className="text-orange-500 print:text-black!" /> Final Standings
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
{podium.filter(p => !p.hidden).map(p => (
|
{podium.filter(p => !p.hidden).map(p => (
|
||||||
<div key={p.rank} className="flex items-center gap-3">
|
<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}
|
{p.rank}
|
||||||
</div>
|
</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}
|
{p.team}
|
||||||
</div>
|
</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)}
|
{printName(p.team)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,25 +1,75 @@
|
|||||||
// frontend/src/components/Tournament/ScoreModal.jsx
|
// frontend/src/components/Tournament/ScoreModal.tsx
|
||||||
|
|
||||||
import { Clock, Eraser, MapPin, Trophy } from 'lucide-react';
|
import { type SetData } from '../../types';
|
||||||
|
import { Clock, Eraser, Trophy, Loader2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Modal from '../UI/Modal';
|
import Modal from '../UI/Modal';
|
||||||
|
import WhistleIcon from "../../assets/whistle.svg?react"
|
||||||
|
import CourtIcon from '../../assets/court.svg?react';
|
||||||
|
|
||||||
const ScoreForm = ({ match, isAdmin, onSubmit, onClear }) => {
|
interface MatchData {
|
||||||
// Safe init of sets
|
id: string | number;
|
||||||
const [sets, setSets] = useState(match.sets && match.sets.length ? match.sets : [{ p1: '', p2: '' }]);
|
number: number;
|
||||||
const [code, setCode] = useState('');
|
time: string;
|
||||||
const [error, setError] = useState(null);
|
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 [isSubmitting, setIsSubmitting] = useState<boolean>(false); // <-- NEW: Lock state
|
||||||
|
|
||||||
|
const refName = match.ref_team?.name || match.ref_label;
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try { await onSubmit(match.id, sets, code); }
|
if (isSubmitting) return; // Prevent double-execution
|
||||||
catch (err) { setError(typeof err.detail === 'string' ? err.detail : "Check code or scores"); }
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
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");
|
||||||
|
setIsSubmitting(false); // Only re-enable the button if it failed (if it succeeds, modal closes)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeSet = (idx) => {
|
const handleClear = async () => {
|
||||||
|
if (isSubmitting) return; // Prevent double-execution
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onClear(match.id, code);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { detail?: string };
|
||||||
|
setError(typeof error?.detail === 'string' ? error.detail : "Failed to clear score");
|
||||||
|
setIsSubmitting(false); // Re-enable on error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSet = (idx: number) => {
|
||||||
if (sets.length > 1) setSets(sets.filter((_, i) => i !== idx));
|
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];
|
const n = [...sets];
|
||||||
n[idx][field] = parseInt(val) || 0;
|
n[idx][field] = parseInt(val) || 0;
|
||||||
setSets(n);
|
setSets(n);
|
||||||
@@ -27,26 +77,44 @@ const ScoreForm = ({ match, isAdmin, onSubmit, onClear }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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 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">
|
<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>
|
<span>{match.time || "10:00"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-4 w-px bg-zinc-300 dark:bg-zinc-800" />
|
<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">
|
<div className="flex items-center gap-2 text-sm font-mono text-zinc-900 dark:text-white">
|
||||||
<MapPin className="text-orange-500" size={18} />
|
<CourtIcon className="text-orange-500" width={16} height={16} />
|
||||||
<span>{match.court || "TBD"}</span>
|
<span>{match.court || "TBD"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Dedicated Ref Row (Always visible) */}
|
||||||
|
{refName && (
|
||||||
|
<div className={`flex justify-center items-center gap-2 p-2.5 rounded-lg border transition-colors ${match.ref_team
|
||||||
|
? 'bg-orange-50 dark:bg-orange-900/10 border-orange-100 dark:border-orange-900/30'
|
||||||
|
: 'bg-zinc-50 dark:bg-zinc-950 border-gray-200 dark:border-zinc-800'
|
||||||
|
}`}>
|
||||||
|
<WhistleIcon className={`${match.ref_team ? 'text-orange-500' : 'text-zinc-400 dark:text-zinc-600'} shrink-0`} width={14} height={14} />
|
||||||
|
<span className={`truncate ${match.ref_team
|
||||||
|
? 'text-xs font-bold text-orange-700 dark:text-orange-500 uppercase tracking-wide'
|
||||||
|
: 'text-sm italic text-zinc-500 dark:text-zinc-400 font-medium'
|
||||||
|
}`}>
|
||||||
|
{refName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{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">
|
<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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isAdmin && (
|
{!isAuthenticated && (
|
||||||
<div className="bg-zinc-50 dark:bg-zinc-950 p-4 rounded-lg border border-gray-200 dark:border-zinc-800">
|
<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>
|
<label className="block text-xs font-bold text-orange-500 uppercase mb-2">Tournament Code</label>
|
||||||
<input
|
<input
|
||||||
@@ -61,9 +129,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="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="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>
|
||||||
|
|
||||||
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||||||
@@ -106,7 +174,6 @@ const ScoreForm = ({ match, isAdmin, onSubmit, onClear }) => {
|
|||||||
className="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-800 p-2 rounded text-center focus:border-orange-500 outline-none text-zinc-900 dark:text-white"
|
className="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-800 p-2 rounded text-center focus:border-orange-500 outline-none text-zinc-900 dark:text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -117,31 +184,47 @@ const ScoreForm = ({ match, isAdmin, onSubmit, onClear }) => {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{match.isFinished && (
|
{match.isFinished && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onClear(match.id, code)}
|
onClick={handleClear}
|
||||||
className="w-1/3 bg-red-100 dark:bg-red-900/50 hover:bg-red-200 dark:hover:bg-red-900 text-red-600 dark:text-red-300 py-3 rounded-lg font-bold transition text-sm"
|
disabled={isSubmitting}
|
||||||
|
className={`w-1/3 bg-red-100 dark:bg-red-900/50 hover:bg-red-200 dark:hover:bg-red-900 text-red-600 dark:text-red-300 py-3 rounded-lg font-bold transition text-sm flex justify-center items-center ${isSubmitting ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
Clear
|
{isSubmitting ? <Loader2 size={16} className="animate-spin" /> : 'Clear'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className={`${match.isFinished ? 'w-2/3' : 'w-full'} bg-orange-600 hover:bg-orange-500 py-3 rounded-lg font-bold shadow-lg shadow-orange-900/20 transition text-white active:scale-95`}
|
disabled={isSubmitting}
|
||||||
|
className={`${match.isFinished ? 'w-2/3' : 'w-full'} bg-orange-600 hover:bg-orange-500 py-3 rounded-lg font-bold shadow-lg shadow-orange-900/20 transition text-white active:scale-95 flex justify-center items-center gap-2 ${isSubmitting ? 'opacity-70 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
Submit Result
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
<span>Saving...</span>
|
||||||
|
</>
|
||||||
|
) : 'Submit Result'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
if (!isOpen || !match) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={`Match #${match.number}`} icon={Trophy}>
|
<Modal isOpen={isOpen} onClose={onClose} title={`Match #${match.number}`} icon={Trophy}>
|
||||||
<ScoreForm
|
<ScoreForm
|
||||||
match={match}
|
match={match}
|
||||||
isAdmin={isAdmin}
|
isAuthenticated={isAuthenticated}
|
||||||
onSubmit={async (id, sets, code) => {
|
onSubmit={async (id, sets, code) => {
|
||||||
await onSubmit(id, sets, code);
|
await onSubmit(id, sets, code);
|
||||||
onClose();
|
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;
|
if (!isOpen) return null;
|
||||||
return (
|
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="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-lg border border-zinc-300 dark:border-zinc-800 max-h-[90vh] overflow-y-auto">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h2 className="text-xl font-black text-zinc-900 dark:text-white flex items-start gap-2">
|
<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} />}
|
{Icon && <Icon weight="duotone" className="text-orange-500 mt-1 shrink-0" size={24} />}
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
</h2>
|
</h2>
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
// frontend/src/components/UI/ThemeButton.jsx
|
// frontend/src/components/UI/ThemeButton.tsx
|
||||||
|
|
||||||
import { Moon, Sun } from 'lucide-react';
|
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 (
|
return (
|
||||||
<div className="fixed bottom-6 sm:bottom-8 right-6 sm:right-8 z-40 print:hidden">
|
<div className="fixed bottom-6 sm:bottom-8 right-6 sm:right-8 z-40 print:hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDarkMode(!darkMode)}
|
onClick={() => setDarkMode(!darkMode)}
|
||||||
title="Toggle Theme"
|
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" />}
|
{darkMode ? <Sun size={24} strokeWidth={2.5} className="sm:size-7" /> : <Moon size={24} strokeWidth={2.5} className="sm:size-7" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
/* frontend/src/index.css */
|
/* frontend/src/index.css */
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|||||||
@@ -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>,
|
|
||||||
)
|
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
// frontend/src/pages/Courts.tsx
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
import { Loader2, Trash, Plus } from 'lucide-react';
|
||||||
|
import { useOutletContext, Link } from 'react-router-dom';
|
||||||
|
import api from '../services/api';
|
||||||
|
import WhistleIcon from '../assets/whistle.svg?react';
|
||||||
|
|
||||||
|
import CourtIcon from '../assets/court.svg?react';
|
||||||
|
|
||||||
|
interface CourtMatch {
|
||||||
|
id: string;
|
||||||
|
tournament_id: string;
|
||||||
|
tournament_name: string;
|
||||||
|
time: string;
|
||||||
|
duration: number;
|
||||||
|
status: string;
|
||||||
|
match_number: number;
|
||||||
|
p1: string;
|
||||||
|
p2: string;
|
||||||
|
p1_is_real: boolean;
|
||||||
|
p2_is_real: boolean;
|
||||||
|
p1_sets: number;
|
||||||
|
p2_sets: number;
|
||||||
|
ref_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CourtSchedule {
|
||||||
|
court: string;
|
||||||
|
matches: CourtMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobalCourt {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OutletContext {
|
||||||
|
setNavTitle: (t: string) => void;
|
||||||
|
setNavSubtitle: (s: string) => void;
|
||||||
|
role: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CourtColumn = ({ courtId, onDelete, role }: { courtId: number, onDelete: (id: number) => void, role: string | null }) => {
|
||||||
|
const [data, setData] = useState<CourtSchedule | null>(null);
|
||||||
|
const [currentTime, setCurrentTime] = useState<string>('');
|
||||||
|
|
||||||
|
// Auto-scroll refs
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [hasScrolled, setHasScrolled] = useState(false);
|
||||||
|
|
||||||
|
// Keep the current time perfectly updated
|
||||||
|
useEffect(() => {
|
||||||
|
const updateTime = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const hours = now.getHours().toString().padStart(2, '0');
|
||||||
|
const minutes = now.getMinutes().toString().padStart(2, '0');
|
||||||
|
setCurrentTime(`${hours}:${minutes}`);
|
||||||
|
};
|
||||||
|
updateTime();
|
||||||
|
const interval = setInterval(updateTime, 60000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSchedule = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<CourtSchedule>(`/courts/${courtId}/schedule`);
|
||||||
|
setData(res);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void fetchSchedule();
|
||||||
|
}, [courtId]);
|
||||||
|
|
||||||
|
let isDayFinished = false;
|
||||||
|
let activeIndex = -1;
|
||||||
|
|
||||||
|
if (data && data.matches.length > 0 && currentTime) {
|
||||||
|
const [currH, currM] = currentTime.split(':').map(Number);
|
||||||
|
const currTotalMins = currH * 60 + currM;
|
||||||
|
|
||||||
|
for (let i = 0; i < data.matches.length; i++) {
|
||||||
|
const m = data.matches[i];
|
||||||
|
if (!m.time) continue;
|
||||||
|
|
||||||
|
const [mH, mM] = m.time.split(':').map(Number);
|
||||||
|
const mStartTotalMins = mH * 60 + mM;
|
||||||
|
const duration = m.duration || 30;
|
||||||
|
const mEndTotalMins = mStartTotalMins + duration;
|
||||||
|
|
||||||
|
if (currTotalMins >= mStartTotalMins && currTotalMins < mEndTotalMins) {
|
||||||
|
activeIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lastMatch = data.matches[data.matches.length - 1];
|
||||||
|
if (lastMatch.time) {
|
||||||
|
const [lastH, lastM] = lastMatch.time.split(':').map(Number);
|
||||||
|
const lastStartTotal = lastH * 60 + lastM;
|
||||||
|
const lastDuration = lastMatch.duration || 30;
|
||||||
|
const lastEndTotal = lastStartTotal + lastDuration;
|
||||||
|
|
||||||
|
if (currTotalMins >= lastEndTotal) {
|
||||||
|
isDayFinished = true;
|
||||||
|
activeIndex = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AUTO-SCROLL LOGIC
|
||||||
|
useEffect(() => {
|
||||||
|
if (data && currentTime && !hasScrolled && scrollContainerRef.current) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (isDayFinished) {
|
||||||
|
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
|
const line = container.querySelector('.now-line') as HTMLElement;
|
||||||
|
if (line) {
|
||||||
|
container.scrollTo({
|
||||||
|
top: line.offsetTop - 20,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setHasScrolled(true);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}, [data, currentTime, hasScrolled, isDayFinished]);
|
||||||
|
|
||||||
|
if (!data) return <div className="w-80 shrink-0 bg-zinc-50 dark:bg-zinc-900/50 rounded-2xl flex items-center justify-center border border-zinc-200 dark:border-zinc-800"><Loader2 className="animate-spin text-orange-500" /></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-72 md:w-80 shrink-0 flex flex-col h-full bg-zinc-100/50 dark:bg-zinc-900/20 rounded-2xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
|
||||||
|
<div className="p-3 md:p-4 bg-zinc-100 dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-800 flex justify-between items-center gap-3 shrink-0">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<CourtIcon className="text-orange-500 shrink-0" width={20} height={20} />
|
||||||
|
<h3 className="font-black text-base md:text-lg text-zinc-900 dark:text-white uppercase tracking-wider truncate">{data.court}</h3>
|
||||||
|
</div>
|
||||||
|
{role === 'admin' && (
|
||||||
|
<button onClick={() => onDelete(courtId)} className="text-zinc-400 hover:text-red-500 transition p-1.5 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30 active:scale-90" title="Delete Court">
|
||||||
|
<Trash size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-3 space-y-2.5 pb-32 relative">
|
||||||
|
{data.matches.length === 0 && (
|
||||||
|
<div className="text-center text-sm font-bold uppercase text-zinc-400 dark:text-zinc-600 mt-10">No matches</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.matches.map((m, index) => {
|
||||||
|
const isFinished = m.status === 'Finished';
|
||||||
|
const isPastSlot = isDayFinished || (activeIndex !== -1 && index < activeIndex);
|
||||||
|
const isLive = !isDayFinished && index === activeIndex;
|
||||||
|
const showNowLine = !isDayFinished && ((activeIndex !== -1 && index === activeIndex) || (activeIndex === -1 && index === 0));
|
||||||
|
|
||||||
|
let borderStyle = 'border-zinc-200 dark:border-zinc-800 hover:-translate-y-1 hover:shadow-md';
|
||||||
|
let textOpacity = 'text-zinc-900 dark:text-white';
|
||||||
|
let pOpacity = 'text-zinc-800 dark:text-zinc-200';
|
||||||
|
|
||||||
|
if (isFinished || isPastSlot || isDayFinished) {
|
||||||
|
borderStyle = 'border-zinc-300 dark:border-zinc-700 opacity-50 grayscale hover:opacity-100 hover:grayscale-0';
|
||||||
|
textOpacity = 'text-zinc-500';
|
||||||
|
pOpacity = 'text-zinc-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLive) {
|
||||||
|
borderStyle = 'border-orange-500 ring-1 ring-orange-500/20 shadow-sm hover:-translate-y-1 hover:shadow-md';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={m.id}>
|
||||||
|
{/* --- THE --NOW-- LINE --- */}
|
||||||
|
{showNowLine && (
|
||||||
|
<div className="now-line relative flex items-center py-3 animate-in fade-in" style={{ scrollMarginTop: '20px' }}>
|
||||||
|
<div className="flex-1 border-t-2 border-red-500 rounded-full"></div>
|
||||||
|
<div className="mx-2 text-[10px] font-black text-red-600 uppercase tracking-widest bg-red-100 dark:bg-red-900/30 px-3 py-1 rounded-full border border-red-200 dark:border-red-900/50 shadow-sm">Now</div>
|
||||||
|
<div className="flex-1 border-t-2 border-red-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to={`/tournaments/${m.tournament_id}`}
|
||||||
|
className={`match-card block bg-white dark:bg-zinc-950 p-2.5 rounded-lg border transition-all ${borderStyle}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`text-base font-black font-mono leading-none ${textOpacity}`}>{m.time}</div>
|
||||||
|
{isLive && (
|
||||||
|
<div className="flex items-center gap-1 bg-orange-100 dark:bg-orange-900/40 text-orange-600 dark:text-orange-500 px-1.5 py-0.5 rounded-full border border-orange-200 dark:border-orange-800/50">
|
||||||
|
<span className="w-1.5 h-1.5 bg-orange-500 rounded-full animate-pulse"></span>
|
||||||
|
<span className="text-[7px] font-black uppercase tracking-widest">Live</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[7px] font-black uppercase text-zinc-500 bg-zinc-100 dark:bg-zinc-900 px-1.5 py-0.5 rounded truncate max-w-22.5">
|
||||||
|
{m.tournament_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-0.5 mb-1.5">
|
||||||
|
<div className="flex justify-between items-center text-xs leading-tight">
|
||||||
|
<span className={`truncate pr-2
|
||||||
|
${isFinished && m.p1_sets > m.p2_sets ? 'font-bold text-orange-500' :
|
||||||
|
m.p1_is_real ? `font-bold ${pOpacity}` :
|
||||||
|
'italic text-zinc-400 dark:text-zinc-500 font-normal'}`}
|
||||||
|
>
|
||||||
|
{m.p1}
|
||||||
|
</span>
|
||||||
|
{isFinished && <span className="text-[10px] font-black font-mono bg-zinc-100 dark:bg-zinc-900 px-1.5 py-0.5 rounded text-zinc-600 dark:text-zinc-400">{m.p1_sets}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-xs leading-tight">
|
||||||
|
<span className={`truncate pr-2
|
||||||
|
${isFinished && m.p2_sets > m.p1_sets ? 'font-bold text-orange-500' :
|
||||||
|
m.p2_is_real ? `font-bold ${pOpacity}` :
|
||||||
|
'italic text-zinc-400 dark:text-zinc-500 font-normal'}`}
|
||||||
|
>
|
||||||
|
{m.p2}
|
||||||
|
</span>
|
||||||
|
{isFinished && <span className="text-[10px] font-black font-mono bg-zinc-100 dark:bg-zinc-900 px-1.5 py-0.5 rounded text-zinc-600 dark:text-zinc-400">{m.p2_sets}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{m.ref_name && !isFinished && (
|
||||||
|
<div className="mt-1.5 pt-1.5 border-t border-zinc-100 dark:border-zinc-800 flex items-center gap-1 text-[8px] font-bold text-zinc-500 dark:text-zinc-400">
|
||||||
|
<WhistleIcon className="text-orange-500" width={10} height={10} />
|
||||||
|
<span className="uppercase tracking-widest truncate">{m.ref_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* --- THE LINE AT THE VERY END --- */}
|
||||||
|
{isDayFinished && (
|
||||||
|
<div className="now-line relative flex items-center py-6 animate-in fade-in">
|
||||||
|
<div className="flex-1 border-t border-zinc-300 dark:border-zinc-700"></div>
|
||||||
|
<div className="mx-3 text-[10px] font-black text-zinc-500 uppercase tracking-widest bg-zinc-100 dark:bg-zinc-800 px-3 py-1 rounded-full border border-zinc-200 dark:border-zinc-700 shadow-sm">
|
||||||
|
All Matched played for today
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 border-t border-zinc-300 dark:border-zinc-700"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Courts() {
|
||||||
|
const { setNavTitle, setNavSubtitle, role } = useOutletContext<OutletContext>();
|
||||||
|
const [courts, setCourts] = useState<GlobalCourt[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [newCourtName, setNewCourtName] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNavTitle('Courts');
|
||||||
|
setNavSubtitle('');
|
||||||
|
|
||||||
|
const fetchCourts = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<GlobalCourt[]>('/courts');
|
||||||
|
setCourts(res);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void fetchCourts();
|
||||||
|
}, [setNavTitle, setNavSubtitle]);
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!window.confirm("Are you sure you want to delete this court? It will remove it from all tournaments and auto-reschedule them.")) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/courts/${id}`);
|
||||||
|
setCourts(courts.filter(c => c.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete court", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCourt = async () => {
|
||||||
|
if (!newCourtName.trim()) return;
|
||||||
|
try {
|
||||||
|
const added = await api.post<GlobalCourt>('/courts', { name: newCourtName.trim() });
|
||||||
|
setCourts([...courts, added]);
|
||||||
|
setNewCourtName('');
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create new court", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="flex h-full items-center justify-center"><Loader2 className="animate-spin text-orange-600" size={48} /></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full overflow-hidden flex flex-col bg-white dark:bg-zinc-950 pt-8 pb-8">
|
||||||
|
<div className="flex-1 overflow-x-auto overflow-y-hidden px-6 md:px-12">
|
||||||
|
<div className="flex h-full gap-6 pb-4 min-w-max">
|
||||||
|
{courts.map(c => (
|
||||||
|
<CourtColumn key={c.id} courtId={c.id} onDelete={handleDelete} role={role} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{courts.length === 0 && role !== 'admin' && (
|
||||||
|
<div className="text-center text-zinc-500 font-bold uppercase mt-20 w-full">
|
||||||
|
No physical courts registered yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- ADMIN PANEL: Add New Court Inline --- */}
|
||||||
|
{role === 'admin' && (
|
||||||
|
<div className="w-72 md:w-80 shrink-0 flex flex-col h-full bg-zinc-100/30 dark:bg-zinc-900/10 rounded-2xl border-2 border-dashed border-zinc-300 dark:border-zinc-800 p-6 items-center justify-center gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-zinc-200 dark:bg-zinc-800 flex items-center justify-center text-zinc-400">
|
||||||
|
<CourtIcon width={32} height={32} />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-zinc-500 uppercase text-center">Add New Court</div>
|
||||||
|
<div className="flex flex-col w-full gap-3 mt-2">
|
||||||
|
<input
|
||||||
|
value={newCourtName}
|
||||||
|
onChange={(e) => setNewCourtName(e.target.value)}
|
||||||
|
placeholder="Court name..."
|
||||||
|
className="w-full bg-white dark:bg-zinc-950 border border-zinc-300 dark:border-zinc-700 rounded-xl px-4 py-3 text-sm focus:border-orange-500 outline-none text-zinc-900 dark:text-white shadow-sm"
|
||||||
|
/>
|
||||||
|
<button onClick={handleAddCourt} className="w-full bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 text-zinc-700 dark:text-zinc-300 py-3 rounded-xl font-bold uppercase text-xs tracking-wider transition flex items-center justify-center gap-2 active:scale-95">
|
||||||
|
<Plus size={16} /> Add Court
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,52 +1,104 @@
|
|||||||
// frontend/src/pages/Dashboard.jsx
|
// frontend/src/pages/Dashboard.tsx
|
||||||
|
|
||||||
import { Calendar, ChevronDown, ChevronUp, History, Plus } from 'lucide-react';
|
import { Calendar, ChevronDown, ChevronUp, History, Plus, PlusCircle, SlidersHorizontal, Loader2 } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { useNavigate, useOutletContext } from 'react-router-dom';
|
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||||
import DashCard from '../components/Dashboard/DashCard';
|
import DashCard from '../components/Dashboard/DashCard';
|
||||||
import TournamentForm from '../components/Forms/TournamentForm';
|
import TournamentForm from '../components/Forms/TournamentForm';
|
||||||
import Modal from '../components/UI/Modal';
|
import Modal from '../components/UI/Modal';
|
||||||
import api, { WS_URL } from '../services/api';
|
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() {
|
export default function Dashboard() {
|
||||||
const { setNavTitle, setNavSubtitle, isAdmin, showSettings, setShowSettings } = useOutletContext();
|
const { setNavTitle, setNavSubtitle, role, showSettings, setShowSettings } = useOutletContext<OutletContextType>();
|
||||||
|
|
||||||
const [tournaments, setTournaments] = useState([]);
|
const [tournaments, setTournaments] = useState<TournamentType[]>([]);
|
||||||
const [editTarget, setEditTarget] = useState(null);
|
const [editTarget, setEditTarget] = useState<TournamentType | null>(null);
|
||||||
const [showPast, setShowPast] = useState(false);
|
const [showPast, setShowPast] = useState<boolean>(false);
|
||||||
const [showAllFuture, setShowAllFuture] = useState(false);
|
const [showAllFuture, setShowAllFuture] = useState<boolean>(false);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const loadDashboard = async () => {
|
const loadDashboard = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/tournaments');
|
const res = await api.get<{ items?: TournamentType[] } | TournamentType[]>('/tournaments');
|
||||||
const list = Array.isArray(res) ? res : (res.items || []);
|
const list = Array.isArray(res) ? res : (res?.items || []);
|
||||||
setTournaments(list);
|
setTournaments(list as TournamentType[]);
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) {
|
||||||
};
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNavTitle('Dashboard');
|
setNavTitle('Dashboard');
|
||||||
setNavSubtitle('');
|
setNavSubtitle('');
|
||||||
loadDashboard();
|
|
||||||
localStorage.removeItem('volley_view');
|
localStorage.removeItem('volley_view');
|
||||||
|
|
||||||
let ws;
|
(async () => {
|
||||||
|
await loadDashboard();
|
||||||
|
})();
|
||||||
|
|
||||||
|
let ws: WebSocket;
|
||||||
|
let reconnectTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
try {
|
try {
|
||||||
ws = new WebSocket(WS_URL);
|
ws = new WebSocket(WS_URL);
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e: MessageEvent) => {
|
||||||
const msg = JSON.parse(e.data);
|
const msg = JSON.parse(e.data);
|
||||||
if (msg.type === 'dashboard_update') loadDashboard();
|
if (msg.type === 'dashboard_update') loadDashboard();
|
||||||
};
|
};
|
||||||
} catch (err) { }
|
ws.onclose = () => {
|
||||||
|
reconnectTimeout = setTimeout(connect, 3000);
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error("WebSocket connection failed", err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
connect();
|
connect();
|
||||||
return () => { if (ws) ws.close(); };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleEdit = (t) => {
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
(async () => {
|
||||||
|
await loadDashboard();
|
||||||
|
})();
|
||||||
|
if (!ws || ws.readyState === WebSocket.CLOSED) {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
if (ws) {
|
||||||
|
ws.onclose = null;
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [setNavTitle, setNavSubtitle, loadDashboard]);
|
||||||
|
|
||||||
|
const handleEdit = (t: TournamentType) => {
|
||||||
setEditTarget(t);
|
setEditTarget(t);
|
||||||
setShowSettings(true);
|
setShowSettings(true);
|
||||||
};
|
};
|
||||||
@@ -57,28 +109,40 @@ export default function Dashboard() {
|
|||||||
loadDashboard();
|
loadDashboard();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
const handleDelete = async (id: string | number) => {
|
||||||
if (window.confirm("Purge this tournament and all its history?")) {
|
if (window.confirm("Purge this tournament and all its history?")) {
|
||||||
await api.delete(`/tournaments/${id}`);
|
await api.delete(`/tournaments/${id}`);
|
||||||
handleSuccess();
|
handleSuccess();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Grouping Logic
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<Loader2 className="animate-spin text-orange-600" size={48} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const groups = { live: [], future: [], past: [] };
|
const groups: { live: TournamentType[]; future: TournamentType[]; past: TournamentType[] } = {
|
||||||
|
live: [],
|
||||||
|
future: [],
|
||||||
|
past: []
|
||||||
|
};
|
||||||
|
|
||||||
tournaments.forEach(t => {
|
tournaments.forEach(t => {
|
||||||
const tDate = new Date(t.timestamp);
|
const tDate = new Date(t.timestamp);
|
||||||
const isToday = tDate.toDateString() === now.toDateString();
|
const isToday = tDate.toDateString() === now.toDateString();
|
||||||
|
|
||||||
if (tDate > now && !isToday) groups.future.push(t);
|
if (tDate > now && !isToday) groups.future.push(t);
|
||||||
else if (isToday) groups.live.push(t);
|
else if (isToday) groups.live.push(t);
|
||||||
else groups.past.push(t);
|
else groups.past.push(t);
|
||||||
});
|
});
|
||||||
|
|
||||||
groups.future.sort((a, b) => new Date(a.timestamp) - new Date(b.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) - new Date(b.timestamp));
|
groups.live.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||||
groups.past.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
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);
|
const futureShow = showAllFuture ? groups.future : groups.future.slice(0, 4);
|
||||||
|
|
||||||
@@ -86,8 +150,7 @@ export default function Dashboard() {
|
|||||||
<div className="h-full overflow-y-auto pt-8 sm:pt-12 pb-32">
|
<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">
|
<div className="container mx-auto max-w-5xl px-4 space-y-12">
|
||||||
|
|
||||||
{/* Create Button */}
|
{role === 'admin' && (
|
||||||
{isAdmin && (
|
|
||||||
<div className="flex justify-center md:justify-end">
|
<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">
|
<button onClick={() => { setEditTarget(null); setShowSettings(true); }} className="bg-orange-600 hover:bg-orange-500 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 text-[10px] font-black uppercase tracking-wider shadow-xl shadow-orange-600/20 active:scale-95 transition">
|
||||||
<Plus size={16} strokeWidth={4} /> Create
|
<Plus size={16} strokeWidth={4} /> Create
|
||||||
@@ -98,13 +161,13 @@ export default function Dashboard() {
|
|||||||
{groups.live.length > 0 && (
|
{groups.live.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-xs font-black text-green-500 uppercase tracking-[0.2em] mb-6 flex items-center gap-3">
|
<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 className="relative flex size-3">
|
||||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
|
<span className="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 inline-flex size-3 rounded-full bg-green-500"></span>
|
||||||
</span> Live Events
|
</span> Live Events
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{groups.live.map(t => <DashCard key={t.id} t={t} isAdmin={isAdmin} 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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -116,11 +179,11 @@ export default function Dashboard() {
|
|||||||
{groups.future.length > 0 ? (
|
{groups.future.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{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>
|
</div>
|
||||||
{groups.future.length > 4 && (
|
{groups.future.length > 4 && (
|
||||||
<div className="mt-8 text-center">
|
<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 ? 'Show Less' : `Show All (${groups.future.length})`}
|
||||||
{showAllFuture ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
{showAllFuture ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
</button>
|
</button>
|
||||||
@@ -138,7 +201,7 @@ export default function Dashboard() {
|
|||||||
</button>
|
</button>
|
||||||
{showPast && (
|
{showPast && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 opacity-75 hover:opacity-100 transition-opacity">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -1,26 +1,32 @@
|
|||||||
// frontend/src/pages/Login.jsx
|
// frontend/src/pages/Login.tsx
|
||||||
|
|
||||||
import { Loader2, Volleyball } from 'lucide-react';
|
import { Loader2, Volleyball } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
|
||||||
|
interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const formData = new FormData(e.target);
|
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.postForm('/auth/token', formData);
|
const res = await api.postForm<TokenResponse>('/auth/token', formData);
|
||||||
localStorage.setItem('volleyToken', res.access_token);
|
localStorage.setItem('volleyToken', res.access_token);
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (err) {
|
} catch {
|
||||||
setError('Invalid credentials.');
|
setError('Invalid credentials.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -37,8 +43,12 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-2xl font-black text-center text-zinc-900 dark:text-white tracking-tight mb-2">Admin Access</h1>
|
<h1 className="text-2xl font-black text-center text-zinc-900 dark:text-white tracking-tight mb-2">
|
||||||
<p className="text-center text-zinc-500 text-sm font-medium mb-8">Enter administrative credentials</p>
|
Staff Login
|
||||||
|
</h1>
|
||||||
|
<p className="text-center text-zinc-500 text-sm font-medium mb-8">
|
||||||
|
Enter staff credentials
|
||||||
|
</p>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-bold uppercase tracking-wide rounded-xl text-center border border-red-100 dark:border-red-900/50">
|
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-bold uppercase tracking-wide rounded-xl text-center border border-red-100 dark:border-red-900/50">
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
// frontend/src/pages/Tournament.jsx
|
|
||||||
|
|
||||||
import { CalendarDays, Loader2, Network, SlidersHorizontal } from 'lucide-react';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useOutletContext, useParams } from 'react-router-dom';
|
|
||||||
import BracketView from '../components/Bracket/BracketView';
|
|
||||||
import TournamentForm from '../components/Forms/TournamentForm';
|
|
||||||
import ScheduleView from '../components/Schedule/ScheduleView';
|
|
||||||
import ScoreModal from '../components/Tournament/ScoreModal';
|
|
||||||
import Modal from '../components/UI/Modal';
|
|
||||||
import api, { WS_URL } from '../services/api';
|
|
||||||
|
|
||||||
export default function Tournament() {
|
|
||||||
const { id } = useParams();
|
|
||||||
const { setNavTitle, setNavSubtitle, isAdmin } = useOutletContext();
|
|
||||||
|
|
||||||
const [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 handleViewChange = (newView) => {
|
|
||||||
setView(newView);
|
|
||||||
localStorage.setItem('volley_view', newView);
|
|
||||||
};
|
|
||||||
|
|
||||||
const processMatches = (rawMatches, courts, teams) => {
|
|
||||||
if (!rawMatches) return [];
|
|
||||||
|
|
||||||
const gf1 = rawMatches.find(m =>
|
|
||||||
m.bracket_type === 'Finals' &&
|
|
||||||
m.winner_next_match_id &&
|
|
||||||
m.winner_next_match_id === m.loser_next_match_id
|
|
||||||
);
|
|
||||||
|
|
||||||
let skippedResetMatchId = null;
|
|
||||||
if (gf1 && gf1.status === 'Finished' && gf1.winner_team_id === gf1.p1_team_id) {
|
|
||||||
skippedResetMatchId = gf1.winner_next_match_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const courtMap = Object.fromEntries(courts.map(c => [c.id, c.name]));
|
|
||||||
const teamMap = Object.fromEntries(teams.map(t => [t.id, t.name]));
|
|
||||||
|
|
||||||
const incoming = {};
|
|
||||||
rawMatches.forEach(m => {
|
|
||||||
const num = m.match_number;
|
|
||||||
if (m.winner_next_match_id) {
|
|
||||||
(incoming[m.winner_next_match_id] = incoming[m.winner_next_match_id] || []).push({ label: `Winner of #${num}`, id: m.id });
|
|
||||||
}
|
|
||||||
if (m.loser_next_match_id) {
|
|
||||||
(incoming[m.loser_next_match_id] = incoming[m.loser_next_match_id] || []).push({ label: `Loser of #${num}`, id: m.id });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return rawMatches
|
|
||||||
.filter(m => m.id !== skippedResetMatchId)
|
|
||||||
.map(m => {
|
|
||||||
const sources = incoming[m.id] || [];
|
|
||||||
|
|
||||||
let sourceIndex = 0;
|
|
||||||
const p1 = m.p1_team_id ? teamMap[m.p1_team_id] : (sources[sourceIndex++]?.label || 'TBD');
|
|
||||||
const p2 = m.p2_team_id ? teamMap[m.p2_team_id] : (sources[sourceIndex++]?.label || 'TBD');
|
|
||||||
|
|
||||||
const hasTeams = !!(m.p1_team_id && m.p2_team_id);
|
|
||||||
const isFinished = m.status === "Finished";
|
|
||||||
const isReady = hasTeams && !isFinished;
|
|
||||||
const winnerName = m.winner_team_id ? teamMap[m.winner_team_id] : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...m,
|
|
||||||
bracket: m.bracket_type,
|
|
||||||
round: m.round_number,
|
|
||||||
number: m.match_number,
|
|
||||||
p1,
|
|
||||||
p2,
|
|
||||||
winnerName,
|
|
||||||
p1_is_real: !!m.p1_team_id,
|
|
||||||
p2_is_real: !!m.p2_team_id,
|
|
||||||
isReady,
|
|
||||||
court: courtMap[m.court_id] || 'TBD',
|
|
||||||
time: m.start_time ? new Date(m.start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '',
|
|
||||||
p1_sets: m.sets?.filter(s => s.p1 > s.p2).length || 0,
|
|
||||||
p2_sets: m.sets?.filter(s => s.p2 > s.p1).length || 0,
|
|
||||||
hasTeams,
|
|
||||||
isFinished
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
|
||||||
const res = await api.get(`/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); }
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData();
|
|
||||||
if (wsRef.current) return;
|
|
||||||
const connect = () => {
|
|
||||||
const ws = new WebSocket(WS_URL);
|
|
||||||
wsRef.current = ws;
|
|
||||||
ws.onmessage = (e) => {
|
|
||||||
const msg = JSON.parse(e.data);
|
|
||||||
if (msg.type === 'tournament_update' && msg.id === id) fetchData();
|
|
||||||
};
|
|
||||||
ws.onclose = () => { wsRef.current = null; };
|
|
||||||
};
|
|
||||||
connect();
|
|
||||||
return () => { if (wsRef.current?.readyState === 1) wsRef.current.close(); wsRef.current = null; };
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
const handleDeleteTournament = async (tId) => {
|
|
||||||
if (window.confirm("Purge this tournament?")) {
|
|
||||||
await api.delete(`/tournaments/${tId}`);
|
|
||||||
window.location.href = '/';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <div className="flex h-full items-center justify-center"><Loader2 className="animate-spin text-orange-600" size={48} /></div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col print:h-auto print:block">
|
|
||||||
<div className="border-b border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-6 py-3 flex justify-between items-center shrink-0 z-20 print:hidden">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button onClick={() => handleViewChange('bracket')} className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider transition ${view === 'bracket' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'}`}>
|
|
||||||
<Network size={16} /> Bracket
|
|
||||||
</button>
|
|
||||||
<button onClick={() => handleViewChange('schedule')} className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider transition ${view === 'schedule' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'}`}>
|
|
||||||
<CalendarDays size={16} /> Schedule
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{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>}
|
|
||||||
</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} />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{scoreMatch && (
|
|
||||||
<ScoreModal
|
|
||||||
isOpen={!!scoreMatch}
|
|
||||||
onClose={() => setScoreMatch(null)}
|
|
||||||
match={scoreMatch}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
onClear={async (mid, c) => {
|
|
||||||
await api.delete(`/tournaments/${id}/matches/${mid}/score?code=${encodeURIComponent(c || '')}`);
|
|
||||||
setScoreMatch(null);
|
|
||||||
}}
|
|
||||||
onSubmit={async (mid, s, c) => {
|
|
||||||
const method = scoreMatch.isFinished ? 'patch' : 'post';
|
|
||||||
await api[method](`/tournaments/${id}/matches/${mid}/score`, { sets: s, code: c });
|
|
||||||
setScoreMatch(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Modal isOpen={showSettings} onClose={() => setShowSettings(false)} title="Edit Tournament">
|
|
||||||
<TournamentForm tournamentId={id} onSuccess={() => { setShowSettings(false); fetchData(); }} onDelete={handleDeleteTournament} />
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
// frontend/src/pages/Tournament.tsx
|
||||||
|
|
||||||
|
import { type SetData } from '../types';
|
||||||
|
import { CalendarDays, Loader2, Network, SlidersHorizontal } from 'lucide-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';
|
||||||
|
import ScheduleView from '../components/Schedule/ScheduleView';
|
||||||
|
import ScoreModal from '../components/Tournament/ScoreModal';
|
||||||
|
import Modal from '../components/UI/Modal';
|
||||||
|
import api, { WS_URL } from '../services/api';
|
||||||
|
|
||||||
|
// --- 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;
|
||||||
|
winner_next_match_slot?: number | null;
|
||||||
|
loser_next_match_id?: string | number | null;
|
||||||
|
loser_next_match_slot?: number | null;
|
||||||
|
p1_team_id?: string | number | null;
|
||||||
|
p2_team_id?: string | number | null;
|
||||||
|
winner_team_id?: string | number | null;
|
||||||
|
ref_team_id?: string | number | null;
|
||||||
|
ref_label?: string | null;
|
||||||
|
ref_team?: { id: string | number; name: string } | null;
|
||||||
|
status: string;
|
||||||
|
court_id: string | number;
|
||||||
|
start_time?: string;
|
||||||
|
sets?: { p1: number; p2: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessedMatch extends Omit<RawMatch, 'ref_label' | 'ref_team'> {
|
||||||
|
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;
|
||||||
|
ref_label?: string;
|
||||||
|
ref_team?: { id: string | number; name: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OutletContextType {
|
||||||
|
setNavTitle: (title: string) => void;
|
||||||
|
setNavSubtitle: (subtitle: string) => void;
|
||||||
|
role: 'admin' | 'ref' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Tournament() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { setNavTitle, setNavSubtitle, role } = useOutletContext<OutletContextType>();
|
||||||
|
|
||||||
|
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: string) => {
|
||||||
|
setView(newView);
|
||||||
|
localStorage.setItem('volley_view', newView);
|
||||||
|
};
|
||||||
|
|
||||||
|
const processMatches = (rawMatches: RawMatch[], courts: Court[], teams: Team[]): ProcessedMatch[] => {
|
||||||
|
if (!rawMatches) return [];
|
||||||
|
|
||||||
|
const gf1 = rawMatches.find(m =>
|
||||||
|
m.bracket_type === 'Finals' &&
|
||||||
|
m.winner_next_match_id &&
|
||||||
|
m.winner_next_match_id === m.loser_next_match_id
|
||||||
|
);
|
||||||
|
|
||||||
|
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 || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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]));
|
||||||
|
|
||||||
|
return rawMatches
|
||||||
|
.filter(m => m.id !== skippedResetMatchId)
|
||||||
|
.map(m => {
|
||||||
|
|
||||||
|
// BUG 1 FIX: Explicitly check the SLOT (0 for top, 1 for bottom)
|
||||||
|
// to prevent duplicated labels.
|
||||||
|
const getPlaceholder = (slot: number) => {
|
||||||
|
const winParent = rawMatches.find(rm => rm.winner_next_match_id === m.id && rm.winner_next_match_slot === slot);
|
||||||
|
if (winParent) return `Winner of #${winParent.match_number}`;
|
||||||
|
|
||||||
|
const loseParent = rawMatches.find(rm => rm.loser_next_match_id === m.id && rm.loser_next_match_slot === slot);
|
||||||
|
if (loseParent) return `Loser of #${loseParent.match_number}`;
|
||||||
|
|
||||||
|
return 'TBD';
|
||||||
|
};
|
||||||
|
|
||||||
|
const p1 = m.p1_team_id ? teamMap[m.p1_team_id] : getPlaceholder(0);
|
||||||
|
const p2 = m.p2_team_id ? teamMap[m.p2_team_id] : getPlaceholder(1);
|
||||||
|
|
||||||
|
// BUG 2 FIX: Resolve Backend Graph Links ("W:uuid" / "L:uuid") for Referees
|
||||||
|
let resolvedRefLabel = m.ref_label;
|
||||||
|
if (resolvedRefLabel && resolvedRefLabel.includes(':')) {
|
||||||
|
const [outcome, refId] = resolvedRefLabel.split(':');
|
||||||
|
const refMatch = rawMatches.find(rm => String(rm.id) === String(refId));
|
||||||
|
if (refMatch) {
|
||||||
|
resolvedRefLabel = `${outcome === 'W' ? 'Winner' : 'Loser'} of #${refMatch.match_number}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTeams = !!(m.p1_team_id && m.p2_team_id);
|
||||||
|
const isFinished = m.status === "Finished";
|
||||||
|
const isReady = hasTeams && !isFinished;
|
||||||
|
const winnerName = m.winner_team_id ? teamMap[m.winner_team_id] : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
ref_label: resolvedRefLabel || undefined,
|
||||||
|
ref_team: m.ref_team || undefined,
|
||||||
|
bracket: m.bracket_type,
|
||||||
|
round: m.round_number,
|
||||||
|
number: m.match_number,
|
||||||
|
p1,
|
||||||
|
p2,
|
||||||
|
winnerName,
|
||||||
|
p1_is_real: !!m.p1_team_id,
|
||||||
|
p2_is_real: !!m.p2_team_id,
|
||||||
|
isReady,
|
||||||
|
court: courtMap[m.court_id] || 'TBD',
|
||||||
|
time: m.start_time ? new Date(m.start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '',
|
||||||
|
p1_sets: m.sets?.filter(s => s.p1 > s.p2).length || 0,
|
||||||
|
p2_sets: m.sets?.filter(s => s.p2 > s.p1).length || 0,
|
||||||
|
hasTeams,
|
||||||
|
isFinished
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}, [id, setNavTitle, setNavSubtitle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
await fetchData();
|
||||||
|
})();
|
||||||
|
|
||||||
|
let reconnectTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
const ws = new WebSocket(WS_URL);
|
||||||
|
wsRef.current = ws;
|
||||||
|
ws.onmessage = (e: MessageEvent) => {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.type === 'tournament_update' && msg.id === id) {
|
||||||
|
(async () => {
|
||||||
|
await fetchData();
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
wsRef.current = null;
|
||||||
|
reconnectTimeout = setTimeout(connect, 3000);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
connect();
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
(async () => {
|
||||||
|
await fetchData();
|
||||||
|
})();
|
||||||
|
if (!wsRef.current || wsRef.current.readyState === WebSocket.CLOSED) {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.onclose = null;
|
||||||
|
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 = '/';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="flex h-full items-center justify-center"><Loader2 className="animate-spin text-orange-600" size={48} /></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col print:h-auto print:block">
|
||||||
|
<div className="border-b border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-6 py-3 flex justify-between items-center shrink-0 z-20 print:hidden">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => handleViewChange('bracket')} className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider transition ${view === 'bracket' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'}`}>
|
||||||
|
<Network size={16} /> Bracket
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleViewChange('schedule')} className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider transition ${view === 'schedule' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'}`}>
|
||||||
|
<CalendarDays size={16} /> Schedule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{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={(m) => setScoreMatch(m as ProcessedMatch)} />
|
||||||
|
: <ScheduleView schedule={matches} onMatchClick={(m) => setScoreMatch(m as ProcessedMatch)} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scoreMatch && (
|
||||||
|
<ScoreModal
|
||||||
|
isOpen={!!scoreMatch}
|
||||||
|
onClose={() => setScoreMatch(null)}
|
||||||
|
match={scoreMatch}
|
||||||
|
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: 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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Modal isOpen={showSettings} onClose={() => setShowSettings(false)} title="Edit Tournament">
|
||||||
|
<TournamentForm tournamentId={id} onSuccess={() => { setShowSettings(false); fetchData(); }} onDelete={handleDeleteTournament} />
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// frontend/src/vite-env.d.ts
|
||||||
|
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-svgr/client" />
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// frontend/vitest.setup.js
|
|
||||||
|
|
||||||
import '@testing-library/jest-dom/vitest';
|
|
||||||