Squash merge feature/library-reorganization

This commit is contained in:
Codex
2026-04-22 06:46:23 -05:00
parent 7f4a4beb5a
commit fe2a04343c
72 changed files with 14520 additions and 0 deletions

26
.env.example Normal file
View File

@@ -0,0 +1,26 @@
APP_HOST=kif.local.ascorrea.com
PROXY_HTTP_PORT=80
PROXY_HTTPS_PORT=443
POSTGRES_DB=walkup
POSTGRES_USER=walkup
POSTGRES_PASSWORD=walkup
BACKEND_HOST=0.0.0.0
BACKEND_PORT=8000
BACKEND_CORS_ORIGINS=https://kif.local.ascorrea.com
SESSION_COOKIE_NAME=walkup_session
SESSION_COOKIE_SECURE=true
SESSION_SECRET=change-me
LOCAL_ADMIN_USERNAME=admin
LOCAL_ADMIN_PASSWORD=admin
TEAMSNAP_CLIENT_ID_FILE=/run/secrets/teamsnap_client_id
TEAMSNAP_CLIENT_SECRET_FILE=/run/secrets/teamsnap_client_secret
TEAMSNAP_AUTH_URL=https://auth.teamsnap.com/oauth/authorize
TEAMSNAP_TOKEN_URL=https://auth.teamsnap.com/oauth/token
TEAMSNAP_API_ROOT=https://apiv3.teamsnap.com
TEAMSNAP_REDIRECT_URI=https://kif.local.ascorrea.com/api/auth/teamsnap/callback
TEAMSNAP_SCOPE=read
MEDIA_ROOT=./backend/storage
DATABASE_URL=postgresql+psycopg://walkup:walkup@db:5432/walkup
VITE_API_BASE_URL=https://kif.local.ascorrea.com/api

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
.DS_Store
.env
secrets/*.txt
secrets/*.pem
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
backend/.venv/
backend/*.egg-info/
backend/uploads/
backend/normalized/
backend/.pytest_cache/
frontend/node_modules/
frontend/dist/
frontend/.vite/
frontend/vite.config.js
frontend/vite.config.d.ts
logs/
*.sqlite3
*.db

20
PLAN.md Normal file
View File

@@ -0,0 +1,20 @@
# Walkup Implementation Plan
## Scope
- React PWA frontend.
- FastAPI backend.
- TeamSnap JavaScript SDK on the client.
- Server-side TeamSnap OAuth code exchange and refresh.
- Backend stores only app-owned data and TeamSnap external IDs.
## Initial Deliverables
- Thin TeamSnap auth/session backend.
- Media upload and clip registration flow.
- Game assignment and operator session APIs.
- Installable React PWA shell with offline-ready game prep scaffolding.
- Docker-based local development stack.
## Known Constraints
- TeamSnap entities should not be durably mirrored on the backend.
- Operator lineup changes are local-session state in v1.
- Browser clip editing is first-class; backend finalizes playback assets.

64
README.md Normal file
View File

@@ -0,0 +1,64 @@
# Walkup
Walkup is a collaborative baseball walk-up song app built as a React PWA with a FastAPI backend. The browser integrates with TeamSnap through the official JavaScript SDK, while the backend keeps TeamSnap secrets and only stores app-owned media and game state.
## Stack
- Frontend: React, TypeScript, Vite, React Router, TanStack Query, `teamsnap.js`, `vite-plugin-pwa`
- Backend: FastAPI, SQLAlchemy, Pydantic Settings, HTTPX
- Infra: Docker Compose, Postgres, local object-style file storage
## Repository Layout
- `frontend/`: React PWA
- `backend/`: FastAPI API and storage logic
- `PLAN.md`: current implementation scope snapshot
## Local Development
1. Copy `.env.example` to `.env`.
2. Create and activate the host virtualenv if needed:
`python3 -m venv .venv && source .venv/bin/activate`
3. Install backend dependencies:
`pip install -r requirements.txt`
4. Create `secrets/teamsnap_client_id.txt` and `secrets/teamsnap_client_secret.txt`.
5. Put the raw TeamSnap client ID and client secret values into those files.
6. Generate the local TLS certificate and key:
`./scripts/create-dev-certs.sh`
7. Start the stack and capture logs:
`./scripts/dev-up.sh`
8. Open `https://kif.local.ascorrea.com`.
## Dev Logs
- `./scripts/dev-up.sh` starts the stack and appends compose output to `logs/docker-compose.log`.
- `./scripts/dev-logs.sh` captures current service logs to `logs/docker-services.log`.
- Use those files when you want me to inspect startup failures or runtime errors from the Docker stack.
## TeamSnap Secrets
- TeamSnap credentials are expected through Docker secrets, not plain environment variables.
- The backend reads `/run/secrets/teamsnap_client_id` and `/run/secrets/teamsnap_client_secret` by default.
- `.env` keeps only the secret file paths, not the credential values.
- Direct env var fallback still exists in code for non-Docker debugging, but the intended runtime path is Docker secrets.
## Local HTTPS Proxy
- Local development uses a Caddy reverse proxy at `https://kif.local.ascorrea.com`.
- TeamSnap callback should be registered as `https://kif.local.ascorrea.com/api/auth/teamsnap/callback`.
- The proxy forwards `/api/*` to FastAPI and all other routes to the Vite frontend.
- The proxy binds host ports `80` and `443` by default.
- If those ports are already in use, set `PROXY_HTTP_PORT` and `PROXY_HTTPS_PORT` in `.env`.
- The backend session cookie is secure-capable so the local flow matches production proxy behavior.
- The hostname is configurable through `APP_HOST`, but the default is `kif.local.ascorrea.com`.
- Generate the local cert/key with `./scripts/create-dev-certs.sh`, which uses `mkcert` and the current `APP_HOST`, or create them manually and place the outputs in:
- `secrets/dev-proxy-cert.pem`
- `secrets/dev-proxy-key.pem`
- Make sure `kif.local.ascorrea.com` resolves to your Docker host on your LAN before testing from other devices.
## Backend Responsibilities
- TeamSnap OAuth start/callback/refresh
- Session cookie management
- Media upload and normalized clip registration
- Game assignments and operator session APIs
## Frontend Responsibilities
- TeamSnap SDK bootstrap with server-issued access tokens
- Team/game browsing from TeamSnap
- Song upload and clip creation
- Game assignments and operator console
- PWA install/offline shell

12
backend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.13-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg nodejs && rm -rf /var/lib/apt/lists/*
COPY pyproject.toml /app/pyproject.toml
COPY requirements.txt requirements-dev.txt /app/
COPY app /app/app
RUN pip install --no-cache-dir -r requirements-dev.txt
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

2
backend/app/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Walkup backend package."""

160
backend/app/auth.py Normal file
View File

@@ -0,0 +1,160 @@
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from urllib.parse import urlencode
import httpx
from fastapi import Depends, HTTPException, Request, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from .config import settings
from .database import get_db
from .models import UserSession
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def create_session_token() -> str:
return secrets.token_urlsafe(32)
def set_session_cookie(response: Response, session_token: str) -> None:
response.set_cookie(
settings.session_cookie_name,
session_token,
httponly=True,
secure=settings.session_cookie_secure,
samesite="lax",
max_age=60 * 60 * 24 * 14,
)
def clear_session_cookie(response: Response) -> None:
response.delete_cookie(settings.session_cookie_name)
def get_current_session(
request: Request,
db: Session = Depends(get_db),
required: bool = False,
) -> UserSession | None:
session_token = request.cookies.get(settings.session_cookie_name)
if not session_token:
if required:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return None
session = db.scalar(select(UserSession).where(UserSession.session_token == session_token))
if session is None and required:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session")
return session
def require_session(session: UserSession | None = Depends(get_current_session)) -> UserSession:
if session is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return session
def require_admin(session: UserSession = Depends(require_session)) -> UserSession:
if not session.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return session
def build_teamsnap_authorize_url(state: str) -> str:
params = urlencode(
{
"client_id": settings.teamsnap_client_id,
"redirect_uri": settings.teamsnap_redirect_uri,
"response_type": "code",
"scope": settings.teamsnap_scope,
"state": state,
}
)
return f"{settings.teamsnap_auth_url}?{params}"
def _extract_collection_item(payload: dict) -> dict[str, object] | None:
items = payload.get("collection", {}).get("items", [])
if not items:
return None
values: dict[str, object] = {}
for field in items[0].get("data", []):
name = field.get("name")
if isinstance(name, str):
values[name] = field.get("value")
return values
async def fetch_teamsnap_user_id(access_token: str) -> str | None:
headers = {
"Accept": "application/vnd.collection+json",
"Authorization": f"Bearer {access_token}",
}
try:
async with httpx.AsyncClient(timeout=15.0) as client:
root_response = await client.get(settings.teamsnap_api_root, headers=headers)
root_response.raise_for_status()
queries = root_response.json().get("collection", {}).get("queries", [])
me_href = next((query.get("href") for query in queries if query.get("rel") == "me"), None)
if not isinstance(me_href, str) or not me_href:
return None
me_response = await client.get(me_href, headers=headers)
me_response.raise_for_status()
except httpx.HTTPError:
return None
me_item = _extract_collection_item(me_response.json())
if not me_item:
return None
user_id = me_item.get("id")
return str(user_id) if user_id is not None else None
async def exchange_code_for_token(code: str) -> dict:
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
settings.teamsnap_token_url,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": settings.teamsnap_redirect_uri,
"client_id": settings.teamsnap_client_id,
"client_secret": settings.teamsnap_client_secret,
},
headers={"Accept": "application/json"},
)
if response.status_code >= 400:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="TeamSnap token exchange failed")
return response.json()
async def refresh_access_token(refresh_token: str) -> dict:
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
settings.teamsnap_token_url,
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": settings.teamsnap_client_id,
"client_secret": settings.teamsnap_client_secret,
},
headers={"Accept": "application/json"},
)
if response.status_code >= 400:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="TeamSnap token refresh failed")
return response.json()
def update_session_tokens(session: UserSession, token_payload: dict) -> None:
session.access_token = token_payload.get("access_token")
session.refresh_token = token_payload.get("refresh_token", session.refresh_token)
expires_in = token_payload.get("expires_in")
session.token_expires_at = utcnow() + timedelta(seconds=int(expires_in)) if expires_in else None

48
backend/app/config.py Normal file
View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from pathlib import Path
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_name: str = "Walkup API"
backend_host: str = "0.0.0.0"
backend_port: int = 8000
backend_cors_origins_raw: str = "https://kif.local.ascorrea.com"
session_cookie_name: str = "walkup_session"
session_cookie_secure: bool = False
auth_return_cookie_name: str = "walkup_auth_return_to"
session_secret: str = "change-me"
local_admin_username: str = "admin"
local_admin_password: str = "admin"
teamsnap_client_id: str = ""
teamsnap_client_secret: str = ""
teamsnap_client_id_file: Path | None = None
teamsnap_client_secret_file: Path | None = None
teamsnap_auth_url: str = "https://auth.teamsnap.com/oauth/authorize"
teamsnap_token_url: str = "https://auth.teamsnap.com/oauth/token"
teamsnap_api_root: str = "https://apiv3.teamsnap.com"
teamsnap_redirect_uri: str = "https://kif.local.ascorrea.com/api/auth/teamsnap/callback"
teamsnap_scope: str = "read"
media_root: Path = Path("./storage")
database_url: str = "sqlite+pysqlite:///./walkup.db"
@property
def backend_cors_origins(self) -> list[str]:
return [item.strip() for item in self.backend_cors_origins_raw.split(",") if item.strip()]
def _read_secret_file(path: Path | None) -> str:
if path is None or not path.exists():
return ""
return path.read_text(encoding="utf-8").strip()
settings = Settings()
settings.teamsnap_client_id = settings.teamsnap_client_id or _read_secret_file(settings.teamsnap_client_id_file)
settings.teamsnap_client_secret = settings.teamsnap_client_secret or _read_secret_file(settings.teamsnap_client_secret_file)

26
backend/app/database.py Normal file
View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from .config import settings
class Base(DeclarativeBase):
pass
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, future=True, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

30
backend/app/main.py Normal file
View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import settings
from .database import Base, engine
from .routes.auth import router as auth_router
from .routes.games import router as games_router
from .routes.health import router as health_router
from .routes.media import router as media_router
from .routes.teamsnap import router as teamsnap_router
Base.metadata.create_all(bind=engine)
app = FastAPI(title=settings.app_name)
app.state.settings = settings
app.add_middleware(
CORSMiddleware,
allow_origins=settings.backend_cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health_router)
app.include_router(auth_router)
app.include_router(media_router)
app.include_router(games_router)
app.include_router(teamsnap_router)

98
backend/app/models.py Normal file
View File

@@ -0,0 +1,98 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class UserSession(Base):
__tablename__ = "user_sessions"
id: Mapped[int] = mapped_column(primary_key=True)
session_token: Mapped[str] = mapped_column(String(128), unique=True, index=True)
provider: Mapped[str] = mapped_column(String(32), default="teamsnap")
external_user_id: Mapped[str | None] = mapped_column(String(128), index=True)
external_team_id: Mapped[str | None] = mapped_column(String(128), index=True)
external_player_id: Mapped[str | None] = mapped_column(String(128), index=True)
access_token: Mapped[str | None] = mapped_column(Text())
refresh_token: Mapped[str | None] = mapped_column(Text())
token_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class AudioAsset(Base):
__tablename__ = "audio_assets"
id: Mapped[int] = mapped_column(primary_key=True)
external_team_id: Mapped[str] = mapped_column(String(128), index=True)
owner_external_player_id: Mapped[str] = mapped_column(String(128), index=True)
uploaded_by_session_id: Mapped[int | None] = mapped_column(ForeignKey("user_sessions.id"))
title: Mapped[str] = mapped_column(String(255))
original_filename: Mapped[str] = mapped_column(String(255))
mime_type: Mapped[str] = mapped_column(String(128))
size_bytes: Mapped[int] = mapped_column(Integer)
storage_path: Mapped[str] = mapped_column(String(512))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
uploaded_by: Mapped[UserSession | None] = relationship()
clips: Mapped[list[AudioClip]] = relationship(back_populates="asset", cascade="all, delete-orphan")
class AudioClip(Base):
__tablename__ = "audio_clips"
id: Mapped[int] = mapped_column(primary_key=True)
asset_id: Mapped[int] = mapped_column(ForeignKey("audio_assets.id"), index=True)
label: Mapped[str] = mapped_column(String(255))
start_ms: Mapped[int] = mapped_column(Integer)
end_ms: Mapped[int] = mapped_column(Integer)
normalization_status: Mapped[str] = mapped_column(String(32), default="pending")
normalized_path: Mapped[str | None] = mapped_column(String(512))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
asset: Mapped[AudioAsset] = relationship(back_populates="clips")
class GameAssignment(Base):
__tablename__ = "game_assignments"
__table_args__ = (
UniqueConstraint("external_game_id", "external_player_id", "clip_id", name="uq_game_assignment_player_clip"),
)
id: Mapped[int] = mapped_column(primary_key=True)
external_team_id: Mapped[str] = mapped_column(String(128), index=True)
external_game_id: Mapped[str] = mapped_column(String(128), index=True)
external_player_id: Mapped[str] = mapped_column(String(128), index=True)
clip_id: Mapped[int] = mapped_column(ForeignKey("audio_clips.id"), index=True)
batting_slot: Mapped[int | None] = mapped_column(Integer)
status: Mapped[str] = mapped_column(String(32), default="ready")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
clip: Mapped[AudioClip] = relationship()
class PlaybackSession(Base):
__tablename__ = "playback_sessions"
id: Mapped[int] = mapped_column(primary_key=True)
external_team_id: Mapped[str] = mapped_column(String(128), index=True)
external_game_id: Mapped[str] = mapped_column(String(128), index=True)
operator_session_id: Mapped[int | None] = mapped_column(ForeignKey("user_sessions.id"))
current_assignment_id: Mapped[int | None] = mapped_column(ForeignKey("game_assignments.id"))
state: Mapped[str] = mapped_column(String(32), default="idle")
last_triggered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
operator_session: Mapped[UserSession | None] = relationship()
current_assignment: Mapped[GameAssignment | None] = relationship()

View File

@@ -0,0 +1,2 @@
"""API routes."""

177
backend/app/routes/auth.py Normal file
View File

@@ -0,0 +1,177 @@
from __future__ import annotations
import secrets
import time
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi.responses import JSONResponse, RedirectResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..auth import (
build_teamsnap_authorize_url,
clear_session_cookie,
create_session_token,
exchange_code_for_token,
fetch_teamsnap_user_id,
get_current_session,
refresh_access_token,
require_admin,
require_session,
set_session_cookie,
update_session_tokens,
)
from ..config import settings
from ..database import get_db
from ..models import UserSession
from .teamsnap import build_proxy_api_root
from ..schemas import (
AdminLoginRequest,
SessionResponse,
TeamSnapTokenResponse,
WalkupSessionSelectionUpdate,
)
router = APIRouter(prefix="/auth", tags=["auth"])
def normalize_return_to(return_to: str | None) -> str:
if not return_to or not return_to.startswith("/") or return_to.startswith("//"):
return "/"
return return_to
@router.get("/teamsnap/start")
def teamsnap_start(return_to: str | None = Query(default="/")) -> Response:
if not settings.teamsnap_client_id:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="TeamSnap is not configured")
state = secrets.token_urlsafe(24)
response = JSONResponse({"authorize_url": build_teamsnap_authorize_url(state), "state": state})
response.set_cookie(
settings.auth_return_cookie_name,
normalize_return_to(return_to),
httponly=True,
secure=settings.session_cookie_secure,
samesite="lax",
max_age=60 * 10,
)
return response
@router.get("/teamsnap/callback")
async def teamsnap_callback(
request: Request,
code: str = Query(...),
db: Session = Depends(get_db),
) -> Response:
token_payload = await exchange_code_for_token(code)
session = UserSession(session_token=create_session_token(), provider="teamsnap")
update_session_tokens(session, token_payload)
if session.access_token:
session.external_user_id = await fetch_teamsnap_user_id(session.access_token)
db.add(session)
db.commit()
redirect_target = normalize_return_to(request.cookies.get(settings.auth_return_cookie_name))
redirect = RedirectResponse(url=redirect_target, status_code=status.HTTP_303_SEE_OTHER)
set_session_cookie(redirect, session.session_token)
redirect.delete_cookie(settings.auth_return_cookie_name)
return redirect
@router.get("/session", response_model=SessionResponse)
def session_status(session: UserSession | None = Depends(get_current_session)) -> SessionResponse:
if session is None:
return SessionResponse(authenticated=False)
return SessionResponse(
authenticated=True,
provider=session.provider,
is_admin=session.is_admin,
external_user_id=session.external_user_id,
external_team_id=session.external_team_id,
external_player_id=session.external_player_id,
token_expires_at=session.token_expires_at,
)
@router.post("/teamsnap/token", response_model=TeamSnapTokenResponse)
async def teamsnap_token(
request: Request,
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> TeamSnapTokenResponse:
if session.provider != "teamsnap":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Session is not TeamSnap-backed")
if not session.access_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing TeamSnap access token")
expires_soon = session.token_expires_at is None or session.token_expires_at.timestamp() <= (time.time() + 60)
if expires_soon and session.refresh_token:
token_payload = await refresh_access_token(session.refresh_token)
update_session_tokens(session, token_payload)
if not session.external_user_id and session.access_token:
session.external_user_id = await fetch_teamsnap_user_id(session.access_token)
db.add(session)
db.commit()
db.refresh(session)
return TeamSnapTokenResponse(
access_token=session.access_token,
expires_at=session.token_expires_at,
api_root=build_proxy_api_root(request),
auth_url=settings.teamsnap_auth_url.removesuffix("/oauth/authorize"),
)
@router.post("/session/walkup", response_model=SessionResponse)
def update_walkup_session_selection(
payload: WalkupSessionSelectionUpdate,
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> SessionResponse:
if session.provider != "teamsnap":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Session is not TeamSnap-backed")
session.external_team_id = payload.external_team_id
session.external_player_id = payload.external_player_id
db.add(session)
db.commit()
db.refresh(session)
return SessionResponse(
authenticated=True,
provider=session.provider,
is_admin=session.is_admin,
external_user_id=session.external_user_id,
external_team_id=session.external_team_id,
external_player_id=session.external_player_id,
token_expires_at=session.token_expires_at,
)
@router.post("/admin/login", response_model=SessionResponse)
def admin_login(payload: AdminLoginRequest, response: Response, db: Session = Depends(get_db)) -> SessionResponse:
if payload.username != settings.local_admin_username or payload.password != settings.local_admin_password:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
session = UserSession(session_token=create_session_token(), provider="local", is_admin=True)
db.add(session)
db.commit()
set_session_cookie(response, session.session_token)
return SessionResponse(authenticated=True, provider="local", is_admin=True)
@router.post("/logout")
def logout(
response: Response,
session: UserSession | None = Depends(get_current_session),
db: Session = Depends(get_db),
) -> dict[str, bool]:
if session is not None:
db.delete(session)
db.commit()
clear_session_cookie(response)
return {"ok": True}
@router.get("/admin/check", response_model=SessionResponse)
def admin_check(_: UserSession = Depends(require_admin)) -> SessionResponse:
return SessionResponse(authenticated=True, provider="local", is_admin=True)

170
backend/app/routes/games.py Normal file
View File

@@ -0,0 +1,170 @@
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..auth import require_session
from ..database import get_db
from ..models import AudioClip, GameAssignment, PlaybackSession, UserSession
from ..schemas import (
GameAssignmentCreate,
GameAssignmentResponse,
GamePrepResponse,
PlaybackAction,
PlaybackSessionCreate,
PlaybackSessionResponse,
)
router = APIRouter(prefix="/games", tags=["games"])
def assignment_to_response(assignment: GameAssignment) -> GameAssignmentResponse:
normalized_url = f"/media/files/{assignment.clip.normalized_path}" if assignment.clip.normalized_path else None
return GameAssignmentResponse(
id=assignment.id,
external_team_id=assignment.external_team_id,
external_game_id=assignment.external_game_id,
external_player_id=assignment.external_player_id,
clip_id=assignment.clip_id,
clip_label=assignment.clip.label,
asset_title=assignment.clip.asset.title,
start_ms=assignment.clip.start_ms,
end_ms=assignment.clip.end_ms,
batting_slot=assignment.batting_slot,
status=assignment.status,
normalized_url=normalized_url,
updated_at=assignment.updated_at,
)
@router.get("/{external_game_id}/assignments", response_model=list[GameAssignmentResponse])
def list_assignments(
external_game_id: str,
external_player_id: str | None = Query(default=None),
_: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> list[GameAssignmentResponse]:
query = select(GameAssignment).where(GameAssignment.external_game_id == external_game_id)
if external_player_id:
query = query.where(GameAssignment.external_player_id == external_player_id)
assignments = db.scalars(query.order_by(GameAssignment.batting_slot, GameAssignment.updated_at.desc())).all()
return [assignment_to_response(assignment) for assignment in assignments]
@router.post("/{external_game_id}/assignments", response_model=GameAssignmentResponse)
def create_assignment(
external_game_id: str,
payload: GameAssignmentCreate,
_: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> GameAssignmentResponse:
clip = db.get(AudioClip, payload.clip_id)
if clip is None or clip.normalization_status != "ready":
raise HTTPException(status_code=422, detail="Clip is not ready")
if clip.asset.external_team_id != payload.external_team_id:
raise HTTPException(status_code=422, detail="Clip does not belong to this team")
if clip.asset.owner_external_player_id != payload.external_player_id:
raise HTTPException(status_code=403, detail="You can only attach clips owned by that player")
assignment = db.scalar(
select(GameAssignment).where(
GameAssignment.external_game_id == external_game_id,
GameAssignment.external_player_id == payload.external_player_id,
GameAssignment.clip_id == payload.clip_id,
)
)
if assignment is None:
assignment = GameAssignment(
external_team_id=payload.external_team_id,
external_game_id=external_game_id,
external_player_id=payload.external_player_id,
clip_id=payload.clip_id,
batting_slot=payload.batting_slot,
status=payload.status,
)
db.add(assignment)
else:
assignment.external_team_id = payload.external_team_id
assignment.clip_id = payload.clip_id
assignment.batting_slot = payload.batting_slot
assignment.status = payload.status
db.commit()
db.refresh(assignment)
return assignment_to_response(assignment)
@router.get("/{external_game_id}/prep", response_model=GamePrepResponse)
def prepare_game(
external_game_id: str,
_: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> GamePrepResponse:
assignments = db.scalars(
select(GameAssignment)
.where(GameAssignment.external_game_id == external_game_id)
.order_by(GameAssignment.batting_slot, GameAssignment.updated_at.desc())
).all()
external_team_id = assignments[0].external_team_id if assignments else ""
return GamePrepResponse(
external_game_id=external_game_id,
external_team_id=external_team_id,
prepared_at=datetime.now(timezone.utc),
assignments=[assignment_to_response(assignment) for assignment in assignments],
)
@router.post("/{external_game_id}/operator/session", response_model=PlaybackSessionResponse)
def create_playback_session(
external_game_id: str,
payload: PlaybackSessionCreate,
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> PlaybackSessionResponse:
playback = PlaybackSession(
external_team_id=payload.external_team_id,
external_game_id=external_game_id,
operator_session_id=session.id,
state="idle",
)
db.add(playback)
db.commit()
db.refresh(playback)
return PlaybackSessionResponse.model_validate(playback, from_attributes=True)
@router.post("/{external_game_id}/operator/session/{playback_session_id}/trigger", response_model=PlaybackSessionResponse)
def trigger_playback(
external_game_id: str,
playback_session_id: int,
payload: PlaybackAction,
_: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> PlaybackSessionResponse:
playback = db.get(PlaybackSession, playback_session_id)
if playback is None or playback.external_game_id != external_game_id:
raise HTTPException(status_code=404, detail="Playback session not found")
if payload.assignment_id is None and payload.clip_id is None:
raise HTTPException(status_code=422, detail="Provide an assignment or clip to trigger")
if payload.assignment_id is not None:
assignment = db.get(GameAssignment, payload.assignment_id)
if assignment is None or assignment.external_game_id != external_game_id:
raise HTTPException(status_code=404, detail="Assignment not found")
playback.current_assignment_id = assignment.id
else:
clip = db.get(AudioClip, payload.clip_id)
if clip is None or clip.asset.external_team_id != playback.external_team_id:
raise HTTPException(status_code=404, detail="Clip not found")
if payload.external_player_id and clip.asset.owner_external_player_id != payload.external_player_id:
raise HTTPException(status_code=403, detail="Clip does not belong to that player")
playback.current_assignment_id = None
playback.state = payload.state
playback.last_triggered_at = datetime.now(timezone.utc)
db.commit()
db.refresh(playback)
return PlaybackSessionResponse.model_validate(playback, from_attributes=True)

View File

@@ -0,0 +1,9 @@
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("/health")
def healthcheck() -> dict[str, str]:
return {"status": "ok"}

373
backend/app/routes/media.py Normal file
View File

@@ -0,0 +1,373 @@
from __future__ import annotations
import secrets
import shutil
from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy import delete, select, update
from sqlalchemy.orm import Session
from ..auth import require_session
from ..database import get_db
from ..models import AudioAsset, AudioClip, GameAssignment, PlaybackSession, UserSession
from ..schemas import (
AudioAssetImportCreate,
AudioAssetResponse,
AudioAssetUpdate,
AudioClipCreate,
AudioClipResponse,
AudioClipUpdate,
)
from ..storage import storage
router = APIRouter(prefix="/media", tags=["media"])
DEFAULT_CLIP_LENGTH_MS = 30_000
def clip_to_response(clip: AudioClip) -> AudioClipResponse:
normalized_url = f"/media/files/{clip.normalized_path}" if clip.normalized_path else None
waveform = storage.load_or_generate_waveform(clip.asset.storage_path)
return AudioClipResponse(
id=clip.id,
asset_id=clip.asset_id,
external_team_id=clip.asset.external_team_id,
owner_external_player_id=clip.asset.owner_external_player_id,
asset_title=clip.asset.title,
label=clip.label,
start_ms=clip.start_ms,
end_ms=clip.end_ms,
normalization_status=clip.normalization_status,
normalized_url=normalized_url,
waveform_duration_ms=waveform["duration_ms"] if waveform else None,
waveform_peaks=waveform["peaks"] if waveform else None,
created_at=clip.created_at,
)
def can_manage_asset(session: UserSession, asset: AudioAsset, owner_external_player_id: str | None = None) -> bool:
if session.is_admin or asset.uploaded_by_session_id == session.id:
return True
return owner_external_player_id is not None and asset.owner_external_player_id == owner_external_player_id
def create_asset_with_default_clip(
*,
db: Session,
session: UserSession,
external_team_id: str,
owner_external_player_id: str,
title: str,
original_filename: str,
mime_type: str,
size_bytes: int,
storage_path: str,
) -> AudioAssetResponse:
asset = AudioAsset(
external_team_id=external_team_id,
owner_external_player_id=owner_external_player_id,
uploaded_by_session_id=session.id,
title=title,
original_filename=original_filename,
mime_type=mime_type,
size_bytes=size_bytes,
storage_path=storage_path,
)
db.add(asset)
db.flush()
clip = AudioClip(
asset_id=asset.id,
label=asset.title,
start_ms=0,
end_ms=DEFAULT_CLIP_LENGTH_MS,
normalization_status="processing",
)
db.add(clip)
db.flush()
normalized_name = f"clip-{clip.id}-{secrets.token_hex(6)}{Path(asset.storage_path).suffix or '.bin'}"
clip.normalized_path = storage.normalize_clip(asset.storage_path, normalized_name)
clip.normalization_status = "ready"
storage.generate_waveform(asset.storage_path)
db.commit()
db.refresh(asset)
return AudioAssetResponse.model_validate(asset, from_attributes=True)
def download_media_to_storage(url: str) -> tuple[str, int, str, str]:
try:
from yt_dlp import YoutubeDL
from yt_dlp.utils import DownloadError
except ImportError as exc: # pragma: no cover - guarded by dependency install
raise HTTPException(status_code=500, detail="yt-dlp is not installed") from exc
storage_name = f"{secrets.token_hex(16)}.%(ext)s"
outtmpl = str(storage.uploads_dir / storage_name)
options = {
"format": "bestaudio/best",
"noplaylist": True,
"quiet": True,
"no_warnings": True,
"outtmpl": outtmpl,
"restrictfilenames": True,
"extractor_args": {"youtube": {"player_client": ["android"]}},
}
node_path = shutil.which("node")
if node_path:
options["js_runtimes"] = {"node": {"path": node_path}}
try:
with YoutubeDL(options) as ydl:
info = ydl.extract_info(url, download=True)
downloaded_path = Path(ydl.prepare_filename(info))
except DownloadError as exc: # pragma: no cover - exercised via HTTP behavior
message = str(exc).strip() or "Could not download media from that URL"
raise HTTPException(status_code=422, detail=f"Could not download media from that URL: {message}") from exc
except Exception as exc: # pragma: no cover - exercised via HTTP behavior
message = str(exc).strip() or "Could not download media from that URL"
raise HTTPException(status_code=422, detail=f"Could not download media from that URL: {message}") from exc
if not downloaded_path.exists():
raise HTTPException(status_code=502, detail="Downloaded file was not created")
size_bytes = downloaded_path.stat().st_size
original_filename = downloaded_path.name
source_title = str(info.get("title") or downloaded_path.stem)
return str(downloaded_path.relative_to(storage.root)), size_bytes, original_filename, source_title
@router.post("/uploads", response_model=AudioAssetResponse)
async def upload_audio(
external_team_id: str = Form(...),
owner_external_player_id: str = Form(...),
title: str = Form(...),
file: UploadFile = File(...),
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> AudioAssetResponse:
extension = Path(file.filename or "upload.bin").suffix or ".bin"
storage_name = f"{secrets.token_hex(16)}{extension}"
relative_path, size = storage.save_upload(file, storage_name)
return create_asset_with_default_clip(
db=db,
session=session,
external_team_id=external_team_id,
owner_external_player_id=owner_external_player_id,
title=title,
original_filename=file.filename or storage_name,
mime_type=file.content_type or "application/octet-stream",
size_bytes=size,
storage_path=relative_path,
)
@router.post("/imports", response_model=AudioAssetResponse)
def import_audio(
payload: AudioAssetImportCreate,
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> AudioAssetResponse:
relative_path, size_bytes, original_filename, source_title = download_media_to_storage(payload.url)
title = payload.title.strip() if payload.title else ""
if not title:
title = source_title
return create_asset_with_default_clip(
db=db,
session=session,
external_team_id=payload.external_team_id,
owner_external_player_id=payload.owner_external_player_id,
title=title,
original_filename=original_filename,
mime_type="application/octet-stream",
size_bytes=size_bytes,
storage_path=relative_path,
)
@router.get("/assets", response_model=list[AudioAssetResponse])
def list_assets(
external_team_id: str,
owner_external_player_id: str | None = None,
_: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> list[AudioAssetResponse]:
query = select(AudioAsset).where(AudioAsset.external_team_id == external_team_id)
if owner_external_player_id:
query = query.where(AudioAsset.owner_external_player_id == owner_external_player_id)
assets = db.scalars(query.order_by(AudioAsset.created_at.desc())).all()
return [AudioAssetResponse.model_validate(asset, from_attributes=True) for asset in assets]
@router.delete("/assets/{asset_id}", status_code=204)
def delete_asset(
asset_id: int,
owner_external_player_id: str | None = None,
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> None:
asset = db.get(AudioAsset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
if not can_manage_asset(session, asset, owner_external_player_id):
raise HTTPException(status_code=403, detail="You can only delete your own uploads")
clips = db.scalars(select(AudioClip).where(AudioClip.asset_id == asset.id)).all()
clip_ids = [clip.id for clip in clips]
if clip_ids:
assignment_ids = db.scalars(select(GameAssignment.id).where(GameAssignment.clip_id.in_(clip_ids))).all()
db.execute(delete(GameAssignment).where(GameAssignment.clip_id.in_(clip_ids)))
if assignment_ids:
db.execute(
update(PlaybackSession)
.where(PlaybackSession.current_assignment_id.in_(assignment_ids))
.values(current_assignment_id=None)
)
for clip in clips:
if clip.normalized_path:
storage.delete_relative_path(clip.normalized_path)
db.delete(clip)
storage.delete_relative_path(asset.storage_path)
db.delete(asset)
db.commit()
@router.patch("/assets/{asset_id}", response_model=AudioAssetResponse)
def update_asset(
asset_id: int,
payload: AudioAssetUpdate,
owner_external_player_id: str | None = None,
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> AudioAssetResponse:
asset = db.get(AudioAsset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
if not can_manage_asset(session, asset, owner_external_player_id):
raise HTTPException(status_code=403, detail="You can only update your own uploads")
title = payload.title.strip()
if not title:
raise HTTPException(status_code=422, detail="File name cannot be blank")
asset.title = title
db.commit()
db.refresh(asset)
return AudioAssetResponse.model_validate(asset, from_attributes=True)
@router.post("/clips", response_model=AudioClipResponse)
def create_clip(
payload: AudioClipCreate,
_: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> AudioClipResponse:
asset = db.get(AudioAsset, payload.asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
if asset.external_team_id != payload.external_team_id:
raise HTTPException(status_code=422, detail="Clip does not belong to this team")
if asset.owner_external_player_id != payload.owner_external_player_id:
raise HTTPException(status_code=403, detail="You can only create clips for that player")
if payload.end_ms <= payload.start_ms:
raise HTTPException(status_code=422, detail="Clip end must be greater than start")
clip = AudioClip(
asset_id=asset.id,
label=payload.label,
start_ms=payload.start_ms,
end_ms=payload.end_ms,
normalization_status="processing",
)
db.add(clip)
db.flush()
normalized_name = f"clip-{clip.id}-{secrets.token_hex(6)}{Path(asset.storage_path).suffix or '.bin'}"
clip.normalized_path = storage.normalize_clip(asset.storage_path, normalized_name)
clip.normalization_status = "ready"
db.commit()
db.refresh(clip)
return clip_to_response(clip)
@router.patch("/clips/{clip_id}", response_model=AudioClipResponse)
def update_clip(
clip_id: int,
payload: AudioClipUpdate,
owner_external_player_id: str | None = None,
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> AudioClipResponse:
clip = db.get(AudioClip, clip_id)
if clip is None:
raise HTTPException(status_code=404, detail="Clip not found")
if not can_manage_asset(session, clip.asset, owner_external_player_id):
raise HTTPException(status_code=403, detail="You can only update clips from your own uploads")
if payload.end_ms <= payload.start_ms:
raise HTTPException(status_code=422, detail="Clip end must be greater than start")
clip.label = payload.label or clip.label
clip.start_ms = payload.start_ms
clip.end_ms = payload.end_ms
db.commit()
db.refresh(clip)
return clip_to_response(clip)
@router.delete("/clips/{clip_id}", status_code=204)
def delete_clip(
clip_id: int,
owner_external_player_id: str | None = None,
session: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> None:
clip = db.get(AudioClip, clip_id)
if clip is None:
raise HTTPException(status_code=404, detail="Clip not found")
if not can_manage_asset(session, clip.asset, owner_external_player_id):
raise HTTPException(status_code=403, detail="You can only delete clips from your own uploads")
assignment_ids = db.scalars(select(GameAssignment.id).where(GameAssignment.clip_id == clip.id)).all()
db.execute(delete(GameAssignment).where(GameAssignment.clip_id == clip.id))
if assignment_ids:
db.execute(
update(PlaybackSession)
.where(PlaybackSession.current_assignment_id.in_(assignment_ids))
.values(current_assignment_id=None)
)
if clip.normalized_path:
storage.delete_relative_path(clip.normalized_path)
db.delete(clip)
db.commit()
@router.get("/clips", response_model=list[AudioClipResponse])
def list_clips(
external_team_id: str,
owner_external_player_id: str | None = None,
_: UserSession = Depends(require_session),
db: Session = Depends(get_db),
) -> list[AudioClipResponse]:
query = (
select(AudioClip)
.join(AudioClip.asset)
.where(AudioAsset.external_team_id == external_team_id)
.order_by(AudioClip.created_at.desc())
)
if owner_external_player_id:
query = query.where(AudioAsset.owner_external_player_id == owner_external_player_id)
clips = db.scalars(query).all()
return [clip_to_response(clip) for clip in clips]
@router.get("/files/{relative_path:path}")
def media_file(relative_path: str) -> FileResponse:
path = storage.absolute_path(relative_path)
if not path.exists():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(path)

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
import json
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from ..auth import require_session
from ..models import UserSession
router = APIRouter(prefix="/teamsnap", tags=["teamsnap"])
def build_proxy_api_root(request: Request) -> str:
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("x-forwarded-host", request.headers.get("host", request.url.netloc))
return f"{scheme}://{host}/api/teamsnap"
def rewrite_teamsnap_urls(value: object, upstream_root: str, proxy_root: str) -> object:
if isinstance(value, str):
return value.replace(upstream_root, proxy_root)
if isinstance(value, Mapping):
return {key: rewrite_teamsnap_urls(item, upstream_root, proxy_root) for key, item in value.items()}
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return [rewrite_teamsnap_urls(item, upstream_root, proxy_root) for item in value]
return value
def build_upstream_url(request: Request, proxy_path: str) -> str:
base = request.app.state.settings.teamsnap_api_root.rstrip("/")
if not proxy_path:
return base
return f"{base}/{proxy_path.lstrip('/')}"
@router.api_route("/{proxy_path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
@router.api_route("", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
async def teamsnap_proxy(
request: Request,
session: UserSession = Depends(require_session),
proxy_path: str = "",
) -> Response:
if session.provider != "teamsnap":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Session is not TeamSnap-backed")
if not session.access_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing TeamSnap access token")
upstream_url = build_upstream_url(request, proxy_path)
outgoing_headers = {
"Accept": request.headers.get("accept", "application/vnd.collection+json"),
"Authorization": f"Bearer {session.access_token}",
}
if request.headers.get("content-type"):
outgoing_headers["Content-Type"] = request.headers["content-type"]
body = await request.body()
async with httpx.AsyncClient(timeout=30.0) as client:
upstream = await client.request(
request.method,
upstream_url,
params=request.query_params,
content=body or None,
headers=outgoing_headers,
)
content_type = upstream.headers.get("content-type", "")
if "json" not in content_type.lower():
response = Response(content=upstream.content, status_code=upstream.status_code)
if content_type:
response.headers["Content-Type"] = content_type
return response
proxy_root = build_proxy_api_root(request)
rewritten = rewrite_teamsnap_urls(upstream.json(), request.app.state.settings.teamsnap_api_root, proxy_root)
return Response(
content=json.dumps(rewritten),
status_code=upstream.status_code,
media_type=content_type or "application/json",
)

136
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,136 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class SessionResponse(BaseModel):
authenticated: bool
provider: str | None = None
is_admin: bool = False
external_user_id: str | None = None
external_team_id: str | None = None
external_player_id: str | None = None
token_expires_at: datetime | None = None
class TeamSnapTokenResponse(BaseModel):
access_token: str
expires_at: datetime | None = None
api_root: str
auth_url: str
class WalkupSessionSelectionUpdate(BaseModel):
external_team_id: str = Field(min_length=1, max_length=128)
external_player_id: str = Field(min_length=1, max_length=128)
class AdminLoginRequest(BaseModel):
username: str
password: str
class AudioAssetResponse(BaseModel):
id: int
external_team_id: str
owner_external_player_id: str
title: str
original_filename: str
mime_type: str
size_bytes: int
created_at: datetime
class AudioAssetUpdate(BaseModel):
title: str = Field(min_length=1, max_length=255)
class AudioAssetImportCreate(BaseModel):
external_team_id: str
owner_external_player_id: str
url: str = Field(min_length=1)
title: str | None = Field(default=None, min_length=1, max_length=255)
class AudioClipCreate(BaseModel):
asset_id: int
external_team_id: str
owner_external_player_id: str
label: str = Field(min_length=1, max_length=255)
start_ms: int = Field(ge=0)
end_ms: int = Field(gt=0)
class AudioClipUpdate(BaseModel):
label: str | None = Field(default=None, min_length=1, max_length=255)
start_ms: int = Field(ge=0)
end_ms: int = Field(gt=0)
class AudioClipResponse(BaseModel):
id: int
asset_id: int
external_team_id: str
owner_external_player_id: str
asset_title: str
label: str
start_ms: int
end_ms: int
normalization_status: str
normalized_url: str | None
waveform_duration_ms: int | None = None
waveform_peaks: list[int] | None = None
created_at: datetime
class GameAssignmentCreate(BaseModel):
external_team_id: str
external_player_id: str
clip_id: int
batting_slot: int | None = Field(default=None, ge=1, le=99)
status: str = "ready"
class GameAssignmentResponse(BaseModel):
id: int
external_team_id: str
external_game_id: str
external_player_id: str
clip_id: int
clip_label: str
asset_title: str
start_ms: int
end_ms: int
batting_slot: int | None
status: str
normalized_url: str | None
updated_at: datetime
class GamePrepResponse(BaseModel):
external_game_id: str
external_team_id: str
prepared_at: datetime
assignments: list[GameAssignmentResponse]
class PlaybackSessionCreate(BaseModel):
external_team_id: str
class PlaybackAction(BaseModel):
assignment_id: int | None = None
clip_id: int | None = None
external_player_id: str | None = None
state: str = "playing"
class PlaybackSessionResponse(BaseModel):
id: int
external_team_id: str
external_game_id: str
current_assignment_id: int | None
state: str
last_triggered_at: datetime | None

140
backend/app/storage.py Normal file
View File

@@ -0,0 +1,140 @@
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from array import array
from pathlib import Path
from fastapi import UploadFile
from .config import settings
WAVEFORM_PEAK_COUNT = 1024
class MediaStorage:
def __init__(self) -> None:
self._ensure_directories()
@property
def root(self) -> Path:
return settings.media_root
@property
def uploads_dir(self) -> Path:
return self.root / "uploads"
@property
def normalized_dir(self) -> Path:
return self.root / "normalized"
def waveform_sidecar_path(self, relative_path: str) -> Path:
path = self.absolute_path(relative_path)
return path.with_name(f"{path.name}.waveform.json")
def _ensure_directories(self) -> None:
self.uploads_dir.mkdir(parents=True, exist_ok=True)
self.normalized_dir.mkdir(parents=True, exist_ok=True)
def save_upload(self, upload: UploadFile, destination_name: str) -> tuple[str, int]:
self._ensure_directories()
destination = self.uploads_dir / destination_name
size = 0
with destination.open("wb") as output:
while chunk := upload.file.read(1024 * 1024):
size += len(chunk)
output.write(chunk)
return str(destination.relative_to(self.root)), size
def normalize_clip(self, source_relative_path: str, clip_name: str) -> str:
self._ensure_directories()
source = self.root / source_relative_path
destination = self.normalized_dir / clip_name
shutil.copyfile(source, destination)
return str(destination.relative_to(self.root))
def generate_waveform(self, source_relative_path: str, bins: int = WAVEFORM_PEAK_COUNT) -> dict[str, int | list[int]]:
self._ensure_directories()
source = self.root / source_relative_path
if not source.exists():
raise FileNotFoundError(source)
ffmpeg_path = shutil.which("ffmpeg")
if not ffmpeg_path:
raise RuntimeError("ffmpeg is not installed")
completed = subprocess.run(
[
ffmpeg_path,
"-v",
"error",
"-i",
str(source),
"-ac",
"1",
"-ar",
"8000",
"-f",
"s16le",
"pipe:1",
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
pcm = completed.stdout
samples = array("h")
samples.frombytes(pcm)
if sys.byteorder != "little":
samples.byteswap()
sample_count = len(samples)
if sample_count == 0:
peaks = [0 for _ in range(bins)]
duration_ms = 0
else:
peaks = []
for index in range(bins):
start = index * sample_count // bins
end = (index + 1) * sample_count // bins
if end <= start:
end = min(sample_count, start + 1)
segment = samples[start:end]
peak = max((abs(value) for value in segment), default=0)
peaks.append(round((peak / 32767) * 100))
duration_ms = round((sample_count / 8000) * 1000)
waveform = {"duration_ms": duration_ms, "peaks": peaks}
sidecar_path = self.waveform_sidecar_path(source_relative_path)
sidecar_path.parent.mkdir(parents=True, exist_ok=True)
sidecar_path.write_text(json.dumps(waveform), encoding="utf-8")
return waveform
def load_waveform(self, relative_path: str) -> dict[str, int | list[int]] | None:
sidecar_path = self.waveform_sidecar_path(relative_path)
if not sidecar_path.exists():
return None
return json.loads(sidecar_path.read_text(encoding="utf-8"))
def load_or_generate_waveform(self, relative_path: str) -> dict[str, int | list[int]] | None:
existing = self.load_waveform(relative_path)
if existing is not None and len(existing.get("peaks", [])) == WAVEFORM_PEAK_COUNT:
return existing
source = self.absolute_path(relative_path)
if not source.exists():
return None
return self.generate_waveform(relative_path)
def absolute_path(self, relative_path: str) -> Path:
return self.root / relative_path
def delete_relative_path(self, relative_path: str) -> None:
path = self.absolute_path(relative_path)
path.unlink(missing_ok=True)
self.waveform_sidecar_path(relative_path).unlink(missing_ok=True)
storage = MediaStorage()

31
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,31 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "walkup-backend"
version = "0.1.0"
description = "FastAPI backend for the Walkup PWA"
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.115,<1.0",
"uvicorn[standard]>=0.30,<1.0",
"sqlalchemy>=2.0,<3.0",
"psycopg[binary]>=3.2,<4.0",
"pydantic-settings>=2.4,<3.0",
"python-multipart>=0.0.9,<1.0",
"httpx>=0.27,<1.0",
"yt-dlp>=2024.5.27,<2026.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3,<9.0",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.setuptools.packages.find]
where = ["."]
include = ["app*"]

View File

@@ -0,0 +1,2 @@
-r requirements.txt
pytest>=8.3,<9.0

8
backend/requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
fastapi>=0.115,<1.0
uvicorn[standard]>=0.30,<1.0
sqlalchemy>=2.0,<3.0
psycopg[binary]>=3.2,<4.0
pydantic-settings>=2.4,<3.0
python-multipart>=0.0.9,<1.0
httpx>=0.27,<1.0
yt-dlp>=2024.5.27,<2026.0

View File

@@ -0,0 +1,25 @@
These are sample TeamSnap API v3 Collection+JSON responses.
The payloads use upstream TeamSnap URLs (`https://apiv3.teamsnap.com`) so
tests can rewrite them through the local proxy root without guessing at paths.
The set includes:
- `root.json`
- `me.json`
- `teams.json`
- `members.json`
- `events.json`
- `availabilities.json`
- `assignments.json`
- `event_lineups.json`
- `event_lineup_entries.json`
They are intentionally small but cover the collections this app reads:
- `me` for auth/session identity
- `teams` for team selection
- `members` for player lookup
- `events` for the operator/game flow
- `availabilities`, `assignments`, `eventLineups`, and `eventLineupEntries`
for lineup and game preparation screens

View File

@@ -0,0 +1,74 @@
{
"collection": {
"version": "1.0",
"href": "https://apiv3.teamsnap.com/assignments?teamId=101&eventId=2001",
"links": [
{
"rel": "self",
"href": "https://apiv3.teamsnap.com/assignments?teamId=101&eventId=2001"
}
],
"queries": [
{
"rel": "search",
"href": "https://apiv3.teamsnap.com/assignments{?teamId,eventId,memberId}",
"prompt": "Assignments"
}
],
"template": {
"data": [
{
"name": "team_id",
"value": ""
},
{
"name": "event_id",
"value": ""
},
{
"name": "member_id",
"value": ""
},
{
"name": "description",
"value": ""
},
{
"name": "order",
"value": 0
}
]
},
"items": [
{
"href": "https://apiv3.teamsnap.com/assignments/4001",
"data": [
{
"name": "id",
"value": 4001
},
{
"name": "team_id",
"value": 101
},
{
"name": "event_id",
"value": 2001
},
{
"name": "member_id",
"value": 1001
},
{
"name": "description",
"value": "Lead off"
},
{
"name": "order",
"value": 1
}
]
}
]
}
}

View File

@@ -0,0 +1,91 @@
{
"collection": {
"version": "1.0",
"href": "https://apiv3.teamsnap.com/availabilities?teamId=101&eventId=2001",
"links": [
{
"rel": "self",
"href": "https://apiv3.teamsnap.com/availabilities?teamId=101&eventId=2001"
}
],
"queries": [
{
"rel": "search",
"href": "https://apiv3.teamsnap.com/availabilities{?teamId,eventId,memberId}",
"prompt": "Availabilities"
}
],
"template": {
"data": [
{
"name": "team_id",
"value": ""
},
{
"name": "event_id",
"value": ""
},
{
"name": "member_id",
"value": ""
},
{
"name": "status_code",
"value": null
}
]
},
"items": [
{
"href": "https://apiv3.teamsnap.com/availabilities/3001",
"data": [
{
"name": "id",
"value": 3001
},
{
"name": "team_id",
"value": 101
},
{
"name": "event_id",
"value": 2001
},
{
"name": "member_id",
"value": 1001
},
{
"name": "status_code",
"value": 1
}
]
},
{
"href": "https://apiv3.teamsnap.com/availabilities/3002",
"data": [
{
"name": "id",
"value": 3002
},
{
"name": "team_id",
"value": 101
},
{
"name": "event_id",
"value": 2001
},
{
"name": "member_id",
"value": 1002
},
{
"name": "status_code",
"value": 0
}
]
}
]
}
}

View File

@@ -0,0 +1,91 @@
{
"collection": {
"version": "1.0",
"href": "https://apiv3.teamsnap.com/eventLineupEntries?eventLineupId=5001",
"links": [
{
"rel": "self",
"href": "https://apiv3.teamsnap.com/eventLineupEntries?eventLineupId=5001"
}
],
"queries": [
{
"rel": "search",
"href": "https://apiv3.teamsnap.com/eventLineupEntries{?eventLineupId}",
"prompt": "Event lineup entries"
}
],
"template": {
"data": [
{
"name": "event_lineup_id",
"value": ""
},
{
"name": "member_id",
"value": ""
},
{
"name": "label",
"value": ""
},
{
"name": "sequence",
"value": 0
}
]
},
"items": [
{
"href": "https://apiv3.teamsnap.com/eventLineupEntries/6001",
"data": [
{
"name": "id",
"value": 6001
},
{
"name": "event_lineup_id",
"value": 5001
},
{
"name": "member_id",
"value": 1001
},
{
"name": "label",
"value": "Shortstop"
},
{
"name": "sequence",
"value": 1
}
]
},
{
"href": "https://apiv3.teamsnap.com/eventLineupEntries/6002",
"data": [
{
"name": "id",
"value": 6002
},
{
"name": "event_lineup_id",
"value": 5001
},
{
"name": "member_id",
"value": 1002
},
{
"name": "label",
"value": "Coach"
},
{
"name": "sequence",
"value": 2
}
]
}
]
}
}

View File

@@ -0,0 +1,56 @@
{
"collection": {
"version": "1.0",
"href": "https://apiv3.teamsnap.com/eventLineups?eventId=2001",
"links": [
{
"rel": "self",
"href": "https://apiv3.teamsnap.com/eventLineups?eventId=2001"
}
],
"queries": [
{
"rel": "search",
"href": "https://apiv3.teamsnap.com/eventLineups{?eventId}",
"prompt": "Event lineups"
}
],
"template": {
"data": [
{
"name": "event_id",
"value": ""
},
{
"name": "is_published",
"value": false
}
]
},
"items": [
{
"href": "https://apiv3.teamsnap.com/eventLineups/5001",
"data": [
{
"name": "id",
"value": 5001
},
{
"name": "event_id",
"value": 2001
},
{
"name": "is_published",
"value": true
}
],
"links": [
{
"rel": "eventLineupEntries",
"href": "https://apiv3.teamsnap.com/eventLineupEntries?eventLineupId=5001"
}
]
}
]
}
}

View File

@@ -0,0 +1,125 @@
{
"collection": {
"version": "1.0",
"href": "https://apiv3.teamsnap.com/events?teamId=101",
"links": [
{
"rel": "self",
"href": "https://apiv3.teamsnap.com/events?teamId=101"
}
],
"queries": [
{
"rel": "search",
"href": "https://apiv3.teamsnap.com/events{?teamId,eventId,contactId}",
"prompt": "Events"
}
],
"template": {
"data": [
{
"name": "team_id",
"value": ""
},
{
"name": "name",
"value": ""
},
{
"name": "is_game",
"value": false
},
{
"name": "opponent_name",
"value": ""
},
{
"name": "location_name",
"value": ""
},
{
"name": "start_date",
"value": ""
}
]
},
"items": [
{
"href": "https://apiv3.teamsnap.com/events/2001",
"data": [
{
"name": "id",
"value": 2001
},
{
"name": "team_id",
"value": 101
},
{
"name": "name",
"value": "Opening Day"
},
{
"name": "is_game",
"value": true
},
{
"name": "opponent_name",
"value": "Sharks"
},
{
"name": "location_name",
"value": "Field 1"
},
{
"name": "start_date",
"value": "2026-04-28T18:00:00Z"
}
],
"links": [
{
"rel": "availabilities",
"href": "https://apiv3.teamsnap.com/availabilities?teamId=101&eventId=2001"
},
{
"rel": "assignments",
"href": "https://apiv3.teamsnap.com/assignments?teamId=101&eventId=2001"
},
{
"rel": "eventLineups",
"href": "https://apiv3.teamsnap.com/eventLineups?eventId=2001"
}
]
},
{
"href": "https://apiv3.teamsnap.com/events/2002",
"data": [
{
"name": "id",
"value": 2002
},
{
"name": "team_id",
"value": 101
},
{
"name": "name",
"value": "Practice"
},
{
"name": "is_game",
"value": false
},
{
"name": "location_name",
"value": "Field 2"
},
{
"name": "start_date",
"value": "2026-04-23T17:30:00Z"
}
]
}
]
}
}

35
backend/tests/fixtures/teamsnap/me.json vendored Normal file
View File

@@ -0,0 +1,35 @@
{
"collection": {
"version": "1.0",
"href": "https://apiv3.teamsnap.com/me",
"links": [
{
"rel": "self",
"href": "https://apiv3.teamsnap.com/me"
}
],
"items": [
{
"href": "https://apiv3.teamsnap.com/me/42",
"data": [
{
"name": "id",
"value": 42
},
{
"name": "first_name",
"value": "Sam"
},
{
"name": "last_name",
"value": "Player"
},
{
"name": "email",
"value": "sam.player@example.com"
}
]
}
]
}
}

View File

@@ -0,0 +1,111 @@
{
"collection": {
"version": "1.0",
"href": "https://apiv3.teamsnap.com/members?teamId=101",
"links": [
{
"rel": "self",
"href": "https://apiv3.teamsnap.com/members?teamId=101"
}
],
"queries": [
{
"rel": "search",
"href": "https://apiv3.teamsnap.com/members{?teamId,userId,memberId}",
"prompt": "Members"
}
],
"template": {
"data": [
{
"name": "team_id",
"value": ""
},
{
"name": "first_name",
"value": ""
},
{
"name": "last_name",
"value": ""
},
{
"name": "number",
"value": ""
},
{
"name": "is_non_player",
"value": false
}
]
},
"items": [
{
"href": "https://apiv3.teamsnap.com/members/1001",
"data": [
{
"name": "id",
"value": 1001
},
{
"name": "team_id",
"value": 101
},
{
"name": "user_id",
"value": 42
},
{
"name": "first_name",
"value": "Sam"
},
{
"name": "last_name",
"value": "Player"
},
{
"name": "number",
"value": 17
},
{
"name": "is_non_player",
"value": false
},
{
"name": "email",
"value": "sam.player@example.com"
}
]
},
{
"href": "https://apiv3.teamsnap.com/members/1002",
"data": [
{
"name": "id",
"value": 1002
},
{
"name": "team_id",
"value": 101
},
{
"name": "first_name",
"value": "Taylor"
},
{
"name": "last_name",
"value": "Coach"
},
{
"name": "is_non_player",
"value": true
},
{
"name": "email",
"value": "taylor.coach@example.com"
}
]
}
]
}
}

View File

@@ -0,0 +1,54 @@
{
"collection": {
"version": "1.0",
"href": "https://apiv3.teamsnap.com/",
"links": [
{
"rel": "self",
"href": "https://apiv3.teamsnap.com/"
}
],
"queries": [
{
"rel": "me",
"href": "https://apiv3.teamsnap.com/me",
"prompt": "Current user"
},
{
"rel": "teams",
"href": "https://apiv3.teamsnap.com/teams{?userId}",
"prompt": "Teams"
},
{
"rel": "members",
"href": "https://apiv3.teamsnap.com/members{?teamId,userId,memberId}",
"prompt": "Members"
},
{
"rel": "events",
"href": "https://apiv3.teamsnap.com/events{?teamId,eventId,contactId}",
"prompt": "Events"
},
{
"rel": "availabilities",
"href": "https://apiv3.teamsnap.com/availabilities{?teamId,eventId,memberId}",
"prompt": "Availabilities"
},
{
"rel": "assignments",
"href": "https://apiv3.teamsnap.com/assignments{?teamId,eventId,memberId}",
"prompt": "Assignments"
},
{
"rel": "eventLineups",
"href": "https://apiv3.teamsnap.com/eventLineups{?eventId}",
"prompt": "Event lineups"
},
{
"rel": "eventLineupEntries",
"href": "https://apiv3.teamsnap.com/eventLineupEntries{?eventLineupId}",
"prompt": "Event lineup entries"
}
]
}
}

View File

@@ -0,0 +1,89 @@
{
"collection": {
"version": "1.0",
"href": "https://apiv3.teamsnap.com/teams",
"links": [
{
"rel": "self",
"href": "https://apiv3.teamsnap.com/teams"
}
],
"queries": [
{
"rel": "search",
"href": "https://apiv3.teamsnap.com/teams{?userId}",
"prompt": "Teams"
}
],
"template": {
"data": [
{
"name": "name",
"value": ""
},
{
"name": "season_name",
"value": ""
},
{
"name": "is_retired",
"value": false
}
]
},
"items": [
{
"href": "https://apiv3.teamsnap.com/teams/101",
"data": [
{
"name": "id",
"value": 101
},
{
"name": "name",
"value": "Walkup Wildcats"
},
{
"name": "season_name",
"value": "Spring 2026"
},
{
"name": "is_retired",
"value": false
}
],
"links": [
{
"rel": "members",
"href": "https://apiv3.teamsnap.com/members?teamId=101"
},
{
"rel": "events",
"href": "https://apiv3.teamsnap.com/events?teamId=101"
}
]
},
{
"href": "https://apiv3.teamsnap.com/teams/202",
"data": [
{
"name": "id",
"value": 202
},
{
"name": "name",
"value": "Retired Example"
},
{
"name": "season_name",
"value": "Fall 2025"
},
{
"name": "is_retired",
"value": true
}
]
}
]
}
}

427
backend/tests/test_api.py Normal file
View File

@@ -0,0 +1,427 @@
from __future__ import annotations
from io import BytesIO
from math import sin, tau
from wave import open as open_wave
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.config import settings
from app.database import Base, SessionLocal, engine
from app.main import app
from app.models import AudioAsset, AudioClip, UserSession
from app.routes.teamsnap import rewrite_teamsnap_urls
@pytest.fixture(autouse=True)
def override_media_root(tmp_path: Path) -> None:
settings.media_root = tmp_path
@pytest.fixture(autouse=True)
def override_cookie_security() -> None:
settings.session_cookie_secure = False
@pytest.fixture(autouse=True)
def reset_database() -> None:
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
@pytest.fixture(autouse=True)
def reset_client_cookies() -> None:
client.cookies.clear()
client = TestClient(app)
def make_test_wav_bytes(*, duration_seconds: float = 0.25, sample_rate: int = 8000, frequency: float = 440.0) -> bytes:
frame_count = max(1, int(duration_seconds * sample_rate))
buffer = BytesIO()
with open_wave(buffer, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(sample_rate)
frames = bytearray()
for index in range(frame_count):
sample = int(0.7 * 32767 * sin(tau * frequency * index / sample_rate))
frames.extend(sample.to_bytes(2, byteorder="little", signed=True))
wav_file.writeframes(bytes(frames))
return buffer.getvalue()
def test_healthcheck() -> None:
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_admin_login_and_session() -> None:
response = client.post("/auth/admin/login", json={"username": "admin", "password": "admin"})
assert response.status_code == 200
session_response = client.get("/auth/session")
assert session_response.status_code == 200
assert session_response.json()["authenticated"] is True
def test_rewrite_teamsnap_urls_uses_same_origin_proxy() -> None:
proxy_root = "https://kif.local.ascorrea.com/api/teamsnap"
payload = {
"collection": {
"href": "https://apiv3.teamsnap.com",
"links": [
{"rel": "self", "href": "https://apiv3.teamsnap.com/teams/1"},
{"rel": "avatar", "href": "https://example.com/avatar.png"},
],
}
}
rewritten = rewrite_teamsnap_urls(payload, "https://apiv3.teamsnap.com", proxy_root)
assert rewritten == {
"collection": {
"href": "https://kif.local.ascorrea.com/api/teamsnap",
"links": [
{"rel": "self", "href": "https://kif.local.ascorrea.com/api/teamsnap/teams/1"},
{"rel": "avatar", "href": "https://example.com/avatar.png"},
],
}
}
def test_teamsnap_token_returns_proxy_api_root() -> None:
db = SessionLocal()
session = UserSession(session_token="teamsnap-session", provider="teamsnap", access_token="token-value")
db.add(session)
db.commit()
db.close()
client.cookies.set(settings.session_cookie_name, "teamsnap-session")
response = client.post(
"/auth/teamsnap/token",
headers={"host": "kif.local.ascorrea.com", "x-forwarded-proto": "https"},
)
assert response.status_code == 200
assert response.json()["api_root"] == "https://kif.local.ascorrea.com/api/teamsnap"
def test_walkup_session_selection_is_persisted_in_session() -> None:
db = SessionLocal()
session = UserSession(
session_token="teamsnap-session",
provider="teamsnap",
external_user_id="user-42",
external_team_id="team-101",
external_player_id="player-1001",
)
db.add(session)
db.commit()
db.close()
client.cookies.set(settings.session_cookie_name, "teamsnap-session")
response = client.post(
"/auth/session/walkup",
json={"external_team_id": "team-101", "external_player_id": "player-1002"},
)
assert response.status_code == 200
assert response.json()["external_user_id"] == "user-42"
assert response.json()["external_team_id"] == "team-101"
assert response.json()["external_player_id"] == "player-1002"
def test_player_can_attach_multiple_clips_to_same_game() -> None:
login = client.post("/auth/admin/login", json={"username": "admin", "password": "admin"})
assert login.status_code == 200
db = SessionLocal()
asset = AudioAsset(
external_team_id="team-1",
owner_external_player_id="player-1",
title="Song",
original_filename="song.mp3",
mime_type="audio/mpeg",
size_bytes=123,
storage_path="uploads/song.mp3",
)
db.add(asset)
db.flush()
first_clip = AudioClip(
asset_id=asset.id,
label="Intro",
start_ms=0,
end_ms=10000,
normalization_status="ready",
normalized_path="clips/intro.mp3",
)
second_clip = AudioClip(
asset_id=asset.id,
label="Chorus",
start_ms=12000,
end_ms=22000,
normalization_status="ready",
normalized_path="clips/chorus.mp3",
)
db.add_all([first_clip, second_clip])
db.commit()
db.refresh(first_clip)
db.refresh(second_clip)
db.close()
first_response = client.post(
"/games/game-1/assignments",
json={
"external_team_id": "team-1",
"external_player_id": "player-1",
"clip_id": first_clip.id,
"batting_slot": 1,
"status": "ready",
},
)
second_response = client.post(
"/games/game-1/assignments",
json={
"external_team_id": "team-1",
"external_player_id": "player-1",
"clip_id": second_clip.id,
"batting_slot": 1,
"status": "ready",
},
)
assert first_response.status_code == 200
assert second_response.status_code == 200
assert first_response.json()["start_ms"] == 0
assert first_response.json()["end_ms"] == 10000
assert second_response.json()["start_ms"] == 12000
assert second_response.json()["end_ms"] == 22000
assignments = client.get("/games/game-1/assignments")
assert assignments.status_code == 200
assignment_ids = [item["clip_id"] for item in assignments.json()]
assert assignment_ids == [second_clip.id, first_clip.id]
def test_upload_creates_default_clip_and_clip_ranges_can_be_updated() -> None:
login = client.post("/auth/admin/login", json={"username": "admin", "password": "admin"})
assert login.status_code == 200
upload = client.post(
"/media/uploads",
data={
"external_team_id": "team-2",
"owner_external_player_id": "player-2",
"title": "Fresh track",
},
files={"file": ("fresh-track.wav", BytesIO(make_test_wav_bytes()), "audio/wav")},
)
assert upload.status_code == 200
asset_id = upload.json()["id"]
clips = client.get("/media/clips", params={"external_team_id": "team-2", "owner_external_player_id": "player-2"})
assert clips.status_code == 200
assert len(clips.json()) == 1
clip = clips.json()[0]
assert clip["asset_id"] == asset_id
assert clip["label"] == "Fresh track"
assert clip["start_ms"] == 0
assert clip["end_ms"] == 30000
assert clip["normalization_status"] == "ready"
assert clip["waveform_duration_ms"] is not None
assert len(clip["waveform_peaks"]) > 0
update = client.patch(
f"/media/clips/{clip['id']}",
json={"start_ms": 2500, "end_ms": 8750},
)
assert update.status_code == 200
updated_clip = update.json()
assert updated_clip["start_ms"] == 2500
assert updated_clip["end_ms"] == 8750
assert updated_clip["label"] == "Fresh track"
def test_clip_updates_can_use_player_scoped_authorization() -> None:
uploader_session = UserSession(session_token="uploader-session", provider="teamsnap")
editor_session = UserSession(session_token="editor-session", provider="teamsnap")
db = SessionLocal()
db.add_all([uploader_session, editor_session])
db.flush()
asset = AudioAsset(
external_team_id="team-3",
owner_external_player_id="player-3",
uploaded_by_session_id=uploader_session.id,
title="Player track",
original_filename="player-track.mp3",
mime_type="audio/mpeg",
size_bytes=123,
storage_path="uploads/player-track.mp3",
)
db.add(asset)
db.flush()
clip = AudioClip(
asset_id=asset.id,
label="Player clip",
start_ms=0,
end_ms=30000,
normalization_status="ready",
normalized_path="clips/player-clip.mp3",
)
db.add(clip)
db.commit()
db.refresh(clip)
db.close()
client.cookies.set(settings.session_cookie_name, "editor-session")
update = client.patch(
f"/media/clips/{clip.id}",
params={"owner_external_player_id": "player-3"},
json={"start_ms": 1500, "end_ms": 9000},
)
assert update.status_code == 200
updated_clip = update.json()
assert updated_clip["start_ms"] == 1500
assert updated_clip["end_ms"] == 9000
assert updated_clip["label"] == "Player clip"
def test_create_clip_uses_team_and_player_scope() -> None:
login = client.post("/auth/admin/login", json={"username": "admin", "password": "admin"})
assert login.status_code == 200
upload = client.post(
"/media/uploads",
data={
"external_team_id": "team-6",
"owner_external_player_id": "player-6",
"title": "Clip source",
},
files={"file": ("clip-source.wav", BytesIO(make_test_wav_bytes()), "audio/wav")},
)
assert upload.status_code == 200
asset_id = upload.json()["id"]
response = client.post(
"/media/clips",
json={
"asset_id": asset_id,
"external_team_id": "team-6",
"owner_external_player_id": "player-6",
"label": "New clip",
"start_ms": 1000,
"end_ms": 6000,
},
)
assert response.status_code == 200
clip = response.json()
assert clip["asset_id"] == asset_id
assert clip["label"] == "New clip"
assert clip["start_ms"] == 1000
assert clip["end_ms"] == 6000
def test_asset_title_can_be_edited() -> None:
login = client.post("/auth/admin/login", json={"username": "admin", "password": "admin"})
assert login.status_code == 200
upload = client.post(
"/media/uploads",
data={
"external_team_id": "team-5",
"owner_external_player_id": "player-5",
"title": "Old title",
},
files={"file": ("old-title.wav", BytesIO(make_test_wav_bytes()), "audio/wav")},
)
assert upload.status_code == 200
asset_id = upload.json()["id"]
update = client.patch(
f"/media/assets/{asset_id}",
json={"title": "New title"},
)
assert update.status_code == 200
assert update.json()["title"] == "New title"
clips = client.get("/media/clips", params={"external_team_id": "team-5", "owner_external_player_id": "player-5"})
assert clips.status_code == 200
assert clips.json()[0]["asset_title"] == "New title"
def test_import_url_creates_default_clip(monkeypatch: pytest.MonkeyPatch) -> None:
login = client.post("/auth/admin/login", json={"username": "admin", "password": "admin"})
assert login.status_code == 200
source_path = settings.media_root / "uploads" / "imported-source.wav"
source_path.parent.mkdir(parents=True, exist_ok=True)
source_path.write_bytes(make_test_wav_bytes())
from app.routes import media as media_routes
def fake_download_media_to_storage(url: str) -> tuple[str, int, str, str]:
return ("uploads/imported-source.wav", source_path.stat().st_size, "imported-source.wav", "Imported Source")
monkeypatch.setattr(media_routes, "download_media_to_storage", fake_download_media_to_storage)
response = client.post(
"/media/imports",
json={
"external_team_id": "team-3",
"owner_external_player_id": "player-3",
"url": "https://example.com/media",
},
)
assert response.status_code == 200
assert response.json()["title"] == "Imported Source"
clips = client.get("/media/clips", params={"external_team_id": "team-3", "owner_external_player_id": "player-3"})
assert clips.status_code == 200
assert len(clips.json()) == 1
clip = clips.json()[0]
assert clip["label"] == "Imported Source"
assert clip["start_ms"] == 0
assert clip["end_ms"] == 30000
assert clip["waveform_duration_ms"] is not None
assert len(clip["waveform_peaks"]) > 0
def test_import_url_surfaces_download_error(monkeypatch: pytest.MonkeyPatch) -> None:
login = client.post("/auth/admin/login", json={"username": "admin", "password": "admin"})
assert login.status_code == 200
from app.routes import media as media_routes
def fake_download_media_to_storage(url: str) -> tuple[str, int, str, str]:
raise media_routes.HTTPException(
status_code=422,
detail="Could not download media from that URL: HTTP Error 403: Forbidden",
)
monkeypatch.setattr(media_routes, "download_media_to_storage", fake_download_media_to_storage)
response = client.post(
"/media/imports",
json={
"external_team_id": "team-4",
"owner_external_player_id": "player-4",
"url": "https://example.com/private",
},
)
assert response.status_code == 422
assert "HTTP Error 403: Forbidden" in response.text

80
docker-compose.yml Normal file
View File

@@ -0,0 +1,80 @@
version: "3.9"
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-walkup}
POSTGRES_USER: ${POSTGRES_USER:-walkup}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-walkup}
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
backend:
build:
context: ./backend
depends_on:
- db
env_file:
- .env
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://walkup:walkup@db:5432/walkup}
MEDIA_ROOT: ${MEDIA_ROOT:-/app/storage}
BACKEND_CORS_ORIGINS: ${BACKEND_CORS_ORIGINS:-https://kif.local.ascorrea.com}
TEAMSNAP_CLIENT_ID_FILE: /run/secrets/teamsnap_client_id
TEAMSNAP_CLIENT_SECRET_FILE: /run/secrets/teamsnap_client_secret
ports:
- "8000:8000"
volumes:
- ./backend:/app
- backend-media:/app/storage
secrets:
- teamsnap_client_id
- teamsnap_client_secret
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --proxy-headers
frontend:
build:
context: ./frontend
depends_on:
- backend
env_file:
- .env
ports:
- "5173:5173"
volumes:
- ./frontend:/app
- frontend-node-modules:/app/node_modules
command: sh -c "npm ci && npm run dev -- --host 0.0.0.0 --port 5173"
proxy:
image: caddy:2.9-alpine
depends_on:
- backend
- frontend
ports:
- "${PROXY_HTTP_PORT:-80}:80"
- "${PROXY_HTTPS_PORT:-443}:443"
volumes:
- ./ops/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
- ./secrets/dev-proxy-cert.pem:/certs/dev-proxy-cert.pem:ro
- ./secrets/dev-proxy-key.pem:/certs/dev-proxy-key.pem:ro
environment:
APP_HOST: ${APP_HOST:-kif.local.ascorrea.com}
volumes:
postgres-data:
backend-media:
frontend-node-modules:
caddy-data:
caddy-config:
secrets:
teamsnap_client_id:
file: ./secrets/teamsnap_client_id.txt
teamsnap_client_secret:
file: ./secrets/teamsnap_client_secret.txt

12
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:22-alpine
WORKDIR /app
RUN apk add --no-cache git
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Walkup</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6359
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "walkup-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.59.0",
"bootstrap": "^5.3.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"teamsnap.js": "github:anthonyscorrea/teamsnap-javascript-sdk#add-eventLineup-eventLineupEntry",
"wavesurfer.js": "^7.12.6"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vite-plugin-pwa": "^0.20.5"
}
}

7
frontend/public/icon.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" rx="48" fill="#132238"/>
<circle cx="128" cy="128" r="78" fill="#f4ede2"/>
<path d="M93 166V92h18l28 33 28-33h18v74h-20v-40l-26 30-26-30v40H93z" fill="#d94f04"/>
<path d="M53 198c18-23 45-35 75-35s57 12 75 35" fill="none" stroke="#d94f04" stroke-width="12" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 392 B

301
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,301 @@
import { Component, useEffect, useState, type ErrorInfo, type ReactElement, type ReactNode } from "react";
import { NavLink, Navigate, Route, Routes, useLocation } from "react-router-dom";
import { WalkupProvider, useWalkupContext } from "./hooks/useWalkupContext";
import { useSession } from "./hooks/useSession";
import { DashboardPage } from "./pages/DashboardPage";
import { GamePage } from "./pages/GamePage";
import { LibraryPage } from "./pages/LibraryPage";
import { OperatorPage } from "./pages/OperatorPage";
import { ProfilePage } from "./pages/ProfilePage";
import { AdminPage } from "./pages/AdminPage";
import { SignInPage } from "./pages/SignInPage";
import { formatTeamLabel } from "./lib/teamsnapHelpers";
function getRouteDestinationLabel(pathname: string) {
switch (pathname) {
case "/":
return "your dashboard";
case "/library":
return "walkup clips";
case "/games":
return "game clips";
case "/operator":
return "the operator console";
default:
return "this page";
}
}
function ProtectedRoute({ children }: { children: ReactElement }) {
const location = useLocation();
const { data, isLoading } = useSession();
if (isLoading) {
return (
<div className="container-fluid py-4">
<div className="card shadow-sm">
<div className="card-body">Loading session...</div>
</div>
</div>
);
}
if (!data?.authenticated) {
return <Navigate to="/signin" replace state={{ from: location }} />;
}
return children;
}
function HomeRoute() {
const walkup = useWalkupContext();
if (walkup.sessionQuery.isLoading) {
return (
<div className="container-fluid py-4">
<div className="card shadow-sm">
<div className="card-body">Loading session...</div>
</div>
</div>
);
}
if (!walkup.sessionQuery.data?.authenticated) {
return <SignInPage />;
}
return (
<DashboardPage />
);
}
function SignInRoute() {
const walkup = useWalkupContext();
if (walkup.sessionQuery.isLoading) {
return (
<div className="container-fluid py-4">
<div className="card shadow-sm">
<div className="card-body">Loading session...</div>
</div>
</div>
);
}
if (walkup.sessionQuery.data?.authenticated) {
return <Navigate to="/" replace />;
}
return <SignInPage />;
}
function TeamSelectionRoute({ children }: { children: ReactElement }) {
return children;
}
function TeamSelectionModal() {
const location = useLocation();
const walkup = useWalkupContext();
if (!walkup.isTeamSnap || !walkup.teamsQuery.isFetched || walkup.hasSelectedTeam) {
return null;
}
return (
<div
className="position-fixed top-0 start-0 w-100 h-100 bg-dark bg-opacity-75 d-flex align-items-center justify-content-center p-3"
role="presentation"
>
<section
className="card shadow-lg border-0 w-100"
style={{ maxWidth: "920px", maxHeight: "88vh" }}
role="dialog"
aria-modal="true"
aria-labelledby="team-selection-title"
>
<div className="card-body d-grid gap-4 overflow-auto p-4 p-lg-5">
<div className="d-grid gap-2">
<p className="text-uppercase small text-secondary-emphasis mb-0">Step 2 of 2</p>
<h2 id="team-selection-title" className="h3 mb-0">
Pick the team you want to use.
</h2>
<p className="text-body-secondary mb-0">
You are signed in with TeamSnap. Choose a team to continue to {getRouteDestinationLabel(location.pathname)}.
</p>
</div>
<div className="row g-4">
<div className="col-12 col-lg-8">
<div className="card shadow-sm h-100">
<div className="card-body d-grid gap-3">
<h3 className="h5 mb-0">Available teams</h3>
{walkup.teamsQuery.isLoading ? (
<div className="text-body-secondary">Loading teams...</div>
) : (
<div className="list-group">
{walkup.teamsQuery.data?.map((team) => {
const teamId = String(team.id);
const selected = teamId === walkup.selectedTeamId;
return (
<button
key={teamId}
type="button"
className={`list-group-item list-group-item-action d-flex justify-content-between align-items-center text-start${
selected ? " active" : ""
}`}
onClick={() => walkup.selectTeam(teamId)}
>
<div>
<strong>{formatTeamLabel(team)}</strong>
<div className={selected ? "text-white-50" : "text-body-secondary"}>Tap to continue</div>
</div>
<span className={`badge rounded-pill ${selected ? "text-bg-light" : "text-bg-secondary"}`}>
{selected ? "Selected" : "Choose"}
</span>
</button>
);
})}
{!walkup.teamsQuery.data?.length ? (
<div className="text-body-secondary">No teams were returned for this account.</div>
) : null}
</div>
)}
</div>
</div>
</div>
<div className="col-12 col-lg-4">
<div className="card bg-body-tertiary border-0 h-100">
<div className="card-body d-grid gap-3">
<h3 className="h5 mb-0">What happens next</h3>
<div className="d-grid gap-2 text-body-secondary">
<div>1. Sign in with TeamSnap.</div>
<div>2. Choose the team you want to manage.</div>
<div>3. Continue into the dashboard, walkup clips, or game tools.</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
);
}
class AppErrorBoundary extends Component<{ children: ReactNode }, { errorMessage: string | null }> {
state = { errorMessage: null };
static getDerivedStateFromError(error: unknown) {
return {
errorMessage: error instanceof Error ? error.message : "Unexpected render error",
};
}
componentDidCatch(error: unknown, errorInfo: ErrorInfo) {
console.error("Walkup render error", error, errorInfo);
}
render() {
if (this.state.errorMessage) {
return (
<div className="container-fluid py-4">
<div className="card shadow-sm">
<div className="card-body d-grid gap-2">
<h2 className="h4 mb-0">App Error</h2>
<div className="text-body-secondary">{this.state.errorMessage}</div>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
function ShellLayout() {
const [navOpen, setNavOpen] = useState(false);
const walkup = useWalkupContext();
const location = useLocation();
const showNavbar = walkup.sessionQuery.data?.authenticated === true;
const showTeamSelectionModal = walkup.isTeamSnap && walkup.teamsQuery.isFetched && !walkup.hasSelectedTeam;
const shellClassName = showNavbar ? "shell is-authenticated" : "shell is-authless";
useEffect(() => {
setNavOpen(false);
}, [location.pathname]);
useEffect(() => {
if (!showTeamSelectionModal) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [showTeamSelectionModal]);
return (
<div className={shellClassName}>
{showNavbar ? (
<header className="navbar navbar-expand-lg navbar-dark bg-dark shadow-sm sticky-top px-3 py-2">
<div className="container-fluid gap-3 align-items-center">
<div className="navbar-brand d-grid gap-0">
<span className="text-uppercase small text-info-emphasis">Baseball audio ops</span>
<span className="fw-semibold fs-4 lh-1 text-white">Walkup</span>
</div>
<button
type="button"
className="navbar-toggler"
aria-expanded={navOpen}
aria-controls="primary-nav"
aria-label={navOpen ? "Close menu" : "Open menu"}
onClick={() => setNavOpen((value) => !value)}
>
<span className="navbar-toggler-icon" aria-hidden="true" />
</button>
<nav id="primary-nav" className={`navbar-collapse collapse${navOpen ? " show" : ""}`}>
<div className="navbar-nav ms-auto gap-2">
<NavLink to="/" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}>
Home
</NavLink>
<NavLink to="/library" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}>
Walkup Clips
</NavLink>
<NavLink to="/games" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}>
Games
</NavLink>
<NavLink to="/operator" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}>
Operator
</NavLink>
<NavLink to="/profile" className={({ isActive }) => `nav-link${isActive ? " active" : ""}`}>
Profile
</NavLink>
</div>
</nav>
</div>
</header>
) : null}
<main className="container-fluid py-4">
<Routes>
<Route path="/signin" element={<SignInRoute />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
<Route path="/" element={<HomeRoute />} />
<Route path="/library" element={<ProtectedRoute><TeamSelectionRoute><LibraryPage /></TeamSelectionRoute></ProtectedRoute>} />
<Route path="/games" element={<ProtectedRoute><TeamSelectionRoute><GamePage /></TeamSelectionRoute></ProtectedRoute>} />
<Route path="/operator" element={<ProtectedRoute><TeamSelectionRoute><OperatorPage /></TeamSelectionRoute></ProtectedRoute>} />
</Routes>
</main>
{showTeamSelectionModal ? <TeamSelectionModal /> : null}
</div>
);
}
export default function App() {
return (
<AppErrorBoundary>
<WalkupProvider>
<ShellLayout />
</WalkupProvider>
</AppErrorBoundary>
);
}

185
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,185 @@
import type {
AudioAsset,
AudioAssetImportCreate,
AudioAssetUpdate,
AudioClip,
AudioClipUpdate,
GameAssignment,
GamePrepResponse,
PlaybackSession,
SessionResponse,
TeamSnapTokenResponse,
} from "./types";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
type UploadAssetPayload = {
teamId: string;
playerId: string;
title: string;
file: File;
onProcessingStart?: () => void;
onUploadProgress?: (percent: number) => void;
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
credentials: "include",
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `Request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
export const api = {
getSession: () => request<SessionResponse>("/auth/session"),
startTeamSnap: (returnTo: string) =>
request<{ authorize_url: string; state: string }>(`/auth/teamsnap/start?return_to=${encodeURIComponent(returnTo)}`),
getTeamSnapToken: () => request<TeamSnapTokenResponse>("/auth/teamsnap/token", { method: "POST" }),
adminLogin: (payload: { username: string; password: string }) =>
request<SessionResponse>("/auth/admin/login", { method: "POST", body: JSON.stringify(payload) }),
logout: () => request<{ ok: boolean }>("/auth/logout", { method: "POST" }),
updateWalkupSessionSelection: (payload: { external_team_id: string; external_player_id: string }) =>
request<SessionResponse>("/auth/session/walkup", { method: "POST", body: JSON.stringify(payload) }),
listAssets: (teamId: string, playerId?: string) =>
request<AudioAsset[]>(
`/media/assets?external_team_id=${encodeURIComponent(teamId)}${
playerId ? `&owner_external_player_id=${encodeURIComponent(playerId)}` : ""
}`,
),
updateAsset: (assetId: number, payload: AudioAssetUpdate, ownerExternalPlayerId?: string) =>
request<AudioAsset>(
`/media/assets/${assetId}${ownerExternalPlayerId ? `?owner_external_player_id=${encodeURIComponent(ownerExternalPlayerId)}` : ""}`,
{ method: "PATCH", body: JSON.stringify(payload) },
),
uploadAsset: async (payload: UploadAssetPayload) => {
const formData = new FormData();
formData.set("external_team_id", payload.teamId);
formData.set("owner_external_player_id", payload.playerId);
formData.set("title", payload.title);
formData.set("file", payload.file);
return new Promise<AudioAsset>((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("POST", `${API_BASE}/media/uploads`);
request.withCredentials = true;
request.upload.addEventListener("progress", (event) => {
if (!event.lengthComputable || event.total <= 0) {
return;
}
payload.onUploadProgress?.(Math.min(100, Math.round((event.loaded / event.total) * 100)));
});
request.upload.addEventListener("load", () => {
payload.onUploadProgress?.(100);
payload.onProcessingStart?.();
});
request.addEventListener("load", () => {
if (request.status < 200 || request.status >= 300) {
reject(new Error(request.responseText || `Request failed: ${request.status}`));
return;
}
resolve(JSON.parse(request.responseText) as AudioAsset);
});
request.addEventListener("error", () => reject(new Error("Upload failed. Check the connection and try again.")));
request.addEventListener("abort", () => reject(new Error("Upload was cancelled.")));
request.send(formData);
});
},
importAssetFromUrl: (payload: AudioAssetImportCreate) =>
request<AudioAsset>("/media/imports", { method: "POST", body: JSON.stringify(payload) }),
deleteAsset: async (assetId: number, ownerExternalPlayerId?: string) => {
const response = await fetch(
`${API_BASE}/media/assets/${assetId}${ownerExternalPlayerId ? `?owner_external_player_id=${encodeURIComponent(ownerExternalPlayerId)}` : ""}`,
{
method: "DELETE",
credentials: "include",
},
);
if (!response.ok) {
throw new Error(await response.text());
}
},
updateClip: (clipId: number, payload: AudioClipUpdate, ownerExternalPlayerId?: string) =>
request<AudioClip>(
`/media/clips/${clipId}${ownerExternalPlayerId ? `?owner_external_player_id=${encodeURIComponent(ownerExternalPlayerId)}` : ""}`,
{ method: "PATCH", body: JSON.stringify(payload) },
),
deleteClip: async (clipId: number, ownerExternalPlayerId?: string) => {
const response = await fetch(
`${API_BASE}/media/clips/${clipId}${ownerExternalPlayerId ? `?owner_external_player_id=${encodeURIComponent(ownerExternalPlayerId)}` : ""}`,
{
method: "DELETE",
credentials: "include",
},
);
if (!response.ok) {
throw new Error(await response.text());
}
},
listClips: (teamId: string, playerId?: string) =>
request<AudioClip[]>(
`/media/clips?external_team_id=${encodeURIComponent(teamId)}${
playerId ? `&owner_external_player_id=${encodeURIComponent(playerId)}` : ""
}`,
),
createClip: (payload: {
asset_id: number;
external_team_id: string;
owner_external_player_id: string;
label: string;
start_ms: number;
end_ms: number;
}) =>
request<AudioClip>("/media/clips", { method: "POST", body: JSON.stringify(payload) }),
listAssignments: (gameId: string, playerId?: string) =>
request<GameAssignment[]>(
`/games/${encodeURIComponent(gameId)}/assignments${
playerId ? `?external_player_id=${encodeURIComponent(playerId)}` : ""
}`,
),
createAssignment: (
gameId: string,
payload: {
external_team_id: string;
external_player_id: string;
clip_id: number;
batting_slot?: number | null;
status: string;
},
) =>
request<GameAssignment>(`/games/${encodeURIComponent(gameId)}/assignments`, {
method: "POST",
body: JSON.stringify(payload),
}),
prepareGame: (gameId: string) => request<GamePrepResponse>(`/games/${encodeURIComponent(gameId)}/prep`),
createPlaybackSession: (gameId: string, teamId: string) =>
request<PlaybackSession>(`/games/${encodeURIComponent(gameId)}/operator/session`, {
method: "POST",
body: JSON.stringify({ external_team_id: teamId }),
}),
triggerPlaybackAssignment: (gameId: string, playbackSessionId: number, assignmentId: number) =>
request<PlaybackSession>(`/games/${encodeURIComponent(gameId)}/operator/session/${playbackSessionId}/trigger`, {
method: "POST",
body: JSON.stringify({ assignment_id: assignmentId, state: "playing" }),
}),
triggerPlaybackClip: (gameId: string, playbackSessionId: number, clipId: number, playerId: string) =>
request<PlaybackSession>(`/games/${encodeURIComponent(gameId)}/operator/session/${playbackSessionId}/trigger`, {
method: "POST",
body: JSON.stringify({ clip_id: clipId, external_player_id: playerId, state: "playing" }),
}),
};

168
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,168 @@
export interface SessionResponse {
authenticated: boolean;
provider?: string | null;
is_admin: boolean;
external_user_id?: string | null;
external_team_id?: string | null;
external_player_id?: string | null;
token_expires_at?: string | null;
}
export interface TeamSnapTokenResponse {
access_token: string;
expires_at?: string | null;
api_root: string;
auth_url: string;
}
export interface AudioAsset {
id: number;
external_team_id: string;
owner_external_player_id: string;
title: string;
original_filename: string;
mime_type: string;
size_bytes: number;
created_at: string;
}
export interface AudioAssetUpdate {
title: string;
}
export interface AudioAssetImportCreate {
external_team_id: string;
owner_external_player_id: string;
url: string;
title?: string;
}
export interface AudioClip {
id: number;
asset_id: number;
external_team_id: string;
owner_external_player_id: string;
asset_title: string;
label: string;
start_ms: number;
end_ms: number;
normalization_status: string;
normalized_url?: string | null;
waveform_duration_ms?: number | null;
waveform_peaks?: number[] | null;
created_at: string;
}
export interface AudioClipUpdate {
start_ms: number;
end_ms: number;
label?: string;
}
export interface GameAssignment {
id: number;
external_team_id: string;
external_game_id: string;
external_player_id: string;
clip_id: number;
clip_label: string;
asset_title: string;
start_ms: number;
end_ms: number;
batting_slot?: number | null;
status: string;
normalized_url?: string | null;
updated_at: string;
}
export interface GamePrepResponse {
external_game_id: string;
external_team_id: string;
prepared_at: string;
assignments: GameAssignment[];
}
export interface PlaybackSession {
id: number;
external_team_id: string;
external_game_id: string;
current_assignment_id?: number | null;
state: string;
last_triggered_at?: string | null;
}
export interface TeamSnapTeam {
id: number | string;
name?: string;
seasonName?: string;
isRetired?: boolean;
[key: string]: unknown;
}
export interface TeamSnapUser {
id: number | string;
firstName?: string;
lastName?: string;
email?: string;
}
export interface TeamSnapMember {
id: number | string;
teamId?: number | string;
userId?: number | string;
isNonPlayer?: boolean;
number?: number | string;
jerseyNumber?: number | string;
jersey_number?: number | string;
firstName?: string;
lastName?: string;
name?: string;
fullName?: string;
displayName?: string;
email?: string;
}
export interface TeamSnapAvailability {
id: number | string;
teamId?: number | string;
eventId?: number | string;
memberId?: number | string;
statusCode?: number | null;
[key: string]: unknown;
}
export interface TeamSnapEvent {
id: number | string;
teamId?: number | string;
name?: string;
isGame?: boolean;
opponentName?: string;
locationName?: string;
startDate?: string | Date;
}
export interface TeamSnapEventLineup {
id: number | string;
eventId?: number | string;
isPublished?: boolean;
[key: string]: unknown;
}
export interface TeamSnapEventLineupEntry {
id: number | string;
eventLineupId?: number | string;
memberId?: number | string;
label?: string;
sequence?: number | string | null;
[key: string]: unknown;
}
export interface TeamSnapAssignment {
id: number | string;
teamId?: number | string;
eventId?: number | string;
memberId?: number | string;
description?: string;
order?: number;
[key: string]: unknown;
}

View File

@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../api/client";
export function useSession() {
return useQuery({
queryKey: ["session"],
queryFn: api.getSession,
});
}

View File

@@ -0,0 +1,135 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "../api/client";
import type { TeamSnapEvent, TeamSnapMember } from "../api/types";
import { queryClient } from "../lib/queryClient";
import { findCurrentPlayer, findNextGame, sortGames } from "../lib/teamsnapHelpers";
import { teamsnapClient } from "../lib/teamsnap";
import { useSession } from "./useSession";
const TEAM_STORAGE_KEY = "walkup.selectedTeamId";
function readStoredTeamId(): string {
if (typeof window === "undefined") {
return "";
}
return window.localStorage.getItem(TEAM_STORAGE_KEY) ?? "";
}
type WalkupContextValue = ReturnType<typeof useBuildWalkupContext>;
const WalkupContext = createContext<WalkupContextValue | null>(null);
function useBuildWalkupContext() {
const sessionQuery = useSession();
const isTeamSnap = sessionQuery.data?.authenticated === true && sessionQuery.data?.provider === "teamsnap";
const [selectedTeamId, setSelectedTeamId] = useState(readStoredTeamId);
const teamsQuery = useQuery({
queryKey: ["teamsnap", "teams"],
queryFn: () => teamsnapClient.loadTeams(),
enabled: isTeamSnap,
});
const teams = teamsQuery.data ?? [];
const selectedTeam = teams.find((team) => String(team.id) === selectedTeamId) ?? null;
const resolvedTeamId = selectedTeam ? String(selectedTeam.id) : "";
useEffect(() => {
if (selectedTeamId && !selectedTeam && teams.length) {
setSelectedTeamId("");
window.localStorage.removeItem(TEAM_STORAGE_KEY);
return;
}
if (!selectedTeamId) {
window.localStorage.removeItem(TEAM_STORAGE_KEY);
return;
}
window.localStorage.setItem(TEAM_STORAGE_KEY, selectedTeamId);
}, [resolvedTeamId, selectedTeam, selectedTeamId, teams.length]);
const membersQuery = useQuery({
queryKey: ["teamsnap", "members", resolvedTeamId],
queryFn: () => teamsnapClient.loadMembers(resolvedTeamId),
enabled: isTeamSnap && Boolean(resolvedTeamId),
});
const eventsQuery = useQuery({
queryKey: ["teamsnap", "events", resolvedTeamId],
queryFn: () => teamsnapClient.loadEvents(resolvedTeamId),
enabled: isTeamSnap && Boolean(resolvedTeamId),
});
const members: TeamSnapMember[] = membersQuery.data ?? [];
const games: TeamSnapEvent[] = sortGames(eventsQuery.data ?? []);
const currentPlayer = findCurrentPlayer(sessionQuery.data?.external_user_id, members);
const nextGame = findNextGame(games);
const currentPlayerId =
sessionQuery.data?.external_team_id === selectedTeamId && sessionQuery.data?.external_player_id
? String(sessionQuery.data.external_player_id)
: currentPlayer
? String(currentPlayer.id)
: "";
useEffect(() => {
if (!isTeamSnap || !resolvedTeamId || !currentPlayer) {
return;
}
const selectedPlayerId = String(currentPlayer.id);
if (
sessionQuery.data?.external_team_id === selectedTeamId &&
sessionQuery.data?.external_player_id === selectedPlayerId
) {
return;
}
void api
.updateWalkupSessionSelection({
external_team_id: resolvedTeamId,
external_player_id: selectedPlayerId,
})
.then(async () => {
await queryClient.invalidateQueries({ queryKey: ["session"] });
})
.catch(() => {
// Keep the UI working even if the session cache update fails.
});
}, [currentPlayer, isTeamSnap, resolvedTeamId, selectedTeamId, sessionQuery.data?.external_player_id, sessionQuery.data?.external_team_id]);
function selectTeam(teamId: string) {
setSelectedTeamId(teamId);
}
return {
isTeamSnap,
sessionQuery,
teamsQuery,
selectedTeam,
selectedTeamId,
hasSelectedTeam: Boolean(resolvedTeamId),
selectTeam,
membersQuery,
members,
currentPlayer,
currentPlayerId,
eventsQuery,
games,
nextGame,
};
}
export function WalkupProvider({ children }: { children: ReactNode }) {
const value = useBuildWalkupContext();
const memoizedValue = useMemo(() => value, [value]);
return <WalkupContext.Provider value={memoizedValue}>{children}</WalkupContext.Provider>;
}
export function useWalkupContext() {
const value = useContext(WalkupContext);
if (!value) {
throw new Error("useWalkupContext must be used within WalkupProvider");
}
return value;
}

26
frontend/src/lib/media.ts Normal file
View File

@@ -0,0 +1,26 @@
function formatSecondsValue(milliseconds: number): string {
return (milliseconds / 1000).toFixed(1);
}
export function formatClipRange(startMs: number, endMs: number): string {
return `${formatSecondsValue(startMs)}s to ${formatSecondsValue(endMs)}s`;
}
export function formatPlaybackPosition(milliseconds: number): string {
const roundedSeconds = Math.round(Math.max(0, milliseconds) / 100) / 10;
const wholeSeconds = Math.floor(roundedSeconds);
const tenths = Math.round((roundedSeconds - wholeSeconds) * 10);
const hours = Math.floor(wholeSeconds / 3600);
const minutes = Math.floor((wholeSeconds % 3600) / 60);
const seconds = wholeSeconds % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${tenths}`;
}
if (minutes > 0) {
return `${minutes}:${String(seconds).padStart(2, "0")}.${tenths}`;
}
return `${seconds}.${tenths}s`;
}

View File

@@ -0,0 +1,19 @@
import type { GamePrepResponse } from "../api/types";
const KEY_PREFIX = "walkup-prep:";
export function savePreparedGame(gameId: string, payload: GamePrepResponse): void {
localStorage.setItem(`${KEY_PREFIX}${gameId}`, JSON.stringify(payload));
}
export function loadPreparedGame(gameId: string): GamePrepResponse | null {
const raw = localStorage.getItem(`${KEY_PREFIX}${gameId}`);
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as GamePrepResponse;
} catch {
return null;
}
}

View File

@@ -0,0 +1,4 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient();

View File

@@ -0,0 +1,186 @@
import teamsnapScriptUrl from "teamsnap.js/lib/teamsnap.js?url";
import { api } from "../api/client";
import type {
TeamSnapAssignment,
TeamSnapAvailability,
TeamSnapEvent,
TeamSnapEventLineup,
TeamSnapEventLineupEntry,
TeamSnapMember,
TeamSnapTeam,
TeamSnapUser,
} from "../api/types";
type TeamSnapSdk = {
auth?: (token: string) => Promise<void> | void;
enablePersistence?: () => void;
loadCollections?: () => Promise<void>;
loadMe?: () => Promise<TeamSnapUser>;
loadTeams?: (...args: unknown[]) => Promise<TeamSnapTeam[]>;
loadMembers?: (params: unknown) => Promise<TeamSnapMember[]>;
loadEvents?: (params: unknown) => Promise<TeamSnapEvent[]>;
loadEventLineups?: (params: unknown) => Promise<TeamSnapEventLineup[]>;
loadAvailabilities?: (params: unknown) => Promise<TeamSnapAvailability[]>;
loadAssignments?: (params: unknown) => Promise<TeamSnapAssignment[]>;
bulkLoad?: (teamId: string | number, typesOrParams?: unknown) => Promise<TeamSnapBulkItem[]>;
createEventLineup?: (data?: Record<string, unknown>) => unknown;
saveEventLineup?: (eventLineup: unknown) => Promise<unknown> | void;
deleteEventLineup?: (eventLineup: unknown) => Promise<unknown> | void;
createEventLineupEntry?: (data?: Record<string, unknown>) => unknown;
saveEventLineupEntry?: (eventLineupEntry: unknown) => Promise<unknown> | void;
deleteEventLineupEntry?: (eventLineupEntry: unknown) => Promise<unknown> | void;
memberName?: (member: TeamSnapMember, reverse?: boolean, forSort?: boolean) => string;
collections?: {
};
};
type TeamSnapBulkItem = {
type?: string;
id?: number | string;
eventId?: number | string;
eventLineupId?: number | string;
memberId?: number | string;
sequence?: number | string | null;
[key: string]: unknown;
};
let sdkPromise: Promise<TeamSnapSdk> | null = null;
declare global {
interface Window {
teamsnap?: TeamSnapSdk;
}
}
function loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(`script[data-sdk="teamsnap"][src="${src}"]`);
if (existing) {
if (window.teamsnap) {
resolve();
return;
}
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error("Failed to load TeamSnap SDK")), { once: true });
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.dataset.sdk = "teamsnap";
script.onload = () => resolve();
script.onerror = () => reject(new Error("Failed to load TeamSnap SDK"));
document.head.appendChild(script);
});
}
async function getSdk(): Promise<TeamSnapSdk> {
if (!sdkPromise) {
sdkPromise = loadScript(teamsnapScriptUrl).then(() => {
if (!window.teamsnap) {
throw new Error("TeamSnap SDK did not initialize");
}
return window.teamsnap;
});
}
return sdkPromise;
}
async function ensureAuthorized(): Promise<TeamSnapSdk> {
const sdk = await getSdk();
const token = await api.getTeamSnapToken();
(sdk as TeamSnapSdk & { apiUrl?: string; authUrl?: string }).apiUrl = token.api_root;
(sdk as TeamSnapSdk & { apiUrl?: string; authUrl?: string }).authUrl = token.auth_url;
if (sdk.auth) {
await sdk.auth(token.access_token);
}
if (sdk.loadCollections) {
await sdk.loadCollections();
}
if (sdk.enablePersistence) {
sdk.enablePersistence();
}
return sdk;
}
export const teamsnapClient = {
async loadMe(): Promise<TeamSnapUser | null> {
const sdk = await ensureAuthorized();
if (sdk.loadMe) {
return sdk.loadMe();
}
return null;
},
async loadTeams(): Promise<TeamSnapTeam[]> {
const sdk = await ensureAuthorized();
if (sdk.loadTeams) {
const teams = await sdk.loadTeams();
return teams.filter((team) => team.isRetired !== true);
}
return [];
},
async loadMembers(teamId: string): Promise<TeamSnapMember[]> {
const sdk = await ensureAuthorized();
if (sdk.loadMembers) {
return sdk.loadMembers({ teamId });
}
return [];
},
async loadEvents(teamId: string): Promise<TeamSnapEvent[]> {
const sdk = await ensureAuthorized();
if (sdk.loadEvents) {
return sdk.loadEvents({ teamId });
}
return [];
},
async loadAvailabilities(teamId: string, eventId?: string): Promise<TeamSnapAvailability[]> {
const sdk = await ensureAuthorized();
if (sdk.loadAvailabilities) {
return sdk.loadAvailabilities(eventId ? { teamId, eventId } : { teamId });
}
return [];
},
async loadAssignments(teamId: string, eventId?: string): Promise<TeamSnapAssignment[]> {
const sdk = await ensureAuthorized();
if (sdk.loadAssignments) {
return sdk.loadAssignments(eventId ? { teamId, eventId } : { teamId });
}
return [];
},
async loadEventLineupData(teamId: string, eventId: string): Promise<{
eventLineup: TeamSnapEventLineup | null;
entries: TeamSnapEventLineupEntry[];
}> {
const sdk = await ensureAuthorized();
if (!sdk.loadEventLineups) {
return { eventLineup: null, entries: [] };
}
const eventLineups = await sdk.loadEventLineups(eventId);
const eventLineup = eventLineups.length ? eventLineups[eventLineups.length - 1] : null;
const eventLineupWithLinks = eventLineup as TeamSnapEventLineup & {
loadItems?: (linkName: string) => Promise<TeamSnapEventLineupEntry[]>;
} | null;
if (!eventLineupWithLinks?.loadItems) {
return { eventLineup, entries: [] };
}
try {
const rawEntries = await eventLineupWithLinks.loadItems("eventLineupEntries");
const entries = rawEntries
.filter((item): item is TeamSnapEventLineupEntry => item.type === "eventLineupEntry")
.sort((left, right) => {
const leftSequence = Number(left.sequence ?? Number.MAX_SAFE_INTEGER);
const rightSequence = Number(right.sequence ?? Number.MAX_SAFE_INTEGER);
return leftSequence - rightSequence;
});
return { eventLineup, entries };
} catch {
return { eventLineup, entries: [] };
}
},
};

View File

@@ -0,0 +1,334 @@
import type {
TeamSnapAssignment,
TeamSnapAvailability,
TeamSnapEvent,
TeamSnapEventLineupEntry,
TeamSnapMember,
TeamSnapTeam,
} from "../api/types";
function asDisplayText(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
export function toDate(value: Date | string | undefined | null): Date | null {
if (!value) {
return null;
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value;
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
export function formatMemberName(member: TeamSnapMember | null | undefined): string {
if (!member) {
return "Unknown player";
}
const sdkName = typeof window !== "undefined" ? window.teamsnap?.memberName?.(member) : "";
if (typeof sdkName === "string" && sdkName.trim()) {
return sdkName.trim();
}
const name =
asDisplayText(member.name) ||
asDisplayText(member.fullName) ||
asDisplayText(member.displayName) ||
[asDisplayText(member.firstName), asDisplayText(member.lastName)].filter(Boolean).join(" ").trim();
return name || `Player ${member.id}`;
}
export function formatMemberJerseyNumber(member: TeamSnapMember | null | undefined): string {
if (!member) {
return "";
}
const value =
member.number ??
member.jerseyNumber ??
member.jersey_number;
const text = asDisplayText(value);
return text ? `#${text}` : "";
}
export function formatTeamLabel(team: TeamSnapTeam | null | undefined): string {
if (!team) {
return "No team selected";
}
const teamName = asDisplayText(team.name) || `Team ${team.id}`;
const seasonName = asDisplayText(team.seasonName);
return seasonName ? `${teamName} (${seasonName})` : teamName;
}
export function findCurrentPlayer(externalUserId: string | number | null | undefined, members: TeamSnapMember[]): TeamSnapMember | null {
if (externalUserId == null || externalUserId === "") {
return null;
}
const meId = String(externalUserId);
return (
members.find((member) => member.userId != null && String(member.userId) === meId) ??
null
);
}
export function formatGameTitle(game: TeamSnapEvent): string {
const name = asDisplayText(game.name);
if (name) {
return name;
}
const opponentName = asDisplayText(game.opponentName);
if (opponentName) {
return `vs ${opponentName}`;
}
return `Game ${game.id}`;
}
export function formatGameDate(game: TeamSnapEvent): string {
const date = toDate(game.startDate);
if (!date) {
return "Date TBD";
}
return date.toLocaleString([], {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function sortGames(events: TeamSnapEvent[]): TeamSnapEvent[] {
return [...events]
.filter((event) => event.isGame)
.sort((left, right) => {
const leftTime = toDate(left.startDate)?.getTime() ?? Number.MAX_SAFE_INTEGER;
const rightTime = toDate(right.startDate)?.getTime() ?? Number.MAX_SAFE_INTEGER;
return leftTime - rightTime;
});
}
export function findNextGame(games: TeamSnapEvent[]): TeamSnapEvent | null {
const now = Date.now();
return games.find((game) => {
const start = toDate(game.startDate);
return start ? start.getTime() >= now : false;
}) ?? games[0] ?? null;
}
function toId(value: number | string | undefined | null): string {
return value == null ? "" : String(value);
}
function toSequence(value: number | string | null | undefined): number {
if (value == null || value === "") {
return Number.MAX_SAFE_INTEGER;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER;
}
function getMemberLookupName(member: TeamSnapMember): string {
return [asDisplayText(member.firstName), asDisplayText(member.lastName)].filter(Boolean).join(" ").trim();
}
function getLineupEntryMemberName(entry: TeamSnapEventLineupEntry): string {
return asDisplayText(entry.memberName);
}
function getMemberLastName(member: TeamSnapMember): string {
return asDisplayText(member.lastName);
}
function matchesLineupEntry(member: TeamSnapMember, entry: TeamSnapEventLineupEntry): boolean {
const memberId = toId(member.id);
if (memberId && toId(entry.memberId) === memberId) {
return true;
}
const memberName = getMemberLookupName(member);
const entryMemberName = getLineupEntryMemberName(entry);
return memberName !== "" && entryMemberName !== "" && memberName === entryMemberName;
}
export function findLineupEntryForMember(
member: TeamSnapMember,
lineupEntries: TeamSnapEventLineupEntry[],
): TeamSnapEventLineupEntry | null {
const matchingEntries = lineupEntries
.filter((entry) => matchesLineupEntry(member, entry))
.sort((left, right) => {
const leftSequence = toSequence(left.sequence);
const rightSequence = toSequence(right.sequence);
if (leftSequence !== rightSequence) {
return leftSequence - rightSequence;
}
return toId(left.id).localeCompare(toId(right.id));
});
return matchingEntries[0] ?? null;
}
export function orderMembersByAssignments(
members: TeamSnapMember[],
assignments: TeamSnapAssignment[],
): TeamSnapMember[] {
if (!assignments.length) {
return members;
}
const byId = new Map(members.map((member) => [toId(member.id), member] as const));
const ordered: TeamSnapMember[] = [];
const seen = new Set<string>();
for (const assignment of assignments) {
const memberId = toId(assignment.memberId);
if (!memberId || seen.has(memberId)) {
continue;
}
const member = byId.get(memberId);
if (!member) {
continue;
}
ordered.push(member);
seen.add(memberId);
}
for (const member of members) {
const memberId = toId(member.id);
if (seen.has(memberId)) {
continue;
}
ordered.push(member);
}
return ordered;
}
export function isPlayerMember(member: TeamSnapMember): boolean {
return member.isNonPlayer !== true;
}
export function getAvailabilityRank(statusCode: number | null | undefined): number {
if (statusCode === 1) {
return 0;
}
if (statusCode === 2) {
return 1;
}
if (statusCode == null) {
return 2;
}
return 3;
}
export function getAvailabilityLabel(statusCode: number | null | undefined): string {
if (statusCode === 1) {
return "Yes";
}
if (statusCode === 2) {
return "Maybe";
}
if (statusCode === 0) {
return "No";
}
return "Unknown";
}
export function orderMembersByLineupAndRsvps(
members: TeamSnapMember[],
lineupEntries: TeamSnapEventLineupEntry[],
availabilities: TeamSnapAvailability[],
): TeamSnapMember[] {
if (!members.length) {
return [];
}
const ordered: TeamSnapMember[] = [];
const seen = new Set<string>();
const availabilityByMemberId = new Map<string, TeamSnapAvailability>();
for (const availability of availabilities) {
const memberId = toId(availability.memberId);
if (memberId) {
availabilityByMemberId.set(memberId, availability);
}
}
const lineupMembers: TeamSnapMember[] = [];
const lineupMembersSeen = new Set<string>();
for (const entry of lineupEntries.slice().sort((left, right) => {
const leftSequence = toSequence(left.sequence);
const rightSequence = toSequence(right.sequence);
if (leftSequence !== rightSequence) {
return leftSequence - rightSequence;
}
return toId(left.id).localeCompare(toId(right.id));
})) {
const member = members.find((candidate) => matchesLineupEntry(candidate, entry));
if (!member) {
continue;
}
const memberId = toId(member.id);
if (lineupMembersSeen.has(memberId)) {
continue;
}
lineupMembers.push(member);
lineupMembersSeen.add(memberId);
}
for (const member of lineupMembers) {
const memberId = toId(member.id);
if (seen.has(memberId)) {
continue;
}
ordered.push(member);
seen.add(memberId);
}
const rankedMembers = members
.filter((member) => isPlayerMember(member))
.filter((member) => !seen.has(toId(member.id)))
.map((member, index) => {
const memberId = toId(member.id);
const availability = availabilityByMemberId.get(memberId);
return {
member,
rank: getAvailabilityRank(availability?.statusCode as number | null | undefined),
index,
};
})
.sort((left, right) => {
if (left.rank !== right.rank) {
return left.rank - right.rank;
}
const leftLastName = getMemberLastName(left.member).toLowerCase();
const rightLastName = getMemberLastName(right.member).toLowerCase();
if (leftLastName !== rightLastName) {
return leftLastName.localeCompare(rightLastName);
}
const leftFirstName = asDisplayText(left.member.firstName).toLowerCase();
const rightFirstName = asDisplayText(right.member.firstName).toLowerCase();
if (leftFirstName !== rightFirstName) {
return leftFirstName.localeCompare(rightFirstName);
}
return left.index - right.index;
})
;
for (const entry of rankedMembers) {
ordered.push(entry.member);
seen.add(toId(entry.member.id));
}
for (const member of members) {
const memberId = toId(member.id);
if (seen.has(memberId)) {
continue;
}
ordered.push(member);
}
return ordered;
}

19
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,19 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { queryClient } from "./lib/queryClient";
import "bootstrap/dist/css/bootstrap.css";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,68 @@
import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import { queryClient } from "../lib/queryClient";
export function AdminPage() {
const navigate = useNavigate();
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("admin");
const [error, setError] = useState<string | null>(null);
async function handleAdminLogin(event: FormEvent) {
event.preventDefault();
setError(null);
try {
await api.adminLogin({ username, password });
await queryClient.invalidateQueries({ queryKey: ["session"] });
navigate("/");
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to sign in");
}
}
return (
<section className="container-fluid min-vh-100 d-flex align-items-center justify-content-center py-4">
<div className="row w-100 justify-content-center g-4">
<div className="col-12 col-md-8 col-lg-5 col-xl-4">
<div className="card shadow-sm border-0">
<div className="card-body p-4 p-lg-5 d-grid gap-4">
<div className="d-grid gap-2">
<p className="text-uppercase small text-primary-emphasis mb-0">Support</p>
<h1 className="h2 mb-0">Admin sign-in</h1>
<p className="text-body-secondary mb-0">Use local credentials for bootstrap and maintenance.</p>
</div>
<form className="d-grid gap-3" onSubmit={handleAdminLogin}>
<label className="form-label d-grid gap-2">
<span>Username</span>
<input className="form-control" value={username} onChange={(event) => setUsername(event.target.value)} />
</label>
<label className="form-label d-grid gap-2">
<span>Password</span>
<input
className="form-control"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
<button type="submit" className="btn btn-primary btn-lg">
Sign in
</button>
</form>
<div className="small text-body-secondary">TeamSnap sign-in remains the normal entry point.</div>
</div>
</div>
</div>
{error ? (
<div className="col-12 col-md-8 col-lg-5 col-xl-4">
<div className="alert alert-danger mb-0" role="alert">
{error}
</div>
</div>
) : null}
</div>
</section>
);
}

View File

@@ -0,0 +1,96 @@
import { useNavigate } from "react-router-dom";
import { useWalkupContext } from "../hooks/useWalkupContext";
import { formatGameDate, formatGameTitle, formatMemberName } from "../lib/teamsnapHelpers";
export function DashboardPage() {
const navigate = useNavigate();
const walkup = useWalkupContext();
if (!walkup.isTeamSnap) {
return (
<section className="container-fluid py-4">
<div className="card bg-dark text-white border-0 shadow-sm">
<div className="card-body p-4 p-lg-5">
<p className="text-uppercase small text-info-emphasis mb-2">Player flow</p>
<h1 className="h2">Sign in with TeamSnap to resolve your player and team context.</h1>
<p className="mb-0 text-white-50">The player dashboard depends on your TeamSnap user, roster membership, and upcoming games.</p>
</div>
</div>
</section>
);
}
return (
<section className="container-fluid py-4 d-grid gap-4">
<div className="card bg-dark text-white border-0 shadow-sm">
<div className="card-body p-4 p-lg-5">
<p className="text-uppercase small text-info-emphasis mb-2">Player dashboard</p>
<h1 className="h2">{walkup.nextGame ? formatGameTitle(walkup.nextGame) : "No upcoming game found yet."}</h1>
<p className="mb-0 text-white-50">
{walkup.currentPlayer
? `${formatMemberName(walkup.currentPlayer)} is ready for the selected team.`
: "Your TeamSnap user is connected, but no matching player record was found on the selected team."}
</p>
</div>
</div>
<div className="row g-4">
<div className="col-12 col-xl-6">
<div className="card shadow-sm h-100">
<div className="card-body d-grid gap-3">
<h2 className="h4 mb-0">Next game</h2>
{walkup.nextGame ? (
<>
<strong className="fs-5">{formatGameTitle(walkup.nextGame)}</strong>
<div className="text-body-secondary">{formatGameDate(walkup.nextGame)}</div>
{walkup.nextGame.locationName ? <div className="text-body-secondary">{walkup.nextGame.locationName}</div> : null}
<div className="d-flex flex-wrap gap-2">
<button type="button" className="btn btn-primary" onClick={() => navigate("/library")}>
Add walkup clip
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => navigate(`/games?gameId=${encodeURIComponent(String(walkup.nextGame?.id ?? ""))}`)}
>
Attach clip to game
</button>
</div>
</>
) : (
<div className="text-body-secondary">No upcoming games were returned for this team.</div>
)}
</div>
</div>
</div>
<div className="col-12 col-xl-6">
<div className="card shadow-sm h-100">
<div className="card-body d-grid gap-3">
<h2 className="h4 mb-0">Other games</h2>
<div className="list-group">
{walkup.eventsQuery.isLoading ? <div className="text-body-secondary">Loading games...</div> : null}
{walkup.games.slice(0, 8).map((game) => (
<button
key={String(game.id)}
type="button"
className="list-group-item list-group-item-action d-flex justify-content-between align-items-center text-start"
onClick={() => navigate(`/games?gameId=${encodeURIComponent(String(game.id))}`)}
>
<div>
<strong>{formatGameTitle(game)}</strong>
<div className="text-body-secondary">{formatGameDate(game)}</div>
</div>
<span className="badge rounded-pill text-bg-warning">{String(game.id) === String(walkup.nextGame?.id) ? "Next" : "Browse"}</span>
</button>
))}
{!walkup.eventsQuery.isLoading && !walkup.games.length ? (
<div className="text-body-secondary">No games were returned for the selected team.</div>
) : null}
</div>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,218 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { api } from "../api/client";
import { useWalkupContext } from "../hooks/useWalkupContext";
import { loadPreparedGame, savePreparedGame } from "../lib/offlinePrep";
import { queryClient } from "../lib/queryClient";
import { formatGameDate, formatGameTitle, formatMemberName, formatTeamLabel } from "../lib/teamsnapHelpers";
export function GamePage() {
const walkup = useWalkupContext();
const [searchParams, setSearchParams] = useSearchParams();
const [selectedGameId, setSelectedGameId] = useState(searchParams.get("gameId") ?? "");
const [clipId, setClipId] = useState<number>(0);
const [slot, setSlot] = useState<number>(1);
const [offlineMessage, setOfflineMessage] = useState<string | null>(null);
const teamId = walkup.selectedTeamId;
const playerId = walkup.currentPlayerId;
useEffect(() => {
const requestedGameId = searchParams.get("gameId");
if (requestedGameId) {
setSelectedGameId(requestedGameId);
return;
}
if (!selectedGameId && walkup.nextGame) {
setSelectedGameId(String(walkup.nextGame.id));
}
}, [searchParams, selectedGameId, walkup.nextGame]);
const clipsQuery = useQuery({
queryKey: ["clips", teamId, playerId],
queryFn: () => api.listClips(teamId, playerId),
enabled: Boolean(teamId && playerId),
});
const assignmentsQuery = useQuery({
queryKey: ["assignments", selectedGameId, playerId],
queryFn: () => api.listAssignments(selectedGameId, playerId),
enabled: Boolean(selectedGameId && playerId),
});
const prepQuery = useQuery({
queryKey: ["prep", selectedGameId],
queryFn: () => api.prepareGame(selectedGameId),
enabled: Boolean(selectedGameId),
});
const saveMutation = useMutation({
mutationFn: () =>
api.createAssignment(selectedGameId, {
external_team_id: teamId,
external_player_id: playerId,
clip_id: clipId,
batting_slot: slot,
status: "ready",
}),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["assignments", selectedGameId, playerId] }),
queryClient.invalidateQueries({ queryKey: ["prep", selectedGameId] }),
]);
},
});
function selectGame(gameId: string) {
setSelectedGameId(gameId);
setSearchParams({ gameId });
setOfflineMessage(null);
}
function cachePreparedGame() {
if (!prepQuery.data) {
setOfflineMessage("Prepare the game first so there is something to cache locally.");
return;
}
savePreparedGame(selectedGameId, prepQuery.data);
setOfflineMessage(`Cached ${prepQuery.data.assignments.length} assignments for offline operator use.`);
}
const selectedGame = walkup.games.find((game) => String(game.id) === selectedGameId) ?? null;
const cachedPrep = selectedGameId ? loadPreparedGame(selectedGameId) : null;
if (!walkup.isTeamSnap) {
return (
<section className="container-fluid py-4">
<div className="card shadow-sm">
<div className="card-body">Reconnect with TeamSnap to attach clips to games.</div>
</div>
</section>
);
}
if (!teamId || !playerId) {
return (
<section className="container-fluid py-4">
<div className="card shadow-sm">
<div className="card-body">
No player record was found on the selected team, so game-specific clip selection is unavailable.
</div>
</div>
</section>
);
}
return (
<section className="container-fluid py-4 d-grid gap-4">
<div className="card bg-dark text-white border-0 shadow-sm">
<div className="card-body p-4 p-lg-5">
<p className="text-uppercase small text-info-emphasis mb-2">Game clips</p>
<h1 className="h2">{selectedGame ? formatGameTitle(selectedGame) : "Select a game"}</h1>
<p className="mb-0 text-white-50">
{formatMemberName(walkup.currentPlayer)} can attach clips from song files in their own library to any game on{" "}
{formatTeamLabel(walkup.selectedTeam)}.
</p>
</div>
</div>
<div className="row g-4">
<div className="col-12 col-xl-6">
<div className="card shadow-sm h-100">
<div className="card-body d-grid gap-3">
<div className="d-grid gap-2">
<label className="form-label d-grid gap-2">
<span>Selected game</span>
<select className="form-select" value={selectedGameId} onChange={(event) => selectGame(event.target.value)}>
<option value="">Select a game</option>
{walkup.games.map((game) => (
<option key={String(game.id)} value={String(game.id)}>
{formatGameTitle(game)}
</option>
))}
</select>
</label>
<div className="text-body-secondary">
{selectedGame ? formatGameDate(selectedGame) : "Choose a game to attach clips."}
</div>
{walkup.nextGame ? <div className="text-body-secondary">Next game: {formatGameTitle(walkup.nextGame)}</div> : null}
</div>
</div>
</div>
</div>
<div className="col-12 col-xl-6">
<div className="card shadow-sm h-100">
<div className="card-body d-grid gap-3">
<h2 className="h4 mb-0">Attach a clip</h2>
{selectedGame ? (
<>
<div className="text-body-secondary">{formatGameDate(selectedGame)}</div>
<label className="form-label d-grid gap-2">
<span>Clip</span>
<select className="form-select" value={clipId} onChange={(event) => setClipId(Number(event.target.value))}>
<option value={0}>Select clip</option>
{clipsQuery.data?.map((clip) => (
<option key={clip.id} value={clip.id}>
{clip.label} from song {clip.asset_title}
</option>
))}
</select>
</label>
<label className="form-label d-grid gap-2">
<span>Suggested batting slot</span>
<input className="form-control" type="number" value={slot} onChange={(event) => setSlot(Number(event.target.value))} />
</label>
<button type="button" className="btn btn-primary" disabled={!clipId} onClick={() => void saveMutation.mutateAsync()}>
{saveMutation.isPending ? "Saving..." : "Attach clip to this game"}
</button>
{saveMutation.error instanceof Error ? <div className="text-body-secondary">{saveMutation.error.message}</div> : null}
</>
) : (
<div className="text-body-secondary">Pick a game to attach clips.</div>
)}
</div>
</div>
</div>
</div>
<div className="row g-4">
<div className="col-12 col-xl-6">
<div className="card shadow-sm h-100">
<div className="card-body d-grid gap-3">
<h2 className="h4 mb-0">Your selected clips</h2>
<div className="list-group">
{assignmentsQuery.data?.map((assignment) => (
<div className="list-group-item d-flex justify-content-between align-items-center" key={assignment.id}>
<div>
<strong>{assignment.clip_label}</strong>
<div className="text-body-secondary">
{assignment.asset_title} {assignment.batting_slot ? `| slot ${assignment.batting_slot}` : ""}
</div>
</div>
<span className="badge rounded-pill text-bg-warning">{assignment.status}</span>
</div>
))}
{!assignmentsQuery.isLoading && !assignmentsQuery.data?.length ? (
<div className="text-body-secondary">No clips attached to this game yet.</div>
) : null}
</div>
</div>
</div>
</div>
<div className="col-12 col-xl-6">
<div className="card shadow-sm h-100">
<div className="card-body d-grid gap-3">
<h2 className="h4 mb-0">Prepared payload</h2>
<div className="d-grid gap-2">
<div className="text-body-secondary">Prepared at: {prepQuery.data?.prepared_at ?? "Not prepared yet"}</div>
<div className="text-body-secondary">Assignments in package: {prepQuery.data?.assignments.length ?? 0}</div>
<div className="text-body-secondary">Cached locally: {cachedPrep ? `${cachedPrep.assignments.length} assignments` : "No"}</div>
</div>
<button type="button" className="btn btn-outline-secondary" onClick={cachePreparedGame} disabled={!selectedGameId}>
Cache on this device
</button>
{offlineMessage ? <div className="text-body-secondary">{offlineMessage}</div> : null}
</div>
</div>
</div>
</div>
</section>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,673 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { api } from "../api/client";
import type { AudioClip, GameAssignment } from "../api/types";
import { useWalkupContext } from "../hooks/useWalkupContext";
import { loadPreparedGame } from "../lib/offlinePrep";
import { teamsnapClient } from "../lib/teamsnap";
import {
formatGameDate,
formatGameTitle,
formatMemberName,
formatMemberJerseyNumber,
formatTeamLabel,
findLineupEntryForMember,
isPlayerMember,
orderMembersByLineupAndRsvps,
} from "../lib/teamsnapHelpers";
function clipKey(kind: "assignment" | "library", id: number | string): string {
return `${kind}:${id}`;
}
type NowPlaying = {
key: string;
title: string;
subtitle: string;
};
const DEFAULT_FADE_OUT_MS = 1000;
function getAvailabilityDotClass(statusCode: number | null | undefined): string {
if (statusCode === 1) {
return "is-yes";
}
if (statusCode === 0) {
return "is-no";
}
if (statusCode === 2) {
return "is-maybe";
}
return "is-blank";
}
function getAvailabilityDotLabel(statusCode: number | null | undefined): string {
if (statusCode === 1) {
return "Availability yes";
}
if (statusCode === 0) {
return "Availability no";
}
if (statusCode === 2) {
return "Availability maybe";
}
return "Availability unset";
}
export function OperatorPage() {
const walkup = useWalkupContext();
const [searchParams, setSearchParams] = useSearchParams();
const [selectedGameId, setSelectedGameId] = useState(searchParams.get("gameId") ?? "");
const [selectedPlayerId, setSelectedPlayerId] = useState("");
const [expandedPlayerId, setExpandedPlayerId] = useState("");
const [playerFilter, setPlayerFilter] = useState<"players" | "nonPlayers" | "all">("players");
const [playbackSessionId, setPlaybackSessionId] = useState<number | null>(null);
const [playingClipKey, setPlayingClipKey] = useState<string | null>(null);
const [nowPlaying, setNowPlaying] = useState<NowPlaying | null>(null);
const [isPlaybackPlaying, setIsPlaybackPlaying] = useState(false);
const selectedPlayerWasManualRef = useRef(false);
const hasInitializedExpandedPlayerRef = useRef(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const mediaSourceRef = useRef<MediaElementAudioSourceNode | null>(null);
const gainNodeRef = useRef<GainNode | null>(null);
const playbackRangeRef = useRef<{ startSeconds: number; endSeconds: number } | null>(null);
const fadeOutTimerRef = useRef<number | null>(null);
const teamId = walkup.selectedTeamId;
useEffect(() => {
const requestedGameId = searchParams.get("gameId");
if (requestedGameId) {
setSelectedGameId(requestedGameId);
return;
}
if (!selectedGameId && walkup.nextGame) {
setSelectedGameId(String(walkup.nextGame.id));
}
}, [searchParams, selectedGameId, walkup.nextGame]);
useEffect(() => {
stopPlayback();
setPlaybackSessionId(null);
setExpandedPlayerId("");
hasInitializedExpandedPlayerRef.current = false;
}, [selectedGameId]);
useEffect(() => {
stopPlayback();
}, [selectedPlayerId]);
const assignmentsQuery = useQuery({
queryKey: ["assignments", selectedGameId],
queryFn: () => api.listAssignments(selectedGameId),
enabled: Boolean(selectedGameId),
retry: 0,
});
const preparedGame = selectedGameId ? loadPreparedGame(selectedGameId) : null;
const assignmentList = assignmentsQuery.data ?? preparedGame?.assignments ?? [];
const eventLineupQuery = useQuery({
queryKey: ["teamsnap", "eventLineup", teamId, selectedGameId],
queryFn: () => teamsnapClient.loadEventLineupData(teamId, selectedGameId),
enabled: Boolean(teamId && selectedGameId),
});
const availabilityQuery = useQuery({
queryKey: ["teamsnap", "availabilities", teamId, selectedGameId],
queryFn: () => teamsnapClient.loadAvailabilities(teamId, selectedGameId),
enabled: Boolean(teamId && selectedGameId),
});
const orderedMembers = useMemo(
() =>
orderMembersByLineupAndRsvps(
walkup.members,
eventLineupQuery.data?.entries ?? [],
availabilityQuery.data ?? [],
),
[availabilityQuery.data, eventLineupQuery.data?.entries, walkup.members],
);
const visibleMembers =
playerFilter === "all"
? orderedMembers
: orderedMembers.filter((member) => (playerFilter === "players" ? isPlayerMember(member) : !isPlayerMember(member)));
useEffect(() => {
if (!selectedGameId || !visibleMembers.length) {
return;
}
if (!selectedPlayerId || !selectedPlayerWasManualRef.current) {
setSelectedPlayerId(String(visibleMembers[0].id));
}
}, [selectedGameId, selectedPlayerId, visibleMembers]);
useEffect(() => {
if (!visibleMembers.length) {
setExpandedPlayerId("");
hasInitializedExpandedPlayerRef.current = false;
return;
}
const expandedStillVisible = visibleMembers.some((member) => String(member.id) === expandedPlayerId);
if (!hasInitializedExpandedPlayerRef.current) {
setExpandedPlayerId(String(visibleMembers[0].id));
hasInitializedExpandedPlayerRef.current = true;
return;
}
if (expandedPlayerId && !expandedStillVisible) {
setExpandedPlayerId(String(visibleMembers[0].id));
}
}, [expandedPlayerId, visibleMembers]);
useEffect(() => {
if (!selectedPlayerId) {
return;
}
const selectedIsVisible = visibleMembers.some((member) => String(member.id) === selectedPlayerId);
if (!selectedIsVisible) {
selectedPlayerWasManualRef.current = false;
setSelectedPlayerId(visibleMembers[0] ? String(visibleMembers[0].id) : "");
}
}, [selectedPlayerId, visibleMembers]);
const selectedPlayer =
walkup.members.find((member) => String(member.id) === selectedPlayerId) ??
(selectedPlayerId ? { id: selectedPlayerId } : null);
const selectedPlayerJersey = selectedPlayer ? formatMemberJerseyNumber(selectedPlayer) : "";
const selectedAssignments = useMemo(
() => assignmentList.filter((assignment) => assignment.external_player_id === selectedPlayerId),
[assignmentList, selectedPlayerId],
);
const createSession = useMutation({
mutationFn: () => api.createPlaybackSession(selectedGameId, teamId),
onSuccess: (session) => setPlaybackSessionId(session.id),
});
const triggerAssignmentMutation = useMutation({
mutationFn: (assignmentId: number) => {
if (!playbackSessionId) {
throw new Error("Start an operator session first");
}
return api.triggerPlaybackAssignment(selectedGameId, playbackSessionId, assignmentId);
},
});
const triggerClipMutation = useMutation({
mutationFn: (clip: AudioClip) => {
if (!playbackSessionId) {
throw new Error("Start an operator session first");
}
return api.triggerPlaybackClip(selectedGameId, playbackSessionId, clip.id, selectedPlayerId);
},
});
const selectedGame = walkup.games.find((game) => String(game.id) === selectedGameId) ?? null;
function selectGame(gameId: string) {
selectedPlayerWasManualRef.current = false;
setSelectedGameId(gameId);
setSelectedPlayerId("");
setExpandedPlayerId("");
setPlayingClipKey(null);
setNowPlaying(null);
setSearchParams({ gameId });
}
function getAudio() {
const audio = audioRef.current ?? new Audio();
if (!audioRef.current) {
audio.onplay = () => {
setIsPlaybackPlaying(true);
};
audio.onpause = () => {
setIsPlaybackPlaying(false);
};
audio.onended = () => {
stopPlayback();
};
audio.ontimeupdate = () => {
const range = playbackRangeRef.current;
if (!range) {
return;
}
if (audio.currentTime >= range.endSeconds) {
stopPlayback();
}
};
const AudioContextCtor = window.AudioContext ?? (window as Window & typeof globalThis & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (AudioContextCtor && !audioContextRef.current) {
const context = new AudioContextCtor();
const source = context.createMediaElementSource(audio);
const gain = context.createGain();
gain.gain.value = 1;
source.connect(gain);
gain.connect(context.destination);
audioContextRef.current = context;
mediaSourceRef.current = source;
gainNodeRef.current = gain;
}
}
audioRef.current = audio;
return audio;
}
function setPlaybackGain(value: number) {
const gainNode = gainNodeRef.current;
if (gainNode) {
gainNode.gain.cancelScheduledValues(gainNode.context.currentTime);
gainNode.gain.setValueAtTime(value, gainNode.context.currentTime);
}
}
function clearFadeOutTimer() {
if (fadeOutTimerRef.current !== null) {
window.clearTimeout(fadeOutTimerRef.current);
fadeOutTimerRef.current = null;
}
}
function stopPlayback(resetGain = true) {
clearFadeOutTimer();
const audio = audioRef.current;
if (audio) {
audio.pause();
audio.currentTime = 0;
}
if (resetGain) {
setPlaybackGain(1);
}
playbackRangeRef.current = null;
setPlayingClipKey(null);
setNowPlaying(null);
setIsPlaybackPlaying(false);
}
function fadeOutPlayback(durationMs = DEFAULT_FADE_OUT_MS) {
const audio = audioRef.current;
if (!audio || audio.paused) {
stopPlayback();
return;
}
clearFadeOutTimer();
const gainNode = gainNodeRef.current;
if (!gainNode) {
stopPlayback();
return;
}
const safeDuration = Math.max(1, durationMs);
const now = gainNode.context.currentTime;
const currentGain = gainNode.gain.value;
gainNode.gain.cancelScheduledValues(now);
gainNode.gain.setValueAtTime(currentGain, now);
gainNode.gain.linearRampToValueAtTime(0, now + safeDuration / 1000);
fadeOutTimerRef.current = window.setTimeout(() => {
stopPlayback(false);
}, safeDuration);
}
async function playAudio(
url: string | null | undefined,
key: string,
playingItem: NowPlaying,
startMs: number,
endMs: number,
onPlay?: () => Promise<unknown>,
) {
if (!url) {
return;
}
const audio = getAudio();
if (playingClipKey === key && !audio.paused) {
stopPlayback();
return;
}
setPlayingClipKey(key);
setNowPlaying(playingItem);
setIsPlaybackPlaying(false);
audio.pause();
setPlaybackGain(1);
if (audioContextRef.current?.state === "suspended") {
await audioContextRef.current.resume();
}
const startSeconds = startMs / 1000;
const endSeconds = endMs / 1000;
playbackRangeRef.current = { startSeconds, endSeconds };
const metadataReady = new Promise<void>((resolve) => {
audio.onloadedmetadata = () => {
if (playbackRangeRef.current?.endSeconds === endSeconds) {
audio.currentTime = startSeconds;
}
resolve();
};
});
audio.src = `${import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"}${url}`;
await metadataReady;
try {
await audio.play();
if (onPlay) {
await onPlay();
}
} catch (error) {
if (audio.paused) {
setPlayingClipKey(null);
setNowPlaying(null);
}
throw error;
}
}
async function playAssignment(assignment: GameAssignment) {
await playAudio(
assignment.normalized_url,
clipKey("assignment", assignment.id),
{
key: clipKey("assignment", assignment.id),
title: assignment.clip_label,
subtitle: formatMemberName(selectedPlayer),
},
assignment.start_ms,
assignment.end_ms,
() => triggerAssignmentMutation.mutateAsync(assignment.id),
);
}
async function playClip(clip: AudioClip) {
await playAudio(
clip.normalized_url,
clipKey("library", clip.id),
{
key: clipKey("library", clip.id),
title: clip.label,
subtitle: formatMemberName(selectedPlayer),
},
clip.start_ms,
clip.end_ms,
() => triggerClipMutation.mutateAsync(clip),
);
}
if (!walkup.isTeamSnap) {
return (
<section className="page-grid">
<div className="panel">Reconnect with TeamSnap to run operator mode.</div>
</section>
);
}
return (
<section className="page-grid operator-page">
{isPlaybackPlaying && nowPlaying ? (
<div className="operator-toolbar">
<div className="operator-toolbar-copy">
<span className="operator-toolbar-label">Now playing</span>
<strong>{nowPlaying.title}</strong>
<span className="muted">{nowPlaying.subtitle}</span>
</div>
<div className="operator-toolbar-actions">
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => stopPlayback()}>
Stop
</button>
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => fadeOutPlayback()}>
Fade out
</button>
</div>
</div>
) : null}
<div className="hero">
<p className="eyebrow">Operator mode</p>
<h1>{selectedGame ? formatGameTitle(selectedGame) : "Select a game to operate"}</h1>
<p>
Any player can operate. The player list now follows the event lineup first, then RSVP order, and each expanded
row shows the current game clips before the player&apos;s library clips.
</p>
</div>
<div className="panel-grid">
<div className="panel">
<div className="d-grid gap-2">
<label className="form-label d-grid gap-2">
<span>Selected game</span>
<select className="form-select" value={selectedGameId} onChange={(event) => selectGame(event.target.value)}>
<option value="">Select a game</option>
{walkup.games.map((game) => (
<option key={String(game.id)} value={String(game.id)}>
{formatGameTitle(game)}
</option>
))}
</select>
</label>
<div className="text-body-secondary">
{selectedGame ? formatGameDate(selectedGame) : "Choose a game to operate."}
</div>
{walkup.nextGame ? <div className="text-body-secondary">Next game: {formatGameTitle(walkup.nextGame)}</div> : null}
</div>
</div>
<div className="panel stack">
<div className="row">
<h2 style={{ margin: 0 }}>Players</h2>
<label className="field" style={{ marginLeft: "auto", minWidth: 180 }}>
Filter
<select className="form-select" value={playerFilter} onChange={(event) => setPlayerFilter(event.target.value as typeof playerFilter)}>
<option value="players">Players</option>
<option value="nonPlayers">Non-players</option>
<option value="all">All</option>
</select>
</label>
</div>
<div className="list-group operator-player-list">
{visibleMembers.map((member) => {
const memberId = String(member.id);
const jerseyNumber = formatMemberJerseyNumber(member);
const lineupEntry = findLineupEntryForMember(member, eventLineupQuery.data?.entries ?? []);
const availability = (availabilityQuery.data ?? []).find(
(entry) => String(entry.memberId) === memberId,
) ?? null;
const assignmentCount = assignmentList.filter((assignment) => assignment.external_player_id === memberId).length;
const isExpanded = memberId === expandedPlayerId;
const expansionId = `player-clips-${memberId}`;
const availabilityStatusCode = availability?.statusCode ?? null;
const playerMeta = [
assignmentCount ? `${assignmentCount} game clip${assignmentCount === 1 ? "" : "s"}` : null,
lineupEntry?.label ?? null,
].filter(Boolean);
return (
<div className={`operator-player-card${isExpanded ? " is-selected" : ""}`} key={memberId}>
<button
type="button"
className={`list-group-item list-group-item-action d-flex justify-content-between align-items-center text-start${isExpanded ? " active" : ""}`}
onClick={() => {
selectedPlayerWasManualRef.current = true;
setSelectedPlayerId(memberId);
setExpandedPlayerId((current) => (current === memberId ? "" : memberId));
}}
aria-expanded={isExpanded}
aria-controls={expansionId}
id={`player-${memberId}-toggle`}
>
<div className="operator-player-summary">
<div className="operator-player-heading">
<strong>
<span
className={`operator-availability-dot ${getAvailabilityDotClass(availabilityStatusCode)}`}
aria-label={getAvailabilityDotLabel(availabilityStatusCode)}
title={getAvailabilityDotLabel(availabilityStatusCode)}
/>
{formatMemberName(member)}
{jerseyNumber ? ` ${jerseyNumber}` : ""}
</strong>
{lineupEntry ? <span className="pill">Lineup {lineupEntry.sequence ?? "?"}</span> : null}
</div>
<div className="muted">{playerMeta.join(" • ")}</div>
</div>
<span className="operator-player-chevron" aria-hidden="true">
{isExpanded ? "" : ""}
</span>
</button>
{isExpanded ? (
<div
className="operator-expansion"
id={expansionId}
role="region"
aria-labelledby={`player-${memberId}-toggle`}
>
<div className="operator-section">
<div className="operator-section-title">
<h3 style={{ margin: 0 }}>Game clips</h3>
<span className="muted">Attached to this game</span>
</div>
<div className="operator-clip-list">
{selectedAssignments.length ? (
selectedAssignments.map((assignment) => {
const key = clipKey("assignment", assignment.id);
const isPlaying = playingClipKey === key;
return (
<div className="operator-clip-row" key={assignment.id}>
<div className="operator-clip-copy">
<strong>{assignment.clip_label}</strong>
<div className="muted">
{assignment.asset_title} {assignment.batting_slot ? `| slot ${assignment.batting_slot}` : ""}
</div>
</div>
<button
type="button"
className={`btn btn-sm ${isPlaying ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => void playAssignment(assignment)}
aria-pressed={isPlaying}
>
<span className={`operator-clip-button-indicator${isPlaying ? " is-playing" : ""}`} />
{isPlaying ? "Stop" : "Play"}
</button>
</div>
);
})
) : (
<div className="muted">No clips attached to this game for this player yet.</div>
)}
</div>
</div>
<div className="operator-section">
<div className="operator-section-title">
<h3 style={{ margin: 0 }}>Clip library</h3>
<span className="muted">Available clips for this player</span>
</div>
<div className="operator-clip-list">
<LibraryClips
teamId={teamId}
playerId={selectedPlayerId}
playingClipKey={playingClipKey}
onPlayClip={playClip}
/>
</div>
</div>
<div className="operator-section">
<h3 style={{ margin: 0 }}>Debug</h3>
<details className="operator-debug-details">
<summary>Show raw lineup data</summary>
<pre className="operator-debug">
{JSON.stringify(
{
selectedPlayerId,
selectedPlayerName: formatMemberName(member),
rawEventLineup: eventLineupQuery.data?.eventLineup ?? null,
rawEventLineupEntries: eventLineupQuery.data?.entries ?? [],
matchedLineupEntry: lineupEntry,
matchedLineupEntryCount: (eventLineupQuery.data?.entries ?? []).filter(
(entry) => String(entry.memberId) === memberId,
).length,
},
null,
2,
)}
</pre>
</details>
</div>
</div>
) : null}
</div>
);
})}
{!visibleMembers.length ? <div className="muted">No members match this filter.</div> : null}
</div>
</div>
<div className="panel stack">
<h2>Session</h2>
<button type="button" className="btn btn-primary" disabled={!selectedGameId || !teamId} onClick={() => void createSession.mutateAsync()}>
{createSession.isPending ? "Starting..." : playbackSessionId ? "Session ready" : "Start operator session"}
</button>
<div className="panel-note">Team: {formatTeamLabel(walkup.selectedTeam)}</div>
<div className="panel-note">Game: {selectedGame ? formatGameDate(selectedGame) : "Select a game"}</div>
<div className="panel-note">
Player:{" "}
{selectedPlayer ? `${formatMemberName(selectedPlayer)}${selectedPlayerJersey ? ` ${selectedPlayerJersey}` : ""}` : "Select a player"}
</div>
{triggerAssignmentMutation.error instanceof Error ? <div className="muted">{triggerAssignmentMutation.error.message}</div> : null}
{triggerClipMutation.error instanceof Error ? <div className="muted">{triggerClipMutation.error.message}</div> : null}
</div>
</div>
</section>
);
}
function LibraryClips({
teamId,
playerId,
playingClipKey,
onPlayClip,
}: {
teamId: string;
playerId: string;
playingClipKey: string | null;
onPlayClip: (clip: AudioClip) => Promise<void>;
}) {
const fallbackClipsQuery = useQuery({
queryKey: ["clips", teamId, playerId],
queryFn: () => api.listClips(teamId, playerId),
enabled: Boolean(teamId && playerId),
});
if (fallbackClipsQuery.isLoading) {
return <div className="muted">Loading library clips...</div>;
}
if (!fallbackClipsQuery.data?.length) {
return <div className="muted">No library clips available for this player.</div>;
}
return (
<>
{fallbackClipsQuery.data.map((clip) => {
const key = clipKey("library", clip.id);
const isPlaying = playingClipKey === key;
return (
<div className="operator-clip-row" key={clip.id}>
<div className="operator-clip-copy">
<strong>{clip.label}</strong>
</div>
<button
type="button"
className={`btn btn-sm ${isPlaying ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => void onPlayClip(clip)}
aria-pressed={isPlaying}
>
<span className={`operator-clip-button-indicator${isPlaying ? " is-playing" : ""}`} />
{isPlaying ? "Stop" : "Play"}
</button>
</div>
);
})}
</>
);
}

View File

@@ -0,0 +1,98 @@
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import { useWalkupContext } from "../hooks/useWalkupContext";
import { formatMemberName, formatTeamLabel } from "../lib/teamsnapHelpers";
import { queryClient } from "../lib/queryClient";
export function ProfilePage() {
const navigate = useNavigate();
const walkup = useWalkupContext();
const logoutMutation = useMutation({
mutationFn: api.logout,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["session"] });
await queryClient.removeQueries({ queryKey: ["teamsnap"] });
navigate("/signin");
},
});
async function reconnect() {
const auth = await api.startTeamSnap(window.location.pathname);
window.location.href = auth.authorize_url;
}
return (
<section className="container-fluid py-4 d-grid gap-4">
<div className="card bg-dark text-white border-0 shadow-sm">
<div className="card-body p-4 p-lg-5">
<p className="text-uppercase small text-info-emphasis mb-2">Profile</p>
<h1 className="h2 mb-3">{walkup.hasSelectedTeam ? formatTeamLabel(walkup.selectedTeam) : "Choose your team"}</h1>
<p className="mb-0 text-white-50">
Session details and the selected team live here. The team choice is stored on this device and reused on the
next visit.
</p>
</div>
</div>
<div className="row g-4">
<div className="col-12 col-lg-6">
<div className="card shadow-sm h-100">
<div className="card-body d-grid gap-3">
<h2 className="h4 mb-0">Session</h2>
<div className="text-body-secondary">Provider: {walkup.sessionQuery.data?.provider ?? "none"}</div>
<div className="text-body-secondary">Authenticated: {walkup.sessionQuery.data?.authenticated ? "yes" : "no"}</div>
<div className="text-body-secondary">
Player: {walkup.currentPlayer ? formatMemberName(walkup.currentPlayer) : "No matching player on the selected team"}
</div>
<div className="d-flex flex-wrap gap-2">
{walkup.isTeamSnap ? (
<button type="button" className="btn btn-outline-secondary" onClick={() => void reconnect()}>
Reconnect TeamSnap
</button>
) : null}
<button type="button" className="btn btn-outline-secondary" onClick={() => void logoutMutation.mutateAsync()}>
{logoutMutation.isPending ? "Signing out..." : "Sign out"}
</button>
</div>
</div>
</div>
</div>
<div className="col-12 col-lg-6">
<div className="card shadow-sm h-100">
<div className="card-body d-grid gap-3">
<h2 className="h4 mb-0">Selected team</h2>
{walkup.isTeamSnap ? (
<>
<label className="form-label d-grid gap-2">
<span>Team and season</span>
<select
className="form-select"
value={walkup.selectedTeamId}
onChange={(event) => walkup.selectTeam(event.target.value)}
disabled={walkup.teamsQuery.isLoading}
>
<option value="">Select a team</option>
{walkup.teamsQuery.data?.map((team) => (
<option key={String(team.id)} value={String(team.id)}>
{formatTeamLabel(team)}
</option>
))}
</select>
</label>
<div className="text-body-secondary">
{walkup.hasSelectedTeam
? `Current selection: ${formatTeamLabel(walkup.selectedTeam)}`
: "Pick a team to continue into the app."}
</div>
</>
) : (
<div className="text-body-secondary">Team selection is available for TeamSnap-backed sessions.</div>
)}
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,59 @@
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { api } from "../api/client";
export function SignInPage() {
const navigate = useNavigate();
const location = useLocation();
const [error, setError] = useState<string | null>(null);
async function handleTeamSnapStart() {
try {
const routeState = location.state as { from?: { pathname?: string; search?: string } } | null;
const from = routeState?.from;
const returnTo =
from?.pathname && from.pathname !== "/signin"
? `${from.pathname}${from.search ?? ""}`
: "/";
const data = await api.startTeamSnap(returnTo);
window.location.href = data.authorize_url;
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to start TeamSnap sign-in");
}
}
return (
<section className="container-fluid min-vh-100 d-flex align-items-center justify-content-center py-4">
<div className="row w-100 justify-content-center g-4">
<div className="col-12 col-md-8 col-lg-5 col-xl-4">
<div className="card shadow-sm border-0">
<div className="card-body p-4 p-lg-5 d-grid gap-4">
<div className="d-grid gap-2">
<p className="text-uppercase small text-primary-emphasis mb-0">Walkup</p>
<h1 className="h2 mb-0">Sign in</h1>
<p className="text-body-secondary mb-0">Use TeamSnap to continue into your team dashboard.</p>
</div>
<div className="d-grid gap-3">
<p className="text-body-secondary mb-0">
After sign-in, you will choose your team and land in the app with your session ready.
</p>
<button type="button" className="btn btn-primary btn-lg w-100" onClick={handleTeamSnapStart}>
Sign in with TeamSnap
</button>
</div>
<div className="small text-body-secondary">TeamSnap sign-in is the primary access path.</div>
</div>
</div>
</div>
{error ? (
<div className="col-12 col-md-8 col-lg-5 col-xl-4">
<div className="alert alert-danger mb-0" role="alert">
{error}
</div>
</div>
) : null}
</div>
</section>
);
}

965
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,965 @@
:root {
color-scheme: light;
--bg: #f7f3eb;
--ink: #132238;
--muted: #5f6670;
--accent: #d94f04;
--accent-soft: #ffd19b;
--line: rgba(19, 34, 56, 0.1);
--panel: rgba(255, 255, 255, 0.9);
--panel-border: rgba(19, 34, 56, 0.12);
font-family: var(--bs-body-font-family);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(217, 79, 4, 0.18), transparent 24%),
linear-gradient(135deg, #faf6ef 0%, #f2e3cd 100%);
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select {
font: inherit;
}
.shell {
min-height: 100vh;
}
.operator-page {
padding-bottom: 112px;
}
.page-grid {
display: grid;
gap: 1.25rem;
}
.hero {
padding: 1.75rem;
border-radius: 1.25rem;
background: linear-gradient(135deg, rgba(19, 34, 56, 0.96), rgba(217, 79, 4, 0.95));
color: white;
box-shadow: 0 20px 40px rgba(19, 34, 56, 0.14);
}
.hero p {
max-width: 50rem;
}
.page-title {
margin: 0;
font-size: clamp(2rem, 4vw, 3.5rem);
letter-spacing: -0.04em;
}
.panel-subtitle {
color: var(--muted);
font-size: 0.95rem;
letter-spacing: 0.02em;
text-transform: none;
}
.panel-grid {
display: grid;
gap: 1.25rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.hero-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.section-header {
display: grid;
gap: 0.25rem;
margin-bottom: 0.85rem;
}
.clip-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.clip-list-add-button {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 3rem;
height: 3rem;
border-radius: 999px;
padding: 0;
}
.clip-list-add-button svg {
width: 1.2rem;
height: 1.2rem;
fill: currentColor;
}
.walkup-modal-backdrop {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(11, 18, 28, 0.76);
backdrop-filter: blur(10px);
z-index: 3000;
}
.walkup-modal {
max-width: 1080px;
max-height: 92vh;
overflow: auto;
position: relative;
z-index: 3001;
}
.walkup-modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 1.25rem 0;
}
.walkup-modal-body {
display: grid;
gap: 1rem;
padding: 1.25rem;
}
.walkup-stepper {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.walkup-step {
padding: 0.4rem 0.7rem;
border-radius: 999px;
background: rgba(19, 34, 56, 0.08);
color: var(--muted);
font-size: 0.85rem;
}
.walkup-step.is-active {
background: var(--accent-soft);
color: var(--ink);
}
.walkup-step.is-complete {
background: rgba(47, 158, 68, 0.14);
color: #25643b;
}
.walkup-modal-actions {
justify-content: flex-start;
}
.source-progress-panel {
display: grid;
gap: 0.45rem;
padding: 0.85rem;
border: 1px solid rgba(19, 34, 56, 0.12);
border-radius: 0.85rem;
background: rgba(19, 34, 56, 0.04);
}
.source-progress-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
font-size: 0.95rem;
}
.source-progress-bar {
position: relative;
overflow: hidden;
height: 0.65rem;
border-radius: 999px;
background: rgba(19, 34, 56, 0.12);
}
.source-progress-bar-fill {
position: absolute;
inset: 0 auto 0 0;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent), #f5a13b);
transition: width 160ms ease;
}
.source-progress-bar.is-indeterminate .source-progress-bar-fill {
width: 42% !important;
animation: source-progress-slide 1.05s ease-in-out infinite;
}
@keyframes source-progress-slide {
0% {
transform: translateX(-115%);
}
100% {
transform: translateX(250%);
}
}
.walkup-panel-actions {
justify-content: flex-end;
margin-top: 0.85rem;
}
.panel {
padding: 1.25rem;
border: 1px solid var(--panel-border);
border-radius: 1rem;
background: var(--panel);
box-shadow: 0 18px 32px rgba(19, 34, 56, 0.08);
backdrop-filter: blur(12px);
}
.panel h2,
.panel h3 {
margin-top: 0;
}
.stack {
display: grid;
gap: 0.75rem;
}
.clip-summary {
display: grid;
gap: 0.35rem;
padding: 0.85rem 0;
border-bottom: 1px solid var(--line);
}
.clip-summary:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.clip-summary-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: nowrap;
}
.clip-summary-title-row {
flex: 1 1 auto;
min-width: 0;
display: block;
line-height: 1.15;
}
.clip-summary-title-row strong {
min-width: 0;
overflow-wrap: anywhere;
}
.icon-button-circle {
width: 2rem;
height: 2rem;
min-width: 2rem;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-button-bare {
padding: 0;
border: 0;
background: transparent;
line-height: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-button-menu {
width: 1.75rem;
height: 1.75rem;
min-width: 1.75rem;
border-radius: 999px;
overflow: visible;
}
.icon-button-menu svg {
width: 1rem;
height: 1rem;
display: block;
fill: currentColor;
}
.icon-button svg {
width: 1em;
height: 1em;
vertical-align: -0.125em;
fill: currentColor;
display: block;
}
.icon-button {
line-height: 0;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
@media (hover: hover) and (pointer: fine) {
.icon-button:hover:not(:disabled) {
background: rgba(19, 34, 56, 0.06);
}
}
.icon-button:disabled {
opacity: 0.45;
}
.clip-summary-menu-wrap {
position: relative;
display: flex;
align-items: center;
flex: 0 0 auto;
}
.clip-summary-menu {
position: absolute;
top: calc(100% + 0.45rem);
right: 0;
z-index: 10;
min-width: 12rem;
padding: 0.45rem;
border: 1px solid var(--panel-border);
border-radius: 0.85rem;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 16px 28px rgba(19, 34, 56, 0.14);
display: grid;
gap: 0.35rem;
}
.clip-summary-menu-item {
width: 100%;
border: 0;
background: transparent;
text-align: left;
padding: 0.45rem 0.5rem;
border-radius: 0.5rem;
color: var(--ink);
display: flex;
align-items: center;
gap: 0.45rem;
}
.clip-summary-menu-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
color: var(--muted);
flex: 0 0 auto;
}
.clip-summary-menu-icon svg {
width: 1em;
height: 1em;
vertical-align: -0.125em;
fill: currentColor;
display: block;
}
.clip-summary-menu-label {
padding: 0.45rem 0.5rem;
border-top: 1px solid var(--line);
font-size: 0.88rem;
color: var(--muted);
overflow-wrap: anywhere;
}
.icon-button {
min-width: 2.1rem;
padding-inline: 0.5rem;
text-align: center;
}
.row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.eyebrow {
margin: 0;
color: rgba(244, 237, 226, 0.8);
font-size: 0.8rem;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.muted {
color: var(--muted);
}
.field {
display: grid;
gap: 0.4rem;
}
.field input,
.field select {
width: 100%;
padding: 0.75rem 0.85rem;
border: 1px solid var(--line);
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.92);
}
.list {
display: grid;
gap: 0.65rem;
}
.list-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem 0.95rem;
border-radius: 0.75rem;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.78);
color: var(--ink);
}
.pill {
display: inline-flex;
align-items: center;
padding: 0.35rem 0.65rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--ink);
font-size: 0.85rem;
}
.panel-note {
padding: 0.75rem 0.85rem;
border-radius: 0.75rem;
background: rgba(19, 34, 56, 0.06);
color: var(--muted);
}
.asset-card {
display: grid;
gap: 0.75rem;
padding: 0.9rem 1rem;
border-radius: 1rem;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.78);
}
.asset-card-header {
display: flex;
gap: 0.9rem;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
}
.asset-card-copy {
display: grid;
gap: 0.25rem;
min-width: 0;
flex: 1 1 240px;
}
.asset-card-copy strong,
.asset-card-filename {
overflow-wrap: anywhere;
}
.asset-card-actions {
justify-content: flex-end;
flex-wrap: wrap;
}
.asset-card-meta {
display: grid;
gap: 0.35rem;
}
.asset-card-footer {
justify-content: flex-start;
}
.asset-card .field input {
min-width: 0;
}
.clip-editor {
display: grid;
gap: 0.85rem;
padding: 1rem;
border: 1px solid var(--line);
border-radius: 0.9rem;
background: rgba(255, 255, 255, 0.78);
}
.clip-editor-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.clip-editor-range {
font-size: 0.95rem;
color: var(--ink);
}
.clip-waveform-shell {
display: grid;
gap: 0.75rem;
padding: 0.9rem;
border: 1px solid var(--line);
border-radius: 0.95rem;
background: rgba(255, 255, 255, 0.72);
}
.clip-waveform-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.clip-waveform-meta {
display: flex;
align-items: center;
gap: 0.55rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.clip-waveform-meta .btn-group {
flex: 0 0 auto;
}
.clip-wavesurfer {
overflow: hidden;
min-height: 7rem;
border-radius: 0.9rem;
border: 1px solid rgba(19, 34, 56, 0.12);
background:
linear-gradient(180deg, rgba(19, 34, 56, 0.05), rgba(19, 34, 56, 0.02)),
rgba(19, 34, 56, 0.03);
overscroll-behavior: contain;
}
.clip-wavesurfer ::part(region) {
border-inline: 2px solid rgba(217, 79, 4, 0.78);
border-radius: 0.65rem;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22);
}
.clip-wavesurfer ::part(region-handle-left),
.clip-wavesurfer ::part(region-handle-right) {
width: 1.5rem;
height: 1.8rem !important;
top: 50% !important;
bottom: auto !important;
border-radius: 0.3rem;
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-grip-vertical' viewBox='0 0 16 16'%3E%3Cpath d='M7 2a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0'/%3E%3C/svg%3E"),
linear-gradient(90deg, rgba(19, 34, 56, 0.94), rgba(19, 34, 56, 0.94)),
rgba(19, 34, 56, 0.94);
background-position: center center;
background-repeat: no-repeat;
background-size: 0.82rem 0.82rem, 2px 100%, auto;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.92), 0 0.25rem 0.65rem rgba(19, 34, 56, 0.16);
cursor: ew-resize;
transform: translateY(-50%);
touch-action: none;
}
.clip-wavesurfer ::part(region-handle-right) {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-grip-vertical' viewBox='0 0 16 16'%3E%3Cpath d='M7 2a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M7 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0m3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0'/%3E%3C/svg%3E"),
linear-gradient(90deg, rgba(217, 79, 4, 0.96), rgba(217, 79, 4, 0.96)),
rgba(217, 79, 4, 0.96);
background-position: center center;
background-repeat: no-repeat;
background-size: 0.82rem 0.82rem, 2px 100%, auto;
touch-action: none;
}
@media (pointer: coarse) {
.clip-wavesurfer ::part(region-handle-left),
.clip-wavesurfer ::part(region-handle-right) {
width: 1.8rem;
height: 2.2rem !important;
background-size: 0.92rem 0.92rem, 2px 100%, auto;
}
}
.clip-zoom-scrubber {
display: grid;
gap: 0.35rem;
}
.clip-zoom-scrubber-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
font-size: 0.9rem;
}
.clip-zoom-scrubber input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
.clip-waveform-controls {
display: grid;
gap: 0.75rem;
}
.clip-waveform-control {
display: grid;
gap: 0.45rem;
}
.clip-waveform-control-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
flex-wrap: wrap;
}
.clip-waveform-nudges {
display: inline-flex;
gap: 0.45rem;
flex-wrap: wrap;
}
.clip-waveform-preview-action {
display: flex;
justify-content: stretch;
}
.clip-waveform-preview-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
}
.clip-waveform-preview-button svg {
width: 1rem;
height: 1rem;
flex: 0 0 auto;
fill: currentColor;
}
.clip-editor-fields {
align-items: stretch;
}
.clip-editor-shortcuts {
display: flex;
align-items: flex-end;
}
.clip-editor-shortcut {
padding-inline: 0.75rem;
}
.clip-editor-actions {
justify-content: space-between;
align-items: center;
}
.action-card {
width: 100%;
text-align: left;
}
.action-card.active,
.action-card.is-selected {
border-color: rgba(217, 79, 4, 0.6);
box-shadow: 0 0 0 2px rgba(217, 79, 4, 0.12);
}
.operator-toolbar {
position: fixed;
right: 1.75rem;
bottom: 1.15rem;
left: 1.75rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 1rem;
border: 1px solid rgba(19, 34, 56, 0.16);
border-radius: 0.8rem;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(14px);
box-shadow: 0 18px 40px rgba(19, 34, 56, 0.18);
z-index: 20;
}
.operator-toolbar-copy {
display: grid;
gap: 0.25rem;
min-width: 0;
}
.operator-toolbar-label {
color: rgba(19, 34, 56, 0.58);
font-size: 0.74rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.operator-toolbar-copy strong,
.operator-toolbar-copy .muted {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.operator-toolbar-actions {
display: flex;
align-items: center;
gap: 0.65rem;
flex: 0 0 auto;
}
.operator-player-list {
gap: 0;
padding: 0;
border: 1px solid var(--panel-border);
border-radius: 0.8rem;
overflow: hidden;
background: rgba(255, 255, 255, 0.68);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
.operator-player-card {
display: grid;
gap: 0;
padding: 0;
border: 0;
border-top: 1px solid var(--line);
background: transparent;
}
.operator-player-card:first-child {
border-top: 0;
}
.operator-player-card.is-selected {
background: rgba(217, 79, 4, 0.03);
}
.operator-player-summary {
display: grid;
gap: 0.3rem;
text-align: left;
}
.operator-player-heading {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.operator-player-heading strong {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.operator-availability-dot {
width: 0.7rem;
height: 0.7rem;
flex: 0 0 auto;
border-radius: 999px;
background: #9aa0a6;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.88);
}
.operator-availability-dot.is-yes {
background: #2f9e44;
}
.operator-availability-dot.is-no {
background: #e03131;
}
.operator-availability-dot.is-maybe {
background: #1c7ed6;
}
.operator-availability-dot.is-blank {
background: #adb5bd;
}
.operator-player-chevron {
flex: 0 0 auto;
width: 1.75rem;
height: 1.75rem;
display: grid;
place-items: center;
border-radius: 0.75rem;
background: rgba(19, 34, 56, 0.08);
color: var(--ink);
font-size: 1.35rem;
line-height: 1;
transition:
transform 0.18s ease,
background-color 0.18s ease,
color 0.18s ease;
}
.list-group-item.active .operator-player-chevron {
transform: rotate(90deg);
background: rgba(217, 79, 4, 0.18);
color: var(--accent);
}
.operator-expansion {
display: grid;
gap: 1rem;
padding: 0;
border-top: 1px solid var(--line);
background: rgba(255, 255, 255, 0.94);
}
.operator-section {
display: grid;
gap: 0.65rem;
padding: 1rem 1rem 0;
}
.operator-section:last-child {
padding-bottom: 1rem;
}
.operator-section-title {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.75rem;
flex-wrap: wrap;
}
.operator-clip-list {
display: grid;
gap: 0.5rem;
}
.operator-clip-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 0.85rem;
border: 1px solid var(--line);
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.75);
}
.operator-clip-copy {
display: grid;
gap: 0.25rem;
}
.operator-clip-button-indicator {
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
background: rgba(19, 34, 56, 0.32);
}
.operator-clip-button-indicator.is-playing {
background: var(--accent);
}
.operator-debug {
margin: 0;
padding: 0.75rem 0.85rem;
border-radius: 0.75rem;
background: rgba(19, 34, 56, 0.06);
color: var(--ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 0.82rem;
line-height: 1.45;
white-space: pre-wrap;
overflow-x: auto;
}
.operator-debug-details {
padding: 0.15rem 0 0;
color: var(--muted);
}
.operator-debug-details > summary {
cursor: pointer;
list-style: none;
color: var(--muted);
font-size: 0.88rem;
}
.operator-debug-details > summary::-webkit-details-marker {
display: none;
}
.operator-debug-details[open] > summary {
margin-bottom: 0.65rem;
}
@media (max-width: 900px) {
.operator-toolbar {
left: 1rem;
right: 1rem;
}
}

1
frontend/src/types/teamsnap-js.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "teamsnap.js";

2
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />

22
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

42
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,42 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, ".", "");
const appHost = env.APP_HOST || "kif.local.ascorrea.com";
return {
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
includeAssets: ["icon.svg"],
manifest: {
name: "Walkup",
short_name: "Walkup",
description: "Collaborative baseball walk-up songs.",
theme_color: "#132238",
background_color: "#f4ede2",
display: "standalone",
start_url: "/",
icons: [
{
src: "/icon.svg",
sizes: "any",
type: "image/svg+xml",
purpose: "any maskable"
}
]
}
})
],
server: {
port: 5173,
allowedHosts: [appHost],
},
preview: {
allowedHosts: [appHost],
},
};
});

17
ops/Caddyfile Normal file
View File

@@ -0,0 +1,17 @@
{
auto_https off
}
https://{$APP_HOST} {
tls /certs/dev-proxy-cert.pem /certs/dev-proxy-key.pem
handle_path /api/* {
reverse_proxy backend:8000
}
reverse_proxy frontend:5173
}
http://{$APP_HOST} {
redir https://{$APP_HOST}{uri}
}

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
-r backend/requirements-dev.txt

43
scripts/create-dev-certs.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SECRETS_DIR="${ROOT_DIR}/secrets"
CERT_PATH="${SECRETS_DIR}/dev-proxy-cert.pem"
KEY_PATH="${SECRETS_DIR}/dev-proxy-key.pem"
HOSTNAME="${APP_HOST:-kif.local.ascorrea.com}"
mkdir -p "${SECRETS_DIR}"
if ! command -v mkcert >/dev/null 2>&1; then
cat <<'EOF'
mkcert is required to generate local development certificates.
Install it first, then rerun this script.
Examples:
brew install mkcert
mkcert -install
EOF
exit 1
fi
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "${TMP_DIR}"' EXIT
TMP_CERT="${TMP_DIR}/${HOSTNAME}.pem"
TMP_KEY="${TMP_DIR}/${HOSTNAME}-key.pem"
mkcert -cert-file "${TMP_CERT}" -key-file "${TMP_KEY}" "${HOSTNAME}"
cp "${TMP_CERT}" "${CERT_PATH}"
cp "${TMP_KEY}" "${KEY_PATH}"
cat <<EOF
Created local TLS files:
${CERT_PATH}
${KEY_PATH}
You can now start the stack with:
docker compose up --build
EOF

15
scripts/dev-logs.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOG_DIR="${ROOT_DIR}/logs"
LOG_FILE="${LOG_DIR}/docker-services.log"
mkdir -p "${LOG_DIR}"
cd "${ROOT_DIR}"
docker compose logs --timestamps --no-color "$@" | tee "${LOG_FILE}"
echo
echo "Saved service logs to ${LOG_FILE}"

15
scripts/dev-up.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOG_DIR="${ROOT_DIR}/logs"
LOG_FILE="${LOG_DIR}/docker-compose.log"
mkdir -p "${LOG_DIR}"
echo "Writing docker compose output to ${LOG_FILE}"
echo "Started at $(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> "${LOG_FILE}"
cd "${ROOT_DIR}"
docker compose up --build "$@" 2>&1 | tee -a "${LOG_FILE}"

View File

@@ -0,0 +1,3 @@
-----BEGIN CERTIFICATE-----
replace-with-your-local-dev-certificate
-----END CERTIFICATE-----

View File

@@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
replace-with-your-local-dev-private-key
-----END PRIVATE KEY-----

View File

@@ -0,0 +1 @@
your-client-id

View File

@@ -0,0 +1 @@
your-client-secret