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