Squash merge feature/library-reorganization
This commit is contained in:
26
.env.example
Normal file
26
.env.example
Normal 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
24
.gitignore
vendored
Normal 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
20
PLAN.md
Normal 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
64
README.md
Normal 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
12
backend/Dockerfile
Normal 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
2
backend/app/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Walkup backend package."""
|
||||||
|
|
||||||
160
backend/app/auth.py
Normal file
160
backend/app/auth.py
Normal 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
48
backend/app/config.py
Normal 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
26
backend/app/database.py
Normal 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
30
backend/app/main.py
Normal 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
98
backend/app/models.py
Normal 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()
|
||||||
2
backend/app/routes/__init__.py
Normal file
2
backend/app/routes/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""API routes."""
|
||||||
|
|
||||||
177
backend/app/routes/auth.py
Normal file
177
backend/app/routes/auth.py
Normal 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
170
backend/app/routes/games.py
Normal 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)
|
||||||
9
backend/app/routes/health.py
Normal file
9
backend/app/routes/health.py
Normal 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
373
backend/app/routes/media.py
Normal 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)
|
||||||
81
backend/app/routes/teamsnap.py
Normal file
81
backend/app/routes/teamsnap.py
Normal 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
136
backend/app/schemas.py
Normal 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
140
backend/app/storage.py
Normal 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
31
backend/pyproject.toml
Normal 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*"]
|
||||||
2
backend/requirements-dev.txt
Normal file
2
backend/requirements-dev.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.3,<9.0
|
||||||
8
backend/requirements.txt
Normal file
8
backend/requirements.txt
Normal 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
|
||||||
25
backend/tests/fixtures/teamsnap/README.md
vendored
Normal file
25
backend/tests/fixtures/teamsnap/README.md
vendored
Normal 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
|
||||||
74
backend/tests/fixtures/teamsnap/assignments.json
vendored
Normal file
74
backend/tests/fixtures/teamsnap/assignments.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
91
backend/tests/fixtures/teamsnap/availabilities.json
vendored
Normal file
91
backend/tests/fixtures/teamsnap/availabilities.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
91
backend/tests/fixtures/teamsnap/event_lineup_entries.json
vendored
Normal file
91
backend/tests/fixtures/teamsnap/event_lineup_entries.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
56
backend/tests/fixtures/teamsnap/event_lineups.json
vendored
Normal file
56
backend/tests/fixtures/teamsnap/event_lineups.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
125
backend/tests/fixtures/teamsnap/events.json
vendored
Normal file
125
backend/tests/fixtures/teamsnap/events.json
vendored
Normal 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
35
backend/tests/fixtures/teamsnap/me.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
111
backend/tests/fixtures/teamsnap/members.json
vendored
Normal file
111
backend/tests/fixtures/teamsnap/members.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
54
backend/tests/fixtures/teamsnap/root.json
vendored
Normal file
54
backend/tests/fixtures/teamsnap/root.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
89
backend/tests/fixtures/teamsnap/teams.json
vendored
Normal file
89
backend/tests/fixtures/teamsnap/teams.json
vendored
Normal 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
427
backend/tests/test_api.py
Normal 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
80
docker-compose.yml
Normal 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
12
frontend/Dockerfile
Normal 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
13
frontend/index.html
Normal 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
6359
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal 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
7
frontend/public/icon.svg
Normal 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
301
frontend/src/App.tsx
Normal 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
185
frontend/src/api/client.ts
Normal 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
168
frontend/src/api/types.ts
Normal 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;
|
||||||
|
}
|
||||||
11
frontend/src/hooks/useSession.ts
Normal file
11
frontend/src/hooks/useSession.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
135
frontend/src/hooks/useWalkupContext.tsx
Normal file
135
frontend/src/hooks/useWalkupContext.tsx
Normal 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
26
frontend/src/lib/media.ts
Normal 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`;
|
||||||
|
}
|
||||||
19
frontend/src/lib/offlinePrep.ts
Normal file
19
frontend/src/lib/offlinePrep.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
frontend/src/lib/queryClient.ts
Normal file
4
frontend/src/lib/queryClient.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient();
|
||||||
|
|
||||||
186
frontend/src/lib/teamsnap.ts
Normal file
186
frontend/src/lib/teamsnap.ts
Normal 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: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
334
frontend/src/lib/teamsnapHelpers.ts
Normal file
334
frontend/src/lib/teamsnapHelpers.ts
Normal 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
19
frontend/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
68
frontend/src/pages/AdminPage.tsx
Normal file
68
frontend/src/pages/AdminPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
frontend/src/pages/DashboardPage.tsx
Normal file
96
frontend/src/pages/DashboardPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
frontend/src/pages/GamePage.tsx
Normal file
218
frontend/src/pages/GamePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1462
frontend/src/pages/LibraryPage.tsx
Normal file
1462
frontend/src/pages/LibraryPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
673
frontend/src/pages/OperatorPage.tsx
Normal file
673
frontend/src/pages/OperatorPage.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
frontend/src/pages/ProfilePage.tsx
Normal file
98
frontend/src/pages/ProfilePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
frontend/src/pages/SignInPage.tsx
Normal file
59
frontend/src/pages/SignInPage.tsx
Normal 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
965
frontend/src/styles.css
Normal 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
1
frontend/src/types/teamsnap-js.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module "teamsnap.js";
|
||||||
2
frontend/src/vite-env.d.ts
vendored
Normal file
2
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
22
frontend/tsconfig.json
Normal file
22
frontend/tsconfig.json
Normal 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" }]
|
||||||
|
}
|
||||||
|
|
||||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal 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
42
frontend/vite.config.ts
Normal 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
17
ops/Caddyfile
Normal 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
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
-r backend/requirements-dev.txt
|
||||||
43
scripts/create-dev-certs.sh
Executable file
43
scripts/create-dev-certs.sh
Executable 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
15
scripts/dev-logs.sh
Executable 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
15
scripts/dev-up.sh
Executable 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}"
|
||||||
3
secrets/dev-proxy-cert.pem.example
Normal file
3
secrets/dev-proxy-cert.pem.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
replace-with-your-local-dev-certificate
|
||||||
|
-----END CERTIFICATE-----
|
||||||
3
secrets/dev-proxy-key.pem.example
Normal file
3
secrets/dev-proxy-key.pem.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
replace-with-your-local-dev-private-key
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
1
secrets/teamsnap_client_id.txt.example
Normal file
1
secrets/teamsnap_client_id.txt.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
your-client-id
|
||||||
1
secrets/teamsnap_client_secret.txt.example
Normal file
1
secrets/teamsnap_client_secret.txt.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
your-client-secret
|
||||||
Reference in New Issue
Block a user