diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..c8339ef
--- /dev/null
+++ b/.env.example
@@ -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
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b299360
--- /dev/null
+++ b/.gitignore
@@ -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
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..8403c85
--- /dev/null
+++ b/PLAN.md
@@ -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.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..42c5925
--- /dev/null
+++ b/README.md
@@ -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
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..dbd9f83
--- /dev/null
+++ b/backend/Dockerfile
@@ -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"]
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000..074ec1f
--- /dev/null
+++ b/backend/app/__init__.py
@@ -0,0 +1,2 @@
+"""Walkup backend package."""
+
diff --git a/backend/app/auth.py b/backend/app/auth.py
new file mode 100644
index 0000000..36055bd
--- /dev/null
+++ b/backend/app/auth.py
@@ -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
diff --git a/backend/app/config.py b/backend/app/config.py
new file mode 100644
index 0000000..ea97797
--- /dev/null
+++ b/backend/app/config.py
@@ -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)
diff --git a/backend/app/database.py b/backend/app/database.py
new file mode 100644
index 0000000..00bd888
--- /dev/null
+++ b/backend/app/database.py
@@ -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()
+
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000..fdd9a1f
--- /dev/null
+++ b/backend/app/main.py
@@ -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)
diff --git a/backend/app/models.py b/backend/app/models.py
new file mode 100644
index 0000000..b477a9d
--- /dev/null
+++ b/backend/app/models.py
@@ -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()
diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py
new file mode 100644
index 0000000..842a1a7
--- /dev/null
+++ b/backend/app/routes/__init__.py
@@ -0,0 +1,2 @@
+"""API routes."""
+
diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py
new file mode 100644
index 0000000..4bf409e
--- /dev/null
+++ b/backend/app/routes/auth.py
@@ -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)
diff --git a/backend/app/routes/games.py b/backend/app/routes/games.py
new file mode 100644
index 0000000..5495c95
--- /dev/null
+++ b/backend/app/routes/games.py
@@ -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)
diff --git a/backend/app/routes/health.py b/backend/app/routes/health.py
new file mode 100644
index 0000000..f668441
--- /dev/null
+++ b/backend/app/routes/health.py
@@ -0,0 +1,9 @@
+from fastapi import APIRouter
+
+router = APIRouter(tags=["health"])
+
+
+@router.get("/health")
+def healthcheck() -> dict[str, str]:
+ return {"status": "ok"}
+
diff --git a/backend/app/routes/media.py b/backend/app/routes/media.py
new file mode 100644
index 0000000..58d378e
--- /dev/null
+++ b/backend/app/routes/media.py
@@ -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)
diff --git a/backend/app/routes/teamsnap.py b/backend/app/routes/teamsnap.py
new file mode 100644
index 0000000..054be9d
--- /dev/null
+++ b/backend/app/routes/teamsnap.py
@@ -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",
+ )
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
new file mode 100644
index 0000000..8888bbe
--- /dev/null
+++ b/backend/app/schemas.py
@@ -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
diff --git a/backend/app/storage.py b/backend/app/storage.py
new file mode 100644
index 0000000..54c0ffc
--- /dev/null
+++ b/backend/app/storage.py
@@ -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()
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
new file mode 100644
index 0000000..a155511
--- /dev/null
+++ b/backend/pyproject.toml
@@ -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*"]
diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt
new file mode 100644
index 0000000..63950e2
--- /dev/null
+++ b/backend/requirements-dev.txt
@@ -0,0 +1,2 @@
+-r requirements.txt
+pytest>=8.3,<9.0
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..3d14fef
--- /dev/null
+++ b/backend/requirements.txt
@@ -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
diff --git a/backend/tests/fixtures/teamsnap/README.md b/backend/tests/fixtures/teamsnap/README.md
new file mode 100644
index 0000000..ddfecfd
--- /dev/null
+++ b/backend/tests/fixtures/teamsnap/README.md
@@ -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
diff --git a/backend/tests/fixtures/teamsnap/assignments.json b/backend/tests/fixtures/teamsnap/assignments.json
new file mode 100644
index 0000000..8f84c14
--- /dev/null
+++ b/backend/tests/fixtures/teamsnap/assignments.json
@@ -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
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/backend/tests/fixtures/teamsnap/availabilities.json b/backend/tests/fixtures/teamsnap/availabilities.json
new file mode 100644
index 0000000..3ddd103
--- /dev/null
+++ b/backend/tests/fixtures/teamsnap/availabilities.json
@@ -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
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/backend/tests/fixtures/teamsnap/event_lineup_entries.json b/backend/tests/fixtures/teamsnap/event_lineup_entries.json
new file mode 100644
index 0000000..862f7e6
--- /dev/null
+++ b/backend/tests/fixtures/teamsnap/event_lineup_entries.json
@@ -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
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/backend/tests/fixtures/teamsnap/event_lineups.json b/backend/tests/fixtures/teamsnap/event_lineups.json
new file mode 100644
index 0000000..d605702
--- /dev/null
+++ b/backend/tests/fixtures/teamsnap/event_lineups.json
@@ -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"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/backend/tests/fixtures/teamsnap/events.json b/backend/tests/fixtures/teamsnap/events.json
new file mode 100644
index 0000000..401ed6b
--- /dev/null
+++ b/backend/tests/fixtures/teamsnap/events.json
@@ -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"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/backend/tests/fixtures/teamsnap/me.json b/backend/tests/fixtures/teamsnap/me.json
new file mode 100644
index 0000000..8342043
--- /dev/null
+++ b/backend/tests/fixtures/teamsnap/me.json
@@ -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"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/backend/tests/fixtures/teamsnap/members.json b/backend/tests/fixtures/teamsnap/members.json
new file mode 100644
index 0000000..2a0d2b6
--- /dev/null
+++ b/backend/tests/fixtures/teamsnap/members.json
@@ -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"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/backend/tests/fixtures/teamsnap/root.json b/backend/tests/fixtures/teamsnap/root.json
new file mode 100644
index 0000000..c2f6442
--- /dev/null
+++ b/backend/tests/fixtures/teamsnap/root.json
@@ -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"
+ }
+ ]
+ }
+}
diff --git a/backend/tests/fixtures/teamsnap/teams.json b/backend/tests/fixtures/teamsnap/teams.json
new file mode 100644
index 0000000..e16c116
--- /dev/null
+++ b/backend/tests/fixtures/teamsnap/teams.json
@@ -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
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
new file mode 100644
index 0000000..966457e
--- /dev/null
+++ b/backend/tests/test_api.py
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..e72370d
--- /dev/null
+++ b/docker-compose.yml
@@ -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
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..2b94034
--- /dev/null
+++ b/frontend/Dockerfile
@@ -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"]
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..9e50d6f
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Walkup
+
+
+
+
+
+
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..0818d3a
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,6359 @@
+{
+ "name": "walkup-frontend",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "walkup-frontend",
+ "version": "0.1.0",
+ "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"
+ }
+ },
+ "node_modules/@apideck/better-ajv-errors": {
+ "version": "0.3.7",
+ "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz",
+ "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "jsonpointer": "^5.0.1",
+ "leven": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "ajv": ">=8"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-annotate-as-pure": {
+ "version": "7.27.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
+ "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.27.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-create-class-features-plugin": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz",
+ "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "@babel/helper-member-expression-to-functions": "^7.28.5",
+ "@babel/helper-optimise-call-expression": "^7.27.1",
+ "@babel/helper-replace-supers": "^7.28.6",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
+ "@babel/traverse": "^7.28.6",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-create-regexp-features-plugin": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz",
+ "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "regexpu-core": "^6.3.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-define-polyfill-provider": {
+ "version": "0.6.8",
+ "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz",
+ "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "debug": "^4.4.3",
+ "lodash.debounce": "^4.0.8",
+ "resolve": "^1.22.11"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-member-expression-to-functions": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz",
+ "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.5",
+ "@babel/types": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-optimise-call-expression": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz",
+ "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-remap-async-to-generator": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz",
+ "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.1",
+ "@babel/helper-wrap-function": "^7.27.1",
+ "@babel/traverse": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-replace-supers": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz",
+ "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-member-expression-to-functions": "^7.28.5",
+ "@babel/helper-optimise-call-expression": "^7.27.1",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-skip-transparent-expression-wrappers": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz",
+ "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.27.1",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-wrap-function": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz",
+ "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz",
+ "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/traverse": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz",
+ "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz",
+ "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz",
+ "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
+ "@babel/plugin-transform-optional-chaining": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.13.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz",
+ "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-private-property-in-object": {
+ "version": "7.21.0-placeholder-for-preset-env.2",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz",
+ "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-import-assertions": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz",
+ "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-import-attributes": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz",
+ "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-unicode-sets-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
+ "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-arrow-functions": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz",
+ "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-async-generator-functions": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz",
+ "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-remap-async-to-generator": "^7.27.1",
+ "@babel/traverse": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-async-to-generator": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz",
+ "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-remap-async-to-generator": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-block-scoped-functions": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz",
+ "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-block-scoping": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz",
+ "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-class-properties": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz",
+ "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-class-static-block": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz",
+ "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.12.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-classes": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz",
+ "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-replace-supers": "^7.28.6",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-computed-properties": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz",
+ "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/template": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-destructuring": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz",
+ "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/traverse": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-dotall-regex": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz",
+ "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-duplicate-keys": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz",
+ "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz",
+ "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-dynamic-import": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz",
+ "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-explicit-resource-management": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz",
+ "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/plugin-transform-destructuring": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-exponentiation-operator": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz",
+ "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-export-namespace-from": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz",
+ "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-for-of": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz",
+ "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-function-name": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz",
+ "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/traverse": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-json-strings": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz",
+ "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-literals": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz",
+ "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-logical-assignment-operators": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz",
+ "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-member-expression-literals": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz",
+ "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-amd": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz",
+ "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-commonjs": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz",
+ "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-systemjs": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz",
+ "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-umd": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz",
+ "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-named-capturing-groups-regex": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz",
+ "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-new-target": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz",
+ "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-nullish-coalescing-operator": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz",
+ "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-numeric-separator": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz",
+ "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-object-rest-spread": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz",
+ "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/plugin-transform-destructuring": "^7.28.5",
+ "@babel/plugin-transform-parameters": "^7.27.7",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-object-super": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz",
+ "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-replace-supers": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-optional-catch-binding": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz",
+ "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-optional-chaining": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz",
+ "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-parameters": {
+ "version": "7.27.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz",
+ "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-private-methods": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz",
+ "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-private-property-in-object": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz",
+ "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "@babel/helper-create-class-features-plugin": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-property-literals": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz",
+ "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-regenerator": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz",
+ "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-regexp-modifiers": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz",
+ "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-reserved-words": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz",
+ "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-shorthand-properties": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz",
+ "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-spread": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz",
+ "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-sticky-regex": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz",
+ "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-template-literals": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz",
+ "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-typeof-symbol": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz",
+ "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-unicode-escapes": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz",
+ "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-unicode-property-regex": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz",
+ "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-unicode-regex": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz",
+ "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-unicode-sets-regex": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz",
+ "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.28.5",
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/preset-env": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.2.tgz",
+ "integrity": "sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5",
+ "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1",
+ "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1",
+ "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1",
+ "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6",
+ "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2",
+ "@babel/plugin-syntax-import-assertions": "^7.28.6",
+ "@babel/plugin-syntax-import-attributes": "^7.28.6",
+ "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6",
+ "@babel/plugin-transform-arrow-functions": "^7.27.1",
+ "@babel/plugin-transform-async-generator-functions": "^7.29.0",
+ "@babel/plugin-transform-async-to-generator": "^7.28.6",
+ "@babel/plugin-transform-block-scoped-functions": "^7.27.1",
+ "@babel/plugin-transform-block-scoping": "^7.28.6",
+ "@babel/plugin-transform-class-properties": "^7.28.6",
+ "@babel/plugin-transform-class-static-block": "^7.28.6",
+ "@babel/plugin-transform-classes": "^7.28.6",
+ "@babel/plugin-transform-computed-properties": "^7.28.6",
+ "@babel/plugin-transform-destructuring": "^7.28.5",
+ "@babel/plugin-transform-dotall-regex": "^7.28.6",
+ "@babel/plugin-transform-duplicate-keys": "^7.27.1",
+ "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0",
+ "@babel/plugin-transform-dynamic-import": "^7.27.1",
+ "@babel/plugin-transform-explicit-resource-management": "^7.28.6",
+ "@babel/plugin-transform-exponentiation-operator": "^7.28.6",
+ "@babel/plugin-transform-export-namespace-from": "^7.27.1",
+ "@babel/plugin-transform-for-of": "^7.27.1",
+ "@babel/plugin-transform-function-name": "^7.27.1",
+ "@babel/plugin-transform-json-strings": "^7.28.6",
+ "@babel/plugin-transform-literals": "^7.27.1",
+ "@babel/plugin-transform-logical-assignment-operators": "^7.28.6",
+ "@babel/plugin-transform-member-expression-literals": "^7.27.1",
+ "@babel/plugin-transform-modules-amd": "^7.27.1",
+ "@babel/plugin-transform-modules-commonjs": "^7.28.6",
+ "@babel/plugin-transform-modules-systemjs": "^7.29.0",
+ "@babel/plugin-transform-modules-umd": "^7.27.1",
+ "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0",
+ "@babel/plugin-transform-new-target": "^7.27.1",
+ "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
+ "@babel/plugin-transform-numeric-separator": "^7.28.6",
+ "@babel/plugin-transform-object-rest-spread": "^7.28.6",
+ "@babel/plugin-transform-object-super": "^7.27.1",
+ "@babel/plugin-transform-optional-catch-binding": "^7.28.6",
+ "@babel/plugin-transform-optional-chaining": "^7.28.6",
+ "@babel/plugin-transform-parameters": "^7.27.7",
+ "@babel/plugin-transform-private-methods": "^7.28.6",
+ "@babel/plugin-transform-private-property-in-object": "^7.28.6",
+ "@babel/plugin-transform-property-literals": "^7.27.1",
+ "@babel/plugin-transform-regenerator": "^7.29.0",
+ "@babel/plugin-transform-regexp-modifiers": "^7.28.6",
+ "@babel/plugin-transform-reserved-words": "^7.27.1",
+ "@babel/plugin-transform-shorthand-properties": "^7.27.1",
+ "@babel/plugin-transform-spread": "^7.28.6",
+ "@babel/plugin-transform-sticky-regex": "^7.27.1",
+ "@babel/plugin-transform-template-literals": "^7.27.1",
+ "@babel/plugin-transform-typeof-symbol": "^7.27.1",
+ "@babel/plugin-transform-unicode-escapes": "^7.27.1",
+ "@babel/plugin-transform-unicode-property-regex": "^7.28.6",
+ "@babel/plugin-transform-unicode-regex": "^7.27.1",
+ "@babel/plugin-transform-unicode-sets-regex": "^7.28.6",
+ "@babel/preset-modules": "0.1.6-no-external-plugins",
+ "babel-plugin-polyfill-corejs2": "^0.4.15",
+ "babel-plugin-polyfill-corejs3": "^0.14.0",
+ "babel-plugin-polyfill-regenerator": "^0.6.6",
+ "core-js-compat": "^3.48.0",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/preset-modules": {
+ "version": "0.1.6-no-external-plugins",
+ "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz",
+ "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.0.0",
+ "@babel/types": "^7.4.4",
+ "esutils": "^2.0.2"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@isaacs/cliui": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
+ "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/source-map": {
+ "version": "0.3.11",
+ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
+ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.25"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@popperjs/core": {
+ "version": "2.11.8",
+ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+ "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/popperjs"
+ }
+ },
+ "node_modules/@remix-run/router": {
+ "version": "1.23.2",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
+ "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/plugin-node-resolve": {
+ "version": "15.3.1",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
+ "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.0.1",
+ "@types/resolve": "1.20.2",
+ "deepmerge": "^4.2.2",
+ "is-module": "^1.0.0",
+ "resolve": "^1.22.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.78.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/plugin-terser": {
+ "version": "0.4.4",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz",
+ "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "serialize-javascript": "^6.0.1",
+ "smob": "^1.0.0",
+ "terser": "^5.17.4"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/pluginutils": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
+ "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
+ "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
+ "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
+ "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
+ "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
+ "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
+ "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
+ "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
+ "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
+ "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
+ "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
+ "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
+ "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
+ "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
+ "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
+ "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
+ "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
+ "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
+ "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
+ "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
+ "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
+ "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
+ "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
+ "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
+ "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
+ "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@surma/rollup-plugin-off-main-thread": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
+ "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "ejs": "^3.1.6",
+ "json5": "^2.2.0",
+ "magic-string": "^0.25.0",
+ "string.prototype.matchall": "^4.0.6"
+ }
+ },
+ "node_modules/@tanstack/query-core": {
+ "version": "5.99.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.0.tgz",
+ "integrity": "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.99.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.0.tgz",
+ "integrity": "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.99.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.28",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
+ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@types/resolve": {
+ "version": "1.20.2",
+ "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
+ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/array-buffer-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
+ "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "is-array-buffer": "^3.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/arraybuffer.prototype.slice": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz",
+ "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.1",
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "is-array-buffer": "^3.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/async": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz",
+ "integrity": "sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==",
+ "license": "MIT"
+ },
+ "node_modules/async-function": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
+ "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/at-least-node": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
+ "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+ "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-corejs2": {
+ "version": "0.4.17",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz",
+ "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-define-polyfill-provider": "^0.6.8",
+ "semver": "^6.3.1"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-corejs3": {
+ "version": "0.14.2",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz",
+ "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": "^0.6.8",
+ "core-js-compat": "^3.48.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-regenerator": {
+ "version": "0.6.8",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz",
+ "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": "^0.6.8"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.19",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz",
+ "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/bootstrap": {
+ "version": "5.3.8",
+ "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
+ "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/twbs"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/bootstrap"
+ }
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "@popperjs/core": "^2.11.8"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/call-bind": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
+ "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "get-intrinsic": "^1.3.0",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001788",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
+ "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/common-tags": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz",
+ "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/core-js-compat": {
+ "version": "3.49.0",
+ "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz",
+ "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/crypto-random-string": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
+ "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/data-view-buffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
+ "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/data-view-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz",
+ "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/inspect-js"
+ }
+ },
+ "node_modules/data-view-byte-offset": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz",
+ "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ejs": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+ "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "jake": "^10.8.5"
+ },
+ "bin": {
+ "ejs": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.340",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz",
+ "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/es-abstract": {
+ "version": "1.24.2",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
+ "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.2",
+ "arraybuffer.prototype.slice": "^1.0.4",
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "data-view-buffer": "^1.0.2",
+ "data-view-byte-length": "^1.0.2",
+ "data-view-byte-offset": "^1.0.1",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "es-set-tostringtag": "^2.1.0",
+ "es-to-primitive": "^1.3.0",
+ "function.prototype.name": "^1.1.8",
+ "get-intrinsic": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "get-symbol-description": "^1.1.0",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "internal-slot": "^1.1.0",
+ "is-array-buffer": "^3.0.5",
+ "is-callable": "^1.2.7",
+ "is-data-view": "^1.0.2",
+ "is-negative-zero": "^2.0.3",
+ "is-regex": "^1.2.1",
+ "is-set": "^2.0.3",
+ "is-shared-array-buffer": "^1.0.4",
+ "is-string": "^1.1.1",
+ "is-typed-array": "^1.1.15",
+ "is-weakref": "^1.1.1",
+ "math-intrinsics": "^1.1.0",
+ "object-inspect": "^1.13.4",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.7",
+ "own-keys": "^1.0.1",
+ "regexp.prototype.flags": "^1.5.4",
+ "safe-array-concat": "^1.1.3",
+ "safe-push-apply": "^1.0.0",
+ "safe-regex-test": "^1.1.0",
+ "set-proto": "^1.0.0",
+ "stop-iteration-iterator": "^1.1.0",
+ "string.prototype.trim": "^1.2.10",
+ "string.prototype.trimend": "^1.0.9",
+ "string.prototype.trimstart": "^1.0.8",
+ "typed-array-buffer": "^1.0.3",
+ "typed-array-byte-length": "^1.0.3",
+ "typed-array-byte-offset": "^1.0.4",
+ "typed-array-length": "^1.0.7",
+ "unbox-primitive": "^1.1.0",
+ "which-typed-array": "^1.1.19"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-to-primitive": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz",
+ "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7",
+ "is-date-object": "^1.0.5",
+ "is-symbol": "^1.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
+ "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/filelist": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
+ "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "minimatch": "^5.0.1"
+ }
+ },
+ "node_modules/filelist/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/filelist/node_modules/brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/filelist/node_modules/minimatch": {
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/for-each": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+ "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/form-data": {
+ "version": "1.0.0-rc3",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-1.0.0-rc3.tgz",
+ "integrity": "sha512-Z5JWXWsFDI8x73Rt/Dc7SK/EvKBzudhqIVBtEhcAhtoevCTqO3YJmctGBLzT0Ggg39xFcefkXt00t1TYLz6D0w==",
+ "license": "MIT",
+ "dependencies": {
+ "async": "^1.4.0",
+ "combined-stream": "^1.0.5",
+ "mime-types": "^2.1.3"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/fs-extra": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
+ "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "at-least-node": "^1.0.0",
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/function.prototype.name": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz",
+ "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "functions-have-names": "^1.2.3",
+ "hasown": "^2.0.2",
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/generator-function": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
+ "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-own-enumerable-property-symbols": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz",
+ "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-symbol-description": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
+ "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/glob": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
+ "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "foreground-child": "^3.3.1",
+ "jackspeak": "^4.1.1",
+ "minimatch": "^10.1.1",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^2.0.0"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/has-bigints": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+ "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-proto": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz",
+ "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/idb": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
+ "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/internal-slot": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
+ "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "hasown": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/is-array-buffer": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
+ "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-async-function": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
+ "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "async-function": "^1.0.0",
+ "call-bound": "^1.0.3",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-bigint": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
+ "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-bigints": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-boolean-object": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
+ "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-data-view": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz",
+ "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "is-typed-array": "^1.1.13"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-date-object": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+ "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-finalizationregistry": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz",
+ "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-generator-function": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
+ "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.4",
+ "generator-function": "^2.0.0",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-map": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
+ "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-module": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
+ "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-negative-zero": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
+ "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-number-object": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
+ "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-obj": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz",
+ "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-regexp": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz",
+ "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-set": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
+ "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-shared-array-buffer": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
+ "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-string": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
+ "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-symbol": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
+ "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-typed-array": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
+ "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakmap": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
+ "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz",
+ "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakset": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
+ "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jackspeak": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
+ "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^9.0.0"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/jake": {
+ "version": "10.9.4",
+ "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
+ "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "async": "^3.2.6",
+ "filelist": "^1.0.4",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "jake": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/jake/node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/jsonfile": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
+ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/jsonpointer": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz",
+ "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/leven": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
+ "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.debounce": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.sortby": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
+ "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
+ "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "sourcemap-codec": "^1.4.8"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.37",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
+ "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.assign": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
+ "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/own-keys": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
+ "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-intrinsic": "^1.2.6",
+ "object-keys": "^1.1.1",
+ "safe-push-apply": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0"
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/path-scurry": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
+ "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^11.0.0",
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/path-scurry/node_modules/lru-cache": {
+ "version": "11.3.5",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
+ "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/possible-typed-array-names": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+ "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.10",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/pretty-bytes": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
+ "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/randombytes": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+ "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "^5.1.0"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
+ "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.30.3",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
+ "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.2",
+ "react-router": "6.30.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/reflect.getprototypeof": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
+ "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.9",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.7",
+ "get-proto": "^1.0.1",
+ "which-builtin-type": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/regenerate": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
+ "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/regenerate-unicode-properties": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz",
+ "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "regenerate": "^1.4.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+ "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-errors": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/regexpu-core": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz",
+ "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "regenerate": "^1.4.2",
+ "regenerate-unicode-properties": "^10.2.2",
+ "regjsgen": "^0.8.0",
+ "regjsparser": "^0.13.0",
+ "unicode-match-property-ecmascript": "^2.0.0",
+ "unicode-match-property-value-ecmascript": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/regjsgen": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz",
+ "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/regjsparser": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz",
+ "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "jsesc": "~3.1.0"
+ },
+ "bin": {
+ "regjsparser": "bin/parser"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.12",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
+ "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.1",
+ "@rollup/rollup-android-arm64": "4.60.1",
+ "@rollup/rollup-darwin-arm64": "4.60.1",
+ "@rollup/rollup-darwin-x64": "4.60.1",
+ "@rollup/rollup-freebsd-arm64": "4.60.1",
+ "@rollup/rollup-freebsd-x64": "4.60.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.1",
+ "@rollup/rollup-linux-arm64-musl": "4.60.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.1",
+ "@rollup/rollup-linux-loong64-musl": "4.60.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.1",
+ "@rollup/rollup-linux-x64-gnu": "4.60.1",
+ "@rollup/rollup-linux-x64-musl": "4.60.1",
+ "@rollup/rollup-openbsd-x64": "4.60.1",
+ "@rollup/rollup-openharmony-arm64": "4.60.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.1",
+ "@rollup/rollup-win32-x64-gnu": "4.60.1",
+ "@rollup/rollup-win32-x64-msvc": "4.60.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/safe-array-concat": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
+ "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "has-symbols": "^1.1.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">=0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safe-push-apply": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
+ "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-regex-test": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+ "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-regex": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/serialize-javascript": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
+ "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "randombytes": "^2.1.0"
+ }
+ },
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-function-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+ "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-proto": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz",
+ "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/smob": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.1.tgz",
+ "integrity": "sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.8.0-beta.0",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",
+ "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==",
+ "deprecated": "The work that was done in this beta branch won't be included in future versions",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "whatwg-url": "^7.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/source-map-support/node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sourcemap-codec": {
+ "version": "1.4.8",
+ "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
+ "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
+ "deprecated": "Please use @jridgewell/sourcemap-codec instead",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/stop-iteration-iterator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+ "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "internal-slot": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/string.prototype.matchall": {
+ "version": "4.0.12",
+ "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
+ "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.6",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "internal-slot": "^1.1.0",
+ "regexp.prototype.flags": "^1.5.3",
+ "set-function-name": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trim": {
+ "version": "1.2.10",
+ "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz",
+ "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-data-property": "^1.1.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-object-atoms": "^1.0.0",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimend": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz",
+ "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimstart": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz",
+ "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/stringify-object": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz",
+ "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "get-own-enumerable-property-symbols": "^3.0.0",
+ "is-obj": "^1.0.1",
+ "is-regexp": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/strip-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz",
+ "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/teamsnap.js": {
+ "version": "1.62.1",
+ "resolved": "git+ssh://git@github.com/anthonyscorrea/teamsnap-javascript-sdk.git#67168be49492fe5a0331bbbeb33408a42c40ab9a",
+ "dependencies": {
+ "form-data": "1.0.0-rc3",
+ "xmlhttprequest": "1.8.0"
+ },
+ "engines": {
+ "node": ">=7.0.0",
+ "npm": ">=3.0.0"
+ }
+ },
+ "node_modules/temp-dir": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
+ "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tempy": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz",
+ "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-stream": "^2.0.0",
+ "temp-dir": "^2.0.0",
+ "type-fest": "^0.16.0",
+ "unique-string": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/terser": {
+ "version": "5.46.1",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz",
+ "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@jridgewell/source-map": "^0.3.3",
+ "acorn": "^8.15.0",
+ "commander": "^2.20.0",
+ "source-map-support": "~0.5.20"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
+ "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.16.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
+ "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typed-array-buffer": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
+ "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/typed-array-byte-length": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz",
+ "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-byte-offset": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz",
+ "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.15",
+ "reflect.getprototypeof": "^1.0.9"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-length": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz",
+ "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "for-each": "^0.3.3",
+ "gopd": "^1.0.1",
+ "is-typed-array": "^1.1.13",
+ "possible-typed-array-names": "^1.0.0",
+ "reflect.getprototypeof": "^1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/unbox-primitive": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+ "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-bigints": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "which-boxed-primitive": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/unicode-canonical-property-names-ecmascript": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
+ "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-match-property-ecmascript": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz",
+ "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "unicode-canonical-property-names-ecmascript": "^2.0.0",
+ "unicode-property-aliases-ecmascript": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-match-property-value-ecmascript": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz",
+ "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-property-aliases-ecmascript": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz",
+ "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unique-string": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
+ "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "crypto-random-string": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/universalify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
+ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/upath": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz",
+ "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4",
+ "yarn": "*"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-plugin-pwa": {
+ "version": "0.20.5",
+ "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.20.5.tgz",
+ "integrity": "sha512-aweuI/6G6n4C5Inn0vwHumElU/UEpNuO+9iZzwPZGTCH87TeZ6YFMrEY6ZUBQdIHHlhTsbMDryFARcSuOdsz9Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.6",
+ "pretty-bytes": "^6.1.1",
+ "tinyglobby": "^0.2.0",
+ "workbox-build": "^7.1.0",
+ "workbox-window": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vite-pwa/assets-generator": "^0.2.6",
+ "vite": "^3.1.0 || ^4.0.0 || ^5.0.0",
+ "workbox-build": "^7.1.0",
+ "workbox-window": "^7.1.0"
+ },
+ "peerDependenciesMeta": {
+ "@vite-pwa/assets-generator": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/wavesurfer.js": {
+ "version": "7.12.6",
+ "resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.6.tgz",
+ "integrity": "sha512-zSxPgOFprtyJ31ppHQF0+E9jAmjAhi1rR36yIW6h1GOYdpRxDe6mbkYtlChqLK0Iz8ROBweiEFw2zus7tDFibA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/webidl-conversions": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
+ "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/whatwg-url": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
+ "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lodash.sortby": "^4.7.0",
+ "tr46": "^1.0.1",
+ "webidl-conversions": "^4.0.2"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-boxed-primitive": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
+ "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-bigint": "^1.1.0",
+ "is-boolean-object": "^1.2.1",
+ "is-number-object": "^1.1.1",
+ "is-string": "^1.1.1",
+ "is-symbol": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-builtin-type": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz",
+ "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "function.prototype.name": "^1.1.6",
+ "has-tostringtag": "^1.0.2",
+ "is-async-function": "^2.0.0",
+ "is-date-object": "^1.1.0",
+ "is-finalizationregistry": "^1.1.0",
+ "is-generator-function": "^1.0.10",
+ "is-regex": "^1.2.1",
+ "is-weakref": "^1.0.2",
+ "isarray": "^2.0.5",
+ "which-boxed-primitive": "^1.1.0",
+ "which-collection": "^1.0.2",
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-collection": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
+ "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-map": "^2.0.3",
+ "is-set": "^2.0.3",
+ "is-weakmap": "^2.0.2",
+ "is-weakset": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-typed-array": {
+ "version": "1.1.20",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
+ "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/workbox-background-sync": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz",
+ "integrity": "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "idb": "^7.0.1",
+ "workbox-core": "7.4.0"
+ }
+ },
+ "node_modules/workbox-broadcast-update": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.0.tgz",
+ "integrity": "sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.0"
+ }
+ },
+ "node_modules/workbox-build": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.0.tgz",
+ "integrity": "sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@apideck/better-ajv-errors": "^0.3.1",
+ "@babel/core": "^7.24.4",
+ "@babel/preset-env": "^7.11.0",
+ "@babel/runtime": "^7.11.2",
+ "@rollup/plugin-babel": "^5.2.0",
+ "@rollup/plugin-node-resolve": "^15.2.3",
+ "@rollup/plugin-replace": "^2.4.1",
+ "@rollup/plugin-terser": "^0.4.3",
+ "@surma/rollup-plugin-off-main-thread": "^2.2.3",
+ "ajv": "^8.6.0",
+ "common-tags": "^1.8.0",
+ "fast-json-stable-stringify": "^2.1.0",
+ "fs-extra": "^9.0.1",
+ "glob": "^11.0.1",
+ "lodash": "^4.17.20",
+ "pretty-bytes": "^5.3.0",
+ "rollup": "^2.79.2",
+ "source-map": "^0.8.0-beta.0",
+ "stringify-object": "^3.3.0",
+ "strip-comments": "^2.0.1",
+ "tempy": "^0.6.0",
+ "upath": "^1.2.0",
+ "workbox-background-sync": "7.4.0",
+ "workbox-broadcast-update": "7.4.0",
+ "workbox-cacheable-response": "7.4.0",
+ "workbox-core": "7.4.0",
+ "workbox-expiration": "7.4.0",
+ "workbox-google-analytics": "7.4.0",
+ "workbox-navigation-preload": "7.4.0",
+ "workbox-precaching": "7.4.0",
+ "workbox-range-requests": "7.4.0",
+ "workbox-recipes": "7.4.0",
+ "workbox-routing": "7.4.0",
+ "workbox-strategies": "7.4.0",
+ "workbox-streams": "7.4.0",
+ "workbox-sw": "7.4.0",
+ "workbox-window": "7.4.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/workbox-build/node_modules/@rollup/plugin-babel": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
+ "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.10.4",
+ "@rollup/pluginutils": "^3.1.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0",
+ "@types/babel__core": "^7.1.9",
+ "rollup": "^1.20.0||^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/babel__core": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/workbox-build/node_modules/@rollup/plugin-replace": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz",
+ "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^3.1.0",
+ "magic-string": "^0.25.7"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0 || ^2.0.0"
+ }
+ },
+ "node_modules/workbox-build/node_modules/@rollup/pluginutils": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
+ "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "0.0.39",
+ "estree-walker": "^1.0.1",
+ "picomatch": "^2.2.2"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0"
+ }
+ },
+ "node_modules/workbox-build/node_modules/@types/estree": {
+ "version": "0.0.39",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz",
+ "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/workbox-build/node_modules/estree-walker": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz",
+ "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/workbox-build/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/workbox-build/node_modules/pretty-bytes": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
+ "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/workbox-build/node_modules/rollup": {
+ "version": "2.80.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
+ "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/workbox-cacheable-response": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz",
+ "integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.0"
+ }
+ },
+ "node_modules/workbox-core": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz",
+ "integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/workbox-expiration": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz",
+ "integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "idb": "^7.0.1",
+ "workbox-core": "7.4.0"
+ }
+ },
+ "node_modules/workbox-google-analytics": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.0.tgz",
+ "integrity": "sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-background-sync": "7.4.0",
+ "workbox-core": "7.4.0",
+ "workbox-routing": "7.4.0",
+ "workbox-strategies": "7.4.0"
+ }
+ },
+ "node_modules/workbox-navigation-preload": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.0.tgz",
+ "integrity": "sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.0"
+ }
+ },
+ "node_modules/workbox-precaching": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz",
+ "integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.0",
+ "workbox-routing": "7.4.0",
+ "workbox-strategies": "7.4.0"
+ }
+ },
+ "node_modules/workbox-range-requests": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.0.tgz",
+ "integrity": "sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.0"
+ }
+ },
+ "node_modules/workbox-recipes": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.0.tgz",
+ "integrity": "sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-cacheable-response": "7.4.0",
+ "workbox-core": "7.4.0",
+ "workbox-expiration": "7.4.0",
+ "workbox-precaching": "7.4.0",
+ "workbox-routing": "7.4.0",
+ "workbox-strategies": "7.4.0"
+ }
+ },
+ "node_modules/workbox-routing": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz",
+ "integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.0"
+ }
+ },
+ "node_modules/workbox-strategies": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz",
+ "integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.0"
+ }
+ },
+ "node_modules/workbox-streams": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.0.tgz",
+ "integrity": "sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.0",
+ "workbox-routing": "7.4.0"
+ }
+ },
+ "node_modules/workbox-sw": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.0.tgz",
+ "integrity": "sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/workbox-window": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz",
+ "integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/trusted-types": "^2.0.2",
+ "workbox-core": "7.4.0"
+ }
+ },
+ "node_modules/xmlhttprequest": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz",
+ "integrity": "sha512-58Im/U0mlVBLM38NdZjHyhuMtCqa61469k2YP/AaPbvCoV9aQGUpbJBj1QRm2ytRiVQBD/fsw7L2bJGDVQswBA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..c353ae7
--- /dev/null
+++ b/frontend/package.json
@@ -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"
+ }
+}
diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg
new file mode 100644
index 0000000..743f944
--- /dev/null
+++ b/frontend/public/icon.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..34dc76d
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -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 (
+
+ );
+ }
+ if (!data?.authenticated) {
+ return ;
+ }
+ return children;
+}
+
+function HomeRoute() {
+ const walkup = useWalkupContext();
+
+ if (walkup.sessionQuery.isLoading) {
+ return (
+
+ );
+ }
+
+ if (!walkup.sessionQuery.data?.authenticated) {
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+function SignInRoute() {
+ const walkup = useWalkupContext();
+
+ if (walkup.sessionQuery.isLoading) {
+ return (
+
+ );
+ }
+
+ if (walkup.sessionQuery.data?.authenticated) {
+ return ;
+ }
+
+ return ;
+}
+
+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 (
+
+
+
+
+
Step 2 of 2
+
+ Pick the team you want to use.
+
+
+ You are signed in with TeamSnap. Choose a team to continue to {getRouteDestinationLabel(location.pathname)}.
+
+
+
+
+
+
+
Available teams
+ {walkup.teamsQuery.isLoading ? (
+
Loading teams...
+ ) : (
+
+ {walkup.teamsQuery.data?.map((team) => {
+ const teamId = String(team.id);
+ const selected = teamId === walkup.selectedTeamId;
+ return (
+
walkup.selectTeam(teamId)}
+ >
+
+
{formatTeamLabel(team)}
+
Tap to continue
+
+
+ {selected ? "Selected" : "Choose"}
+
+
+ );
+ })}
+ {!walkup.teamsQuery.data?.length ? (
+
No teams were returned for this account.
+ ) : null}
+
+ )}
+
+
+
+
+
+
+
What happens next
+
+
1. Sign in with TeamSnap.
+
2. Choose the team you want to manage.
+
3. Continue into the dashboard, walkup clips, or game tools.
+
+
+
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+
App Error
+
{this.state.errorMessage}
+
+
+
+ );
+ }
+ 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 (
+
+ {showNavbar ? (
+
+ ) : null}
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ {showTeamSelectionModal ?
: null}
+
+ );
+}
+
+export default function App() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
new file mode 100644
index 0000000..8b3e833
--- /dev/null
+++ b/frontend/src/api/client.ts
@@ -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(path: string, init?: RequestInit): Promise {
+ 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;
+}
+
+export const api = {
+ getSession: () => request("/auth/session"),
+ startTeamSnap: (returnTo: string) =>
+ request<{ authorize_url: string; state: string }>(`/auth/teamsnap/start?return_to=${encodeURIComponent(returnTo)}`),
+ getTeamSnapToken: () => request("/auth/teamsnap/token", { method: "POST" }),
+ adminLogin: (payload: { username: string; password: string }) =>
+ request("/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("/auth/session/walkup", { method: "POST", body: JSON.stringify(payload) }),
+ listAssets: (teamId: string, playerId?: string) =>
+ request(
+ `/media/assets?external_team_id=${encodeURIComponent(teamId)}${
+ playerId ? `&owner_external_player_id=${encodeURIComponent(playerId)}` : ""
+ }`,
+ ),
+ updateAsset: (assetId: number, payload: AudioAssetUpdate, ownerExternalPlayerId?: string) =>
+ request(
+ `/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((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("/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(
+ `/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(
+ `/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("/media/clips", { method: "POST", body: JSON.stringify(payload) }),
+ listAssignments: (gameId: string, playerId?: string) =>
+ request(
+ `/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(`/games/${encodeURIComponent(gameId)}/assignments`, {
+ method: "POST",
+ body: JSON.stringify(payload),
+ }),
+ prepareGame: (gameId: string) => request(`/games/${encodeURIComponent(gameId)}/prep`),
+ createPlaybackSession: (gameId: string, teamId: string) =>
+ request(`/games/${encodeURIComponent(gameId)}/operator/session`, {
+ method: "POST",
+ body: JSON.stringify({ external_team_id: teamId }),
+ }),
+ triggerPlaybackAssignment: (gameId: string, playbackSessionId: number, assignmentId: number) =>
+ request(`/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(`/games/${encodeURIComponent(gameId)}/operator/session/${playbackSessionId}/trigger`, {
+ method: "POST",
+ body: JSON.stringify({ clip_id: clipId, external_player_id: playerId, state: "playing" }),
+ }),
+};
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
new file mode 100644
index 0000000..c0e150d
--- /dev/null
+++ b/frontend/src/api/types.ts
@@ -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;
+}
diff --git a/frontend/src/hooks/useSession.ts b/frontend/src/hooks/useSession.ts
new file mode 100644
index 0000000..759bc26
--- /dev/null
+++ b/frontend/src/hooks/useSession.ts
@@ -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,
+ });
+}
+
diff --git a/frontend/src/hooks/useWalkupContext.tsx b/frontend/src/hooks/useWalkupContext.tsx
new file mode 100644
index 0000000..7095bcf
--- /dev/null
+++ b/frontend/src/hooks/useWalkupContext.tsx
@@ -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;
+
+const WalkupContext = createContext(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 {children} ;
+}
+
+export function useWalkupContext() {
+ const value = useContext(WalkupContext);
+ if (!value) {
+ throw new Error("useWalkupContext must be used within WalkupProvider");
+ }
+ return value;
+}
diff --git a/frontend/src/lib/media.ts b/frontend/src/lib/media.ts
new file mode 100644
index 0000000..e75746a
--- /dev/null
+++ b/frontend/src/lib/media.ts
@@ -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`;
+}
diff --git a/frontend/src/lib/offlinePrep.ts b/frontend/src/lib/offlinePrep.ts
new file mode 100644
index 0000000..e45f498
--- /dev/null
+++ b/frontend/src/lib/offlinePrep.ts
@@ -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;
+ }
+}
diff --git a/frontend/src/lib/queryClient.ts b/frontend/src/lib/queryClient.ts
new file mode 100644
index 0000000..bf65e5a
--- /dev/null
+++ b/frontend/src/lib/queryClient.ts
@@ -0,0 +1,4 @@
+import { QueryClient } from "@tanstack/react-query";
+
+export const queryClient = new QueryClient();
+
diff --git a/frontend/src/lib/teamsnap.ts b/frontend/src/lib/teamsnap.ts
new file mode 100644
index 0000000..79a2bb9
--- /dev/null
+++ b/frontend/src/lib/teamsnap.ts
@@ -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;
+ enablePersistence?: () => void;
+ loadCollections?: () => Promise;
+ loadMe?: () => Promise;
+ loadTeams?: (...args: unknown[]) => Promise;
+ loadMembers?: (params: unknown) => Promise;
+ loadEvents?: (params: unknown) => Promise;
+ loadEventLineups?: (params: unknown) => Promise;
+ loadAvailabilities?: (params: unknown) => Promise;
+ loadAssignments?: (params: unknown) => Promise;
+ bulkLoad?: (teamId: string | number, typesOrParams?: unknown) => Promise;
+ createEventLineup?: (data?: Record) => unknown;
+ saveEventLineup?: (eventLineup: unknown) => Promise | void;
+ deleteEventLineup?: (eventLineup: unknown) => Promise | void;
+ createEventLineupEntry?: (data?: Record) => unknown;
+ saveEventLineupEntry?: (eventLineupEntry: unknown) => Promise | void;
+ deleteEventLineupEntry?: (eventLineupEntry: unknown) => Promise | 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 | null = null;
+
+declare global {
+ interface Window {
+ teamsnap?: TeamSnapSdk;
+ }
+}
+
+function loadScript(src: string): Promise {
+ return new Promise((resolve, reject) => {
+ const existing = document.querySelector(`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 {
+ 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 {
+ 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 {
+ const sdk = await ensureAuthorized();
+ if (sdk.loadMe) {
+ return sdk.loadMe();
+ }
+ return null;
+ },
+ async loadTeams(): Promise {
+ 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 {
+ const sdk = await ensureAuthorized();
+ if (sdk.loadMembers) {
+ return sdk.loadMembers({ teamId });
+ }
+ return [];
+ },
+ async loadEvents(teamId: string): Promise {
+ const sdk = await ensureAuthorized();
+ if (sdk.loadEvents) {
+ return sdk.loadEvents({ teamId });
+ }
+ return [];
+ },
+ async loadAvailabilities(teamId: string, eventId?: string): Promise {
+ const sdk = await ensureAuthorized();
+ if (sdk.loadAvailabilities) {
+ return sdk.loadAvailabilities(eventId ? { teamId, eventId } : { teamId });
+ }
+ return [];
+ },
+ async loadAssignments(teamId: string, eventId?: string): Promise {
+ 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;
+ } | 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: [] };
+ }
+ },
+};
diff --git a/frontend/src/lib/teamsnapHelpers.ts b/frontend/src/lib/teamsnapHelpers.ts
new file mode 100644
index 0000000..1cedc79
--- /dev/null
+++ b/frontend/src/lib/teamsnapHelpers.ts
@@ -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();
+
+ 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();
+ const availabilityByMemberId = new Map();
+
+ for (const availability of availabilities) {
+ const memberId = toId(availability.memberId);
+ if (memberId) {
+ availabilityByMemberId.set(memberId, availability);
+ }
+ }
+
+ const lineupMembers: TeamSnapMember[] = [];
+ const lineupMembersSeen = new Set();
+
+ 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;
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..7b641b4
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -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(
+
+
+
+
+
+
+ ,
+);
diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx
new file mode 100644
index 0000000..e5c6d6a
--- /dev/null
+++ b/frontend/src/pages/AdminPage.tsx
@@ -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(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 (
+
+
+
+
+
+
+
Support
+
Admin sign-in
+
Use local credentials for bootstrap and maintenance.
+
+
+
TeamSnap sign-in remains the normal entry point.
+
+
+
+ {error ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
new file mode 100644
index 0000000..d220272
--- /dev/null
+++ b/frontend/src/pages/DashboardPage.tsx
@@ -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 (
+
+
+
+
Player flow
+
Sign in with TeamSnap to resolve your player and team context.
+
The player dashboard depends on your TeamSnap user, roster membership, and upcoming games.
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
Player dashboard
+
{walkup.nextGame ? formatGameTitle(walkup.nextGame) : "No upcoming game found yet."}
+
+ {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."}
+
+
+
+
+
+
+
+
Next game
+ {walkup.nextGame ? (
+ <>
+
{formatGameTitle(walkup.nextGame)}
+
{formatGameDate(walkup.nextGame)}
+ {walkup.nextGame.locationName ?
{walkup.nextGame.locationName}
: null}
+
+ navigate("/library")}>
+ Add walkup clip
+
+ navigate(`/games?gameId=${encodeURIComponent(String(walkup.nextGame?.id ?? ""))}`)}
+ >
+ Attach clip to game
+
+
+ >
+ ) : (
+
No upcoming games were returned for this team.
+ )}
+
+
+
+
+
+
+
Other games
+
+ {walkup.eventsQuery.isLoading ?
Loading games...
: null}
+ {walkup.games.slice(0, 8).map((game) => (
+
navigate(`/games?gameId=${encodeURIComponent(String(game.id))}`)}
+ >
+
+
{formatGameTitle(game)}
+
{formatGameDate(game)}
+
+ {String(game.id) === String(walkup.nextGame?.id) ? "Next" : "Browse"}
+
+ ))}
+ {!walkup.eventsQuery.isLoading && !walkup.games.length ? (
+
No games were returned for the selected team.
+ ) : null}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx
new file mode 100644
index 0000000..bb03cbc
--- /dev/null
+++ b/frontend/src/pages/GamePage.tsx
@@ -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(0);
+ const [slot, setSlot] = useState(1);
+ const [offlineMessage, setOfflineMessage] = useState(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 (
+
+
+
Reconnect with TeamSnap to attach clips to games.
+
+
+ );
+ }
+
+ if (!teamId || !playerId) {
+ return (
+
+
+
+ No player record was found on the selected team, so game-specific clip selection is unavailable.
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
Game clips
+
{selectedGame ? formatGameTitle(selectedGame) : "Select a game"}
+
+ {formatMemberName(walkup.currentPlayer)} can attach clips from song files in their own library to any game on{" "}
+ {formatTeamLabel(walkup.selectedTeam)}.
+
+
+
+
+
+
+
+
+
+ Selected game
+ selectGame(event.target.value)}>
+ Select a game
+ {walkup.games.map((game) => (
+
+ {formatGameTitle(game)}
+
+ ))}
+
+
+
+ {selectedGame ? formatGameDate(selectedGame) : "Choose a game to attach clips."}
+
+ {walkup.nextGame ?
Next game: {formatGameTitle(walkup.nextGame)}
: null}
+
+
+
+
+
+
+
+
Attach a clip
+ {selectedGame ? (
+ <>
+
{formatGameDate(selectedGame)}
+
+ Clip
+ setClipId(Number(event.target.value))}>
+ Select clip
+ {clipsQuery.data?.map((clip) => (
+
+ {clip.label} from song {clip.asset_title}
+
+ ))}
+
+
+
+ Suggested batting slot
+ setSlot(Number(event.target.value))} />
+
+
void saveMutation.mutateAsync()}>
+ {saveMutation.isPending ? "Saving..." : "Attach clip to this game"}
+
+ {saveMutation.error instanceof Error ?
{saveMutation.error.message}
: null}
+ >
+ ) : (
+
Pick a game to attach clips.
+ )}
+
+
+
+
+
+
+
+
+
Your selected clips
+
+ {assignmentsQuery.data?.map((assignment) => (
+
+
+
{assignment.clip_label}
+
+ {assignment.asset_title} {assignment.batting_slot ? `| slot ${assignment.batting_slot}` : ""}
+
+
+
{assignment.status}
+
+ ))}
+ {!assignmentsQuery.isLoading && !assignmentsQuery.data?.length ? (
+
No clips attached to this game yet.
+ ) : null}
+
+
+
+
+
+
+
+
Prepared payload
+
+
Prepared at: {prepQuery.data?.prepared_at ?? "Not prepared yet"}
+
Assignments in package: {prepQuery.data?.assignments.length ?? 0}
+
Cached locally: {cachedPrep ? `${cachedPrep.assignments.length} assignments` : "No"}
+
+
+ Cache on this device
+
+ {offlineMessage ?
{offlineMessage}
: null}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/LibraryPage.tsx b/frontend/src/pages/LibraryPage.tsx
new file mode 100644
index 0000000..2bf7d5f
--- /dev/null
+++ b/frontend/src/pages/LibraryPage.tsx
@@ -0,0 +1,1462 @@
+import { FormEvent, useEffect, useRef, useState } from "react";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import WaveSurfer from "wavesurfer.js";
+import RegionsPlugin, { type Region } from "wavesurfer.js/plugins/regions";
+
+import { api } from "../api/client";
+import type { AudioAsset, AudioClip } from "../api/types";
+import { useWalkupContext } from "../hooks/useWalkupContext";
+import { queryClient } from "../lib/queryClient";
+import { formatClipRange, formatPlaybackPosition } from "../lib/media";
+import { formatMemberName } from "../lib/teamsnapHelpers";
+
+const MEDIA_ACCEPT =
+ ".mp3,.m4a,.aac,.wav,.ogg,.oga,.flac,.mp4,.m4v,.mov,audio/*,video/*,application/octet-stream";
+const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
+const DEFAULT_CLIP_LENGTH_MS = 30_000;
+const TRIM_NUDGE_MS = 100;
+const TRIM_STEP_MS = 100;
+const TRIM_ZOOM_WINDOW_MS = 3_000;
+const TRIM_ZOOM_SLIDER_MAX = 100;
+
+type WalkupClipSourceMode = "upload" | "url" | "existing";
+
+type WalkupClipModalState =
+ | { mode: "create" }
+ | { mode: "edit"; clip: AudioClip };
+
+type BootstrapIconName = "play" | "stop" | "three-dots-vertical" | "pencil-square" | "plus-lg" | "x-lg";
+type TrimFocusEdge = "start" | "end";
+type SourceCreationProgress = {
+ label: string;
+ detail: string;
+ percent: number | null;
+};
+
+export function LibraryPage() {
+ const walkup = useWalkupContext();
+ const teamId = walkup.selectedTeamId;
+ const playerId = walkup.currentPlayerId;
+ const [walkupClipModal, setWalkupClipModal] = useState(null);
+ const [manageMediaOpen, setManageMediaOpen] = useState(false);
+ const audioRef = useRef(null);
+ const previewClipIdRef = useRef(null);
+ const previewRangeRef = useRef<{ startMs: number; endMs: number } | null>(null);
+ const [previewClipId, setPreviewClipId] = useState(null);
+ const [previewTimeMs, setPreviewTimeMs] = useState(null);
+
+ const assetsQuery = useQuery({
+ queryKey: ["assets", teamId, playerId],
+ queryFn: () => api.listAssets(teamId, playerId),
+ enabled: Boolean(teamId && playerId),
+ });
+ const clipsQuery = useQuery({
+ queryKey: ["clips", teamId, playerId],
+ queryFn: () => api.listClips(teamId, playerId),
+ enabled: Boolean(teamId && playerId),
+ });
+
+ useEffect(() => {
+ return () => {
+ stopPreview();
+ };
+ }, []);
+
+ const deleteClipMutation = useMutation({
+ mutationFn: (clipId: number) => api.deleteClip(clipId, playerId),
+ onSuccess: async () => {
+ stopPreview();
+ await queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] });
+ },
+ });
+
+ function getAudio() {
+ const audio = audioRef.current ?? new Audio();
+ if (!audioRef.current) {
+ audio.preload = "auto";
+ audio.onended = () => {
+ stopPreview();
+ };
+ audio.ontimeupdate = () => {
+ const range = previewRangeRef.current;
+ if (!range) {
+ return;
+ }
+
+ setPreviewTimeMs(Math.round(audio.currentTime * 1000));
+
+ if (audio.currentTime >= range.endMs / 1000) {
+ stopPreview();
+ }
+ };
+ }
+ audioRef.current = audio;
+ return audio;
+ }
+
+ function stopPreview() {
+ previewClipIdRef.current = null;
+ previewRangeRef.current = null;
+ setPreviewClipId(null);
+ setPreviewTimeMs(null);
+
+ const audio = audioRef.current;
+ if (!audio) {
+ return;
+ }
+
+ audio.pause();
+ audio.currentTime = 0;
+ audio.removeAttribute("src");
+ audio.load();
+ }
+
+ async function playPreview(
+ clip: AudioClip,
+ startMsOverride?: number,
+ endMsOverride?: number,
+ ) {
+ if (!clip.normalized_url) {
+ return;
+ }
+
+ const audio = getAudio();
+ const startMs = startMsOverride ?? clip.start_ms;
+ const endMs = endMsOverride ?? clip.end_ms;
+ const startSeconds = startMs / 1000;
+
+ if (previewClipIdRef.current === clip.id && !audio.paused) {
+ stopPreview();
+ return;
+ }
+
+ stopPreview();
+ const nextAudio = getAudio();
+ previewClipIdRef.current = clip.id;
+ previewRangeRef.current = { startMs, endMs };
+ setPreviewClipId(clip.id);
+ setPreviewTimeMs(startMs);
+ nextAudio.pause();
+ const metadataReady = new Promise((resolve) => {
+ nextAudio.onloadedmetadata = () => {
+ if (previewClipIdRef.current === clip.id) {
+ nextAudio.currentTime = startSeconds;
+ setPreviewTimeMs(startMs);
+ }
+ resolve();
+ };
+ });
+ nextAudio.src = `${API_BASE}${clip.normalized_url}`;
+ await metadataReady;
+
+ try {
+ await nextAudio.play();
+ } catch (error) {
+ stopPreview();
+ throw error;
+ }
+ }
+
+ function openCreateWalkupClip() {
+ setWalkupClipModal({ mode: "create" });
+ }
+
+ function closeCreateWalkupClip() {
+ stopPreview();
+ setWalkupClipModal(null);
+ }
+
+ function openEditWalkupClip(clip: AudioClip) {
+ setWalkupClipModal({ mode: "edit", clip });
+ }
+
+ function openManageMedia() {
+ setManageMediaOpen(true);
+ }
+
+ function closeManageMedia() {
+ stopPreview();
+ setManageMediaOpen(false);
+ }
+
+ if (!walkup.isTeamSnap) {
+ return Reconnect with TeamSnap to manage walkup clips.
;
+ }
+
+ if (!teamId || !playerId) {
+ return (
+
+ No player record was found on the selected team, so this account cannot add walkup clips yet.
+
+ );
+ }
+
+ return (
+
+
+
+
+
My Clips
+
{walkup.currentPlayer ? formatMemberName(walkup.currentPlayer) : "Selected Player"}
+
+
openCreateWalkupClip()}
+ aria-label="Create walk up clip"
+ title="Create walk up clip"
+ >
+
+
+
+
+ {clipsQuery.data?.map((clip) => (
+
void playPreview(clip)}
+ onEdit={() => openEditWalkupClip(clip)}
+ onStopPreview={stopPreview}
+ />
+ ))}
+ {!clipsQuery.isLoading && !clipsQuery.data?.length ? (
+ No walkup clips created yet. Open the modal to make the first one.
+ ) : null}
+ {deleteClipMutation.error instanceof Error ? {deleteClipMutation.error.message}
: null}
+
+
+
+
+
Uploaded media
+
Review the source files behind your walkup clips. You can rename or delete uploads here.
+
+
+
+ Manage uploaded media
+
+
+
+ {walkupClipModal ? (
+ {
+ stopPreview();
+ await deleteClipMutation.mutateAsync(clipId);
+ }}
+ isDeletingClip={deleteClipMutation.isPending}
+ />
+ ) : null}
+ {manageMediaOpen ? (
+
+ ) : null}
+
+ );
+}
+
+function BootstrapIcon({ name }: { name: BootstrapIconName }) {
+ if (name === "play") {
+ return (
+
+
+
+ );
+ }
+
+ if (name === "stop") {
+ return (
+
+
+
+ );
+ }
+
+ if (name === "three-dots-vertical") {
+ return (
+
+
+
+ );
+ }
+
+ if (name === "plus-lg") {
+ return (
+
+
+
+ );
+ }
+
+ if (name === "x-lg") {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+function WalkupClipModal({
+ state,
+ assets,
+ teamId,
+ playerId,
+ previewTimeMs,
+ playPreview,
+ onClose,
+ stopPreview,
+ onDeleteClip,
+ isDeletingClip,
+}: {
+ state: WalkupClipModalState;
+ assets: AudioAsset[];
+ teamId: string;
+ playerId: string;
+ previewTimeMs: number | null;
+ playPreview: (clip: AudioClip, startMs?: number, endMs?: number) => Promise;
+ onClose: () => void;
+ stopPreview: () => void;
+ onDeleteClip: (clipId: number) => Promise;
+ isDeletingClip: boolean;
+}) {
+ const isCreateMode = state.mode === "create";
+ const [step, setStep] = useState<"source" | "editor">(isCreateMode ? "source" : "editor");
+ const [sourceMode, setSourceMode] = useState("upload");
+ const [sourceTitle, setSourceTitle] = useState("");
+ const [draftLabel, setDraftLabel] = useState(state.mode === "edit" ? state.clip.label ?? "" : "");
+ const [file, setFile] = useState(null);
+ const [fileInputKey, setFileInputKey] = useState(0);
+ const [importUrl, setImportUrl] = useState("");
+ const [existingAssetId, setExistingAssetId] = useState("");
+ const [draftClip, setDraftClip] = useState(state.mode === "edit" ? state.clip : null);
+ const [draftStartMs, setDraftStartMs] = useState(state.mode === "edit" ? state.clip.start_ms : 0);
+ const [draftEndMs, setDraftEndMs] = useState(state.mode === "edit" ? state.clip.end_ms : DEFAULT_CLIP_LENGTH_MS);
+ const [sourceProgress, setSourceProgress] = useState(null);
+
+ useEffect(() => {
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = previousOverflow;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (state.mode === "edit") {
+ setStep("editor");
+ setDraftClip(state.clip);
+ setDraftStartMs(state.clip.start_ms);
+ setDraftEndMs(state.clip.end_ms);
+ setDraftLabel(state.clip.label ?? "");
+ }
+ }, [state]);
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ stopPreview();
+ onClose();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [onClose, stopPreview]);
+
+ const resolveCreatedClip = async (assetId: number) => {
+ setSourceProgress({
+ label: "Loading clip",
+ detail: "Refreshing the library with the generated walkup clip.",
+ percent: null,
+ });
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["assets", teamId, playerId] }),
+ queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] }),
+ ]);
+ const refreshedClips = await queryClient.fetchQuery({
+ queryKey: ["clips", teamId, playerId],
+ queryFn: () => api.listClips(teamId, playerId),
+ });
+ const createdClip = refreshedClips.find((clip) => clip.asset_id === assetId);
+ if (!createdClip) {
+ throw new Error("The new walk up clip could not be found after saving the media.");
+ }
+ return createdClip;
+ };
+
+ const createSourceMutation = useMutation({
+ mutationFn: async () => {
+ if (sourceMode === "upload") {
+ if (!file) {
+ throw new Error("Select a media file first");
+ }
+ setSourceProgress({
+ label: "Uploading file",
+ detail: "Sending the media file to the server.",
+ percent: 0,
+ });
+ const asset = await api.uploadAsset({
+ teamId,
+ playerId,
+ title: sourceTitle.trim() || file.name,
+ file,
+ onUploadProgress: (percent) => {
+ setSourceProgress({
+ label: "Uploading file",
+ detail: "Sending the media file to the server.",
+ percent,
+ });
+ },
+ onProcessingStart: () => {
+ setSourceProgress({
+ label: "Generating waveform",
+ detail: "Upload complete. Normalizing audio and building the waveform preview.",
+ percent: null,
+ });
+ },
+ });
+ const clip = await resolveCreatedClip(asset.id);
+ return clip;
+ }
+
+ if (sourceMode === "url") {
+ if (!importUrl.trim()) {
+ throw new Error("Paste a media URL first");
+ }
+ setSourceProgress({
+ label: "Importing media",
+ detail: "Downloading the media, normalizing audio, and generating the waveform.",
+ percent: null,
+ });
+ const asset = await api.importAssetFromUrl({
+ external_team_id: teamId,
+ owner_external_player_id: playerId,
+ url: importUrl.trim(),
+ title: sourceTitle.trim() || undefined,
+ });
+ const clip = await resolveCreatedClip(asset.id);
+ return clip;
+ }
+
+ if (!existingAssetId) {
+ throw new Error("Choose an existing media file first");
+ }
+
+ setSourceProgress({
+ label: "Creating clip",
+ detail: "Creating a walkup clip from the selected media.",
+ percent: null,
+ });
+ const clip = await api.createClip({
+ asset_id: Number(existingAssetId),
+ external_team_id: teamId,
+ owner_external_player_id: playerId,
+ label: draftLabel.trim() || "Walkup clip",
+ start_ms: 0,
+ end_ms: DEFAULT_CLIP_LENGTH_MS,
+ });
+ await queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] });
+ return clip;
+ },
+ onSuccess: (clip) => {
+ setSourceProgress(null);
+ setDraftClip(clip);
+ setDraftStartMs(clip.start_ms);
+ setDraftEndMs(clip.end_ms);
+ setDraftLabel(clip.label || "Walkup clip");
+ setStep("editor");
+ },
+ onError: () => {
+ setSourceProgress(null);
+ },
+ });
+
+ function resetToSource() {
+ stopPreview();
+ setSourceProgress(null);
+ setStep("source");
+ setDraftClip(null);
+ setFileInputKey((current) => current + 1);
+ }
+
+ function handleClose() {
+ stopPreview();
+ onClose();
+ }
+
+ function handleSourceSubmit(event: FormEvent) {
+ event.preventDefault();
+ void createSourceMutation.mutateAsync();
+ }
+
+ return (
+
+
event.stopPropagation()}
+ >
+
+
+
{isCreateMode ? "Create walk up clip" : "Edit walk up clip"}
+
+ {step === "source" ? "Choose a source" : isCreateMode ? "Trim and name the clip" : "Edit metadata"}
+
+
+
+
+
+
+
+
+
1. Source
+
2. Trim and metadata
+
+
+ {step === "source" ? (
+
+ ) : null}
+
+ {step === "editor" && draftClip ? (
+
void playPreview(draftClip, startMs, endMs)}
+ onStopPreview={stopPreview}
+ previewTimeMs={previewTimeMs}
+ playerId={playerId}
+ onSaveComplete={async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["assets", teamId, playerId] }),
+ queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] }),
+ ]);
+ handleClose();
+ }}
+ saveButtonLabel={isCreateMode ? "Save walk up clip" : "Save changes"}
+ introText={
+ isCreateMode
+ ? `Source: ${draftClip.asset_title}. Use the controls below to trim the clip and update its metadata.`
+ : `Edit ${draftClip.asset_title}. Update the clip range and metadata below.`
+ }
+ onChangeSource={isCreateMode ? resetToSource : undefined}
+ closeLabel={isCreateMode ? "Cancel" : "Close"}
+ onClose={handleClose}
+ onDelete={async () => onDeleteClip(draftClip.id)}
+ isDeleting={isDeletingClip}
+ />
+ ) : null}
+
+
+
+ );
+}
+
+function ManageUploadedMediaModal({
+ assets,
+ teamId,
+ playerId,
+ onClose,
+ stopPreview,
+}: {
+ assets: AudioAsset[];
+ teamId: string;
+ playerId: string;
+ onClose: () => void;
+ stopPreview: () => void;
+}) {
+ useEffect(() => {
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = previousOverflow;
+ };
+ }, []);
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ stopPreview();
+ onClose();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [onClose, stopPreview]);
+
+ const deleteAssetMutation = useMutation({
+ mutationFn: (assetId: number) => api.deleteAsset(assetId, playerId),
+ onSuccess: async () => {
+ stopPreview();
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["assets", teamId, playerId] }),
+ queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] }),
+ ]);
+ },
+ });
+
+ function handleClose() {
+ stopPreview();
+ onClose();
+ }
+
+ return (
+
+
event.stopPropagation()}
+ >
+
+
+
+ Rename or delete the uploaded media that backs your walkup clips. Existing clip edits stay in the clip view.
+
+
+ {assets.map((asset) => (
+
void deleteAssetMutation.mutateAsync(asset.id)}
+ isDeleting={deleteAssetMutation.isPending}
+ teamId={teamId}
+ playerId={playerId}
+ />
+ ))}
+ {!assets.length ? No uploaded media has been added yet.
: null}
+ {deleteAssetMutation.error instanceof Error ? {deleteAssetMutation.error.message}
: null}
+
+
+
+
+ );
+}
+
+function WalkupClipCard({
+ clip,
+ isPreviewing,
+ onPreview,
+ onEdit,
+ onStopPreview,
+}: {
+ clip: AudioClip;
+ isPreviewing: boolean;
+ onPreview: () => void;
+ onEdit: () => void;
+ onStopPreview: () => void;
+}) {
+ const [menuOpen, setMenuOpen] = useState(false);
+
+ return (
+
+
+
+
+
+
+ {clip.label}
+
+
+
setMenuOpen((current) => !current)}
+ aria-label="Clip menu"
+ aria-expanded={menuOpen}
+ aria-haspopup="menu"
+ title="Clip menu"
+ >
+
+
+ {menuOpen ? (
+
+
+
+
+
+ Edit clip
+
+
Source: {clip.asset_title}
+
+ ) : null}
+
+
+
+ );
+}
+
+function SourceProgressPanel({ progress }: { progress: SourceCreationProgress }) {
+ const progressValue = progress.percent ?? 100;
+ return (
+
+
+ {progress.label}
+ {progress.percent !== null ? {progress.percent}% : Working... }
+
+
+
{progress.detail}
+
+ );
+}
+
+function WalkupClipEditorPanel({
+ clip,
+ label,
+ setLabel,
+ startMs,
+ setStartMs,
+ endMs,
+ setEndMs,
+ previewTimeMs,
+ onPreview,
+ onStopPreview,
+ onSaveComplete,
+ saveButtonLabel,
+ introText,
+ onChangeSource,
+ closeLabel,
+ onClose,
+ onDelete,
+ isDeleting,
+ playerId,
+}: {
+ clip: AudioClip;
+ label: string;
+ setLabel: (value: string) => void;
+ startMs: number;
+ setStartMs: (value: number) => void;
+ endMs: number;
+ setEndMs: (value: number) => void;
+ previewTimeMs: number | null;
+ onPreview: (startMs: number, endMs: number) => void;
+ onStopPreview: () => void;
+ onSaveComplete: () => Promise;
+ saveButtonLabel: string;
+ introText: string;
+ onChangeSource?: () => void;
+ closeLabel: string;
+ onClose: () => void;
+ onDelete: () => Promise;
+ isDeleting: boolean;
+ playerId: string;
+}) {
+ useEffect(() => {
+ setLabel(clip.label ?? "");
+ setStartMs(clip.start_ms);
+ setEndMs(clip.end_ms);
+ }, [clip.end_ms, clip.id, clip.label, clip.start_ms, setEndMs, setLabel, setStartMs]);
+
+ const previewClip = {
+ ...clip,
+ start_ms: startMs,
+ end_ms: endMs,
+ label,
+ };
+
+ const saveMutation = useMutation({
+ mutationFn: async () => {
+ const trimmedLabel = label.trim();
+ if (!trimmedLabel) {
+ throw new Error("Walkup clip name cannot be blank");
+ }
+ if (endMs <= startMs) {
+ throw new Error("Clip end must be greater than start");
+ }
+ return api.updateClip(clip.id, {
+ label: trimmedLabel !== clip.label ? trimmedLabel : undefined,
+ start_ms: startMs,
+ end_ms: endMs,
+ }, playerId);
+ },
+ });
+
+ async function handleSave() {
+ try {
+ await saveMutation.mutateAsync();
+ await onSaveComplete();
+ } catch {
+ // Mutation state already captures the error for display in the modal.
+ }
+ }
+
+ async function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ await handleSave();
+ }
+
+ function useThirtySecondLength() {
+ setEndMs(startMs + DEFAULT_CLIP_LENGTH_MS);
+ }
+
+ function handleStartChange(nextStart: number) {
+ setStartMs(nextStart);
+ if (nextStart >= endMs) {
+ setEndMs(nextStart + DEFAULT_CLIP_LENGTH_MS);
+ }
+ }
+
+ function handleEndChange(nextEnd: number) {
+ setEndMs(nextEnd);
+ if (nextEnd <= startMs) {
+ setStartMs(Math.max(0, nextEnd - DEFAULT_CLIP_LENGTH_MS));
+ }
+ }
+
+ const trimmedLabel = label.trim();
+ const canSave = trimmedLabel.length > 0 && endMs > startMs && !saveMutation.isPending;
+
+ return (
+
+ );
+}
+
+function snapTrimMs(value: number, stepMs: number): number {
+ return Math.round(value / stepMs) * stepMs;
+}
+
+function peaksForWaveSurfer(peaks: number[]): number[][] | undefined {
+ if (!peaks.length) {
+ return undefined;
+ }
+
+ return [peaks.map((peak) => Math.max(0, Math.min(1, peak / 100)))];
+}
+
+function ClipTrimScrubber({
+ clip,
+ durationMs,
+ waveformPeaks,
+ startMs,
+ endMs,
+ previewTimeMs,
+ onStartChange,
+ onEndChange,
+ onPreview,
+ onStopPreview,
+}: {
+ clip: AudioClip;
+ durationMs: number;
+ waveformPeaks: number[];
+ startMs: number;
+ endMs: number;
+ previewTimeMs: number | null;
+ onStartChange: (value: number) => void;
+ onEndChange: (value: number) => void;
+ onPreview: () => void;
+ onStopPreview: () => void;
+}) {
+ const waveformContainerRef = useRef(null);
+ const waveSurferRef = useRef(null);
+ const regionsPluginRef = useRef(null);
+ const trimRegionRef = useRef(null);
+ const suppressRegionUpdateRef = useRef(false);
+ const latestTrimRef = useRef({
+ onStartChange,
+ onEndChange,
+ stepMs: TRIM_STEP_MS,
+ scrubMaxMs: durationMs,
+ });
+ const scrubMaxMs = Math.max(durationMs, endMs, startMs + 1);
+ const safeStartMs = Math.min(startMs, scrubMaxMs - 1);
+ const safeEndMs = Math.max(endMs, safeStartMs + 1);
+ const [zoomAmount, setZoomAmount] = useState(55);
+ const [focusEdge, setFocusEdge] = useState("start");
+ const [waveformReady, setWaveformReady] = useState(false);
+ const zoomRatio = Math.pow(Math.max(0, Math.min(1, zoomAmount / TRIM_ZOOM_SLIDER_MAX)), 1.8);
+ const closeWindowMs = Math.min(scrubMaxMs, TRIM_ZOOM_WINDOW_MS);
+ const targetViewDurationMs = zoomAmount <= 0 ? scrubMaxMs : scrubMaxMs - (scrubMaxMs - closeWindowMs) * zoomRatio;
+ const viewCenterMs = focusEdge === "end" ? safeEndMs : safeStartMs;
+ const halfWindowMs = targetViewDurationMs / 2;
+ let viewStartMs = zoomAmount <= 0 ? 0 : Math.max(0, viewCenterMs - halfWindowMs);
+ let viewEndMs = zoomAmount <= 0 ? scrubMaxMs : Math.min(scrubMaxMs, viewCenterMs + halfWindowMs);
+ if (zoomAmount > 0 && viewEndMs - viewStartMs < targetViewDurationMs) {
+ if (viewStartMs === 0) {
+ viewEndMs = Math.min(scrubMaxMs, targetViewDurationMs);
+ } else {
+ viewStartMs = Math.max(0, scrubMaxMs - targetViewDurationMs);
+ }
+ }
+ const viewDurationMs = Math.max(1, viewEndMs - viewStartMs);
+ const sliderStepMs = TRIM_STEP_MS;
+
+ useEffect(() => {
+ latestTrimRef.current = {
+ onStartChange,
+ onEndChange,
+ stepMs: sliderStepMs,
+ scrubMaxMs,
+ };
+ }, [onEndChange, onStartChange, scrubMaxMs, sliderStepMs]);
+
+ useEffect(() => {
+ const container = waveformContainerRef.current;
+ if (!container || !clip.normalized_url) {
+ return;
+ }
+
+ setWaveformReady(false);
+ trimRegionRef.current = null;
+ const regions = RegionsPlugin.create();
+ const wavesurfer = WaveSurfer.create({
+ container,
+ url: `${API_BASE}${clip.normalized_url}`,
+ peaks: peaksForWaveSurfer(waveformPeaks),
+ duration: scrubMaxMs / 1000,
+ height: 110,
+ waveColor: "rgba(19, 34, 56, 0.28)",
+ progressColor: "rgba(217, 79, 4, 0.8)",
+ cursorColor: "rgba(19, 34, 56, 0.9)",
+ cursorWidth: 2,
+ minPxPerSec: 1,
+ fillParent: true,
+ normalize: true,
+ dragToSeek: false,
+ interact: false,
+ hideScrollbar: false,
+ plugins: [regions],
+ });
+
+ waveSurferRef.current = wavesurfer;
+ regionsPluginRef.current = regions;
+
+ const unsubscribeReady = wavesurfer.on("ready", () => {
+ const region = regions.addRegion({
+ id: "walkup-trim-region",
+ start: safeStartMs / 1000,
+ end: safeEndMs / 1000,
+ color: "rgba(217, 79, 4, 0.18)",
+ drag: false,
+ resize: true,
+ resizeStart: true,
+ resizeEnd: true,
+ minLength: 0.05,
+ });
+ trimRegionRef.current = region;
+ setWaveformReady(true);
+ });
+
+ const unsubscribeRegionUpdate = regions.on("region-update", (region, side) => {
+ if (region.id !== "walkup-trim-region" || suppressRegionUpdateRef.current) {
+ return;
+ }
+
+ const { onStartChange: updateStart, onEndChange: updateEnd, stepMs, scrubMaxMs: maxMs } = latestTrimRef.current;
+ const nextStartMs = Math.max(0, Math.min(maxMs - 1, snapTrimMs(region.start * 1000, stepMs)));
+ const nextEndMs = Math.max(nextStartMs + 1, Math.min(maxMs, snapTrimMs(region.end * 1000, stepMs)));
+ if (side !== "end") {
+ setFocusEdge("start");
+ updateStart(nextStartMs);
+ }
+ if (side !== "start") {
+ setFocusEdge("end");
+ updateEnd(nextEndMs);
+ }
+ });
+
+ return () => {
+ unsubscribeRegionUpdate();
+ unsubscribeReady();
+ trimRegionRef.current = null;
+ regionsPluginRef.current = null;
+ waveSurferRef.current = null;
+ setWaveformReady(false);
+ wavesurfer.destroy();
+ };
+ }, [clip.id, clip.normalized_url, durationMs, waveformPeaks]);
+
+ useEffect(() => {
+ const region = trimRegionRef.current;
+ if (!region) {
+ return;
+ }
+
+ suppressRegionUpdateRef.current = true;
+ region.setOptions({
+ start: safeStartMs / 1000,
+ end: safeEndMs / 1000,
+ });
+ window.setTimeout(() => {
+ suppressRegionUpdateRef.current = false;
+ }, 0);
+ }, [safeEndMs, safeStartMs]);
+
+ useEffect(() => {
+ const wavesurfer = waveSurferRef.current;
+ const container = waveformContainerRef.current;
+ if (!wavesurfer || !container || !waveformReady) {
+ return;
+ }
+
+ const viewDurationSeconds = Math.max(0.1, viewDurationMs / 1000);
+ const minPxPerSec = zoomAmount <= 0 ? 1 : Math.max(1, container.clientWidth / viewDurationSeconds);
+ wavesurfer.zoom(minPxPerSec);
+ wavesurfer.setScrollTime(zoomAmount <= 0 ? 0 : viewStartMs / 1000);
+ }, [viewDurationMs, viewStartMs, waveformReady, zoomAmount]);
+
+ useEffect(() => {
+ const wavesurfer = waveSurferRef.current;
+ if (!wavesurfer || previewTimeMs === null) {
+ return;
+ }
+
+ wavesurfer.setTime(previewTimeMs / 1000);
+ }, [previewTimeMs]);
+
+ function handleZoomAmountChange(value: number) {
+ setZoomAmount(Math.max(0, Math.min(TRIM_ZOOM_SLIDER_MAX, value)));
+ }
+
+ function nudgeStart(deltaMs: number) {
+ const nextStartMs = Math.max(0, safeStartMs + deltaMs);
+ setFocusEdge("start");
+ onStartChange(nextStartMs);
+ }
+
+ function nudgeEnd(deltaMs: number) {
+ const nextEndMs = Math.max(safeStartMs + 1, safeEndMs + deltaMs);
+ setFocusEdge("end");
+ onEndChange(nextEndMs);
+ }
+
+ return (
+
+
+
+
Trim waveform
+
+ {formatClipRange(safeStartMs, safeEndMs)} | Step {sliderStepMs}ms
+
+
+
+ Source: {formatPlaybackPosition(durationMs)}
+
+
+
+
+
+ Zoom scrubber
+ {zoomAmount === 0 ? "Full file" : `${zoomAmount}%`}
+
+ handleZoomAmountChange(Number(event.target.value))}
+ disabled={!clip.normalized_url || !waveformReady}
+ />
+
+
+
+
+
Start
+
{formatPlaybackPosition(safeStartMs)}
+
+ nudgeStart(-TRIM_NUDGE_MS)}>
+ -{TRIM_NUDGE_MS}ms
+
+ nudgeStart(TRIM_NUDGE_MS)}>
+ +{TRIM_NUDGE_MS}ms
+
+
+
+
+
+
+
End
+
{formatPlaybackPosition(safeEndMs)}
+
+ nudgeEnd(-TRIM_NUDGE_MS)}>
+ -{TRIM_NUDGE_MS}ms
+
+ nudgeEnd(TRIM_NUDGE_MS)}>
+ +{TRIM_NUDGE_MS}ms
+
+
+
+
+
+
+
+
+ {previewTimeMs !== null ? "Stop preview" : "Preview clip"}
+
+
+
+ );
+}
+
+function UploadedMediaCard({
+ asset,
+ onDelete,
+ isDeleting,
+ teamId,
+ playerId,
+}: {
+ asset: AudioAsset;
+ onDelete: () => void;
+ isDeleting: boolean;
+ teamId: string;
+ playerId: string;
+}) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [title, setTitle] = useState(asset.title ?? "");
+
+ useEffect(() => {
+ setIsEditing(false);
+ setTitle(asset.title ?? "");
+ }, [asset.id, asset.title]);
+
+ const updateMutation = useMutation({
+ mutationFn: () => api.updateAsset(asset.id, { title: title.trim() }, playerId),
+ onSuccess: async () => {
+ setIsEditing(false);
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["assets", teamId, playerId] }),
+ queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] }),
+ ]);
+ },
+ });
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ void updateMutation.mutateAsync();
+ }
+
+ const trimmedTitle = title.trim();
+ const titleChanged = trimmedTitle !== asset.title;
+ const canSave = isEditing && trimmedTitle.length > 0 && titleChanged && !updateMutation.isPending;
+
+ return (
+
+ );
+}
diff --git a/frontend/src/pages/OperatorPage.tsx b/frontend/src/pages/OperatorPage.tsx
new file mode 100644
index 0000000..0f4fcd5
--- /dev/null
+++ b/frontend/src/pages/OperatorPage.tsx
@@ -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(null);
+ const [playingClipKey, setPlayingClipKey] = useState(null);
+ const [nowPlaying, setNowPlaying] = useState(null);
+ const [isPlaybackPlaying, setIsPlaybackPlaying] = useState(false);
+ const selectedPlayerWasManualRef = useRef(false);
+ const hasInitializedExpandedPlayerRef = useRef(false);
+ const audioRef = useRef(null);
+ const audioContextRef = useRef(null);
+ const mediaSourceRef = useRef(null);
+ const gainNodeRef = useRef(null);
+ const playbackRangeRef = useRef<{ startSeconds: number; endSeconds: number } | null>(null);
+ const fadeOutTimerRef = useRef(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,
+ ) {
+ 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((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 (
+
+ Reconnect with TeamSnap to run operator mode.
+
+ );
+ }
+
+ return (
+
+ {isPlaybackPlaying && nowPlaying ? (
+
+
+ Now playing
+ {nowPlaying.title}
+ {nowPlaying.subtitle}
+
+
+ stopPlayback()}>
+ Stop
+
+ fadeOutPlayback()}>
+ Fade out
+
+
+
+ ) : null}
+
+
Operator mode
+
{selectedGame ? formatGameTitle(selectedGame) : "Select a game to operate"}
+
+ 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.
+
+
+
+
+
+
+ Selected game
+ selectGame(event.target.value)}>
+ Select a game
+ {walkup.games.map((game) => (
+
+ {formatGameTitle(game)}
+
+ ))}
+
+
+
+ {selectedGame ? formatGameDate(selectedGame) : "Choose a game to operate."}
+
+ {walkup.nextGame ?
Next game: {formatGameTitle(walkup.nextGame)}
: null}
+
+
+
+
+
Players
+
+ Filter
+ setPlayerFilter(event.target.value as typeof playerFilter)}>
+ Players
+ Non-players
+ All
+
+
+
+
+ {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 (
+
+
{
+ selectedPlayerWasManualRef.current = true;
+ setSelectedPlayerId(memberId);
+ setExpandedPlayerId((current) => (current === memberId ? "" : memberId));
+ }}
+ aria-expanded={isExpanded}
+ aria-controls={expansionId}
+ id={`player-${memberId}-toggle`}
+ >
+
+
+
+
+ {formatMemberName(member)}
+ {jerseyNumber ? ` ${jerseyNumber}` : ""}
+
+ {lineupEntry ? Lineup {lineupEntry.sequence ?? "?"} : null}
+
+
{playerMeta.join(" • ")}
+
+
+ {isExpanded ? "−" : "›"}
+
+
+ {isExpanded ? (
+
+
+
+
Game clips
+ Attached to this game
+
+
+ {selectedAssignments.length ? (
+ selectedAssignments.map((assignment) => {
+ const key = clipKey("assignment", assignment.id);
+ const isPlaying = playingClipKey === key;
+ return (
+
+
+
{assignment.clip_label}
+
+ {assignment.asset_title} {assignment.batting_slot ? `| slot ${assignment.batting_slot}` : ""}
+
+
+
void playAssignment(assignment)}
+ aria-pressed={isPlaying}
+ >
+
+ {isPlaying ? "Stop" : "Play"}
+
+
+ );
+ })
+ ) : (
+
No clips attached to this game for this player yet.
+ )}
+
+
+
+
+
Clip library
+ Available clips for this player
+
+
+
+
+
+
+
Debug
+
+ Show raw lineup data
+
+ {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,
+ )}
+
+
+
+
+ ) : null}
+
+ );
+ })}
+ {!visibleMembers.length ?
No members match this filter.
: null}
+
+
+
+
Session
+
void createSession.mutateAsync()}>
+ {createSession.isPending ? "Starting..." : playbackSessionId ? "Session ready" : "Start operator session"}
+
+
Team: {formatTeamLabel(walkup.selectedTeam)}
+
Game: {selectedGame ? formatGameDate(selectedGame) : "Select a game"}
+
+ Player:{" "}
+ {selectedPlayer ? `${formatMemberName(selectedPlayer)}${selectedPlayerJersey ? ` ${selectedPlayerJersey}` : ""}` : "Select a player"}
+
+ {triggerAssignmentMutation.error instanceof Error ?
{triggerAssignmentMutation.error.message}
: null}
+ {triggerClipMutation.error instanceof Error ?
{triggerClipMutation.error.message}
: null}
+
+
+
+ );
+}
+
+function LibraryClips({
+ teamId,
+ playerId,
+ playingClipKey,
+ onPlayClip,
+}: {
+ teamId: string;
+ playerId: string;
+ playingClipKey: string | null;
+ onPlayClip: (clip: AudioClip) => Promise;
+}) {
+ const fallbackClipsQuery = useQuery({
+ queryKey: ["clips", teamId, playerId],
+ queryFn: () => api.listClips(teamId, playerId),
+ enabled: Boolean(teamId && playerId),
+ });
+
+ if (fallbackClipsQuery.isLoading) {
+ return Loading library clips...
;
+ }
+
+ if (!fallbackClipsQuery.data?.length) {
+ return No library clips available for this player.
;
+ }
+
+ return (
+ <>
+ {fallbackClipsQuery.data.map((clip) => {
+ const key = clipKey("library", clip.id);
+ const isPlaying = playingClipKey === key;
+ return (
+
+
+ {clip.label}
+
+
void onPlayClip(clip)}
+ aria-pressed={isPlaying}
+ >
+
+ {isPlaying ? "Stop" : "Play"}
+
+
+ );
+ })}
+ >
+ );
+}
diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx
new file mode 100644
index 0000000..0edb0bf
--- /dev/null
+++ b/frontend/src/pages/ProfilePage.tsx
@@ -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 (
+
+
+
+
Profile
+
{walkup.hasSelectedTeam ? formatTeamLabel(walkup.selectedTeam) : "Choose your team"}
+
+ Session details and the selected team live here. The team choice is stored on this device and reused on the
+ next visit.
+
+
+
+
+
+
+
+
Session
+
Provider: {walkup.sessionQuery.data?.provider ?? "none"}
+
Authenticated: {walkup.sessionQuery.data?.authenticated ? "yes" : "no"}
+
+ Player: {walkup.currentPlayer ? formatMemberName(walkup.currentPlayer) : "No matching player on the selected team"}
+
+
+ {walkup.isTeamSnap ? (
+ void reconnect()}>
+ Reconnect TeamSnap
+
+ ) : null}
+ void logoutMutation.mutateAsync()}>
+ {logoutMutation.isPending ? "Signing out..." : "Sign out"}
+
+
+
+
+
+
+
+
+
Selected team
+ {walkup.isTeamSnap ? (
+ <>
+
+ Team and season
+ walkup.selectTeam(event.target.value)}
+ disabled={walkup.teamsQuery.isLoading}
+ >
+ Select a team
+ {walkup.teamsQuery.data?.map((team) => (
+
+ {formatTeamLabel(team)}
+
+ ))}
+
+
+
+ {walkup.hasSelectedTeam
+ ? `Current selection: ${formatTeamLabel(walkup.selectedTeam)}`
+ : "Pick a team to continue into the app."}
+
+ >
+ ) : (
+
Team selection is available for TeamSnap-backed sessions.
+ )}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/SignInPage.tsx b/frontend/src/pages/SignInPage.tsx
new file mode 100644
index 0000000..f2e7d0b
--- /dev/null
+++ b/frontend/src/pages/SignInPage.tsx
@@ -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(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 (
+
+
+
+
+
+
+
Walkup
+
Sign in
+
Use TeamSnap to continue into your team dashboard.
+
+
+
+ After sign-in, you will choose your team and land in the app with your session ready.
+
+
+ Sign in with TeamSnap
+
+
+
TeamSnap sign-in is the primary access path.
+
+
+
+ {error ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
new file mode 100644
index 0000000..1d64417
--- /dev/null
+++ b/frontend/src/styles.css
@@ -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;
+ }
+}
diff --git a/frontend/src/types/teamsnap-js.d.ts b/frontend/src/types/teamsnap-js.d.ts
new file mode 100644
index 0000000..22b4cd9
--- /dev/null
+++ b/frontend/src/types/teamsnap-js.d.ts
@@ -0,0 +1 @@
+declare module "teamsnap.js";
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..ed77210
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1,2 @@
+///
+
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..7182dc1
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -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" }]
+}
+
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..c88b91b
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
+
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..d94fb73
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -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],
+ },
+ };
+});
diff --git a/ops/Caddyfile b/ops/Caddyfile
new file mode 100644
index 0000000..71be2da
--- /dev/null
+++ b/ops/Caddyfile
@@ -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}
+}
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..1c7cbf0
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+-r backend/requirements-dev.txt
diff --git a/scripts/create-dev-certs.sh b/scripts/create-dev-certs.sh
new file mode 100755
index 0000000..a54b0b4
--- /dev/null
+++ b/scripts/create-dev-certs.sh
@@ -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 <> "${LOG_FILE}"
+
+cd "${ROOT_DIR}"
+docker compose up --build "$@" 2>&1 | tee -a "${LOG_FILE}"
diff --git a/secrets/dev-proxy-cert.pem.example b/secrets/dev-proxy-cert.pem.example
new file mode 100644
index 0000000..99a6c19
--- /dev/null
+++ b/secrets/dev-proxy-cert.pem.example
@@ -0,0 +1,3 @@
+-----BEGIN CERTIFICATE-----
+replace-with-your-local-dev-certificate
+-----END CERTIFICATE-----
diff --git a/secrets/dev-proxy-key.pem.example b/secrets/dev-proxy-key.pem.example
new file mode 100644
index 0000000..833c449
--- /dev/null
+++ b/secrets/dev-proxy-key.pem.example
@@ -0,0 +1,3 @@
+-----BEGIN PRIVATE KEY-----
+replace-with-your-local-dev-private-key
+-----END PRIVATE KEY-----
diff --git a/secrets/teamsnap_client_id.txt.example b/secrets/teamsnap_client_id.txt.example
new file mode 100644
index 0000000..e96c6da
--- /dev/null
+++ b/secrets/teamsnap_client_id.txt.example
@@ -0,0 +1 @@
+your-client-id
diff --git a/secrets/teamsnap_client_secret.txt.example b/secrets/teamsnap_client_secret.txt.example
new file mode 100644
index 0000000..49bd007
--- /dev/null
+++ b/secrets/teamsnap_client_secret.txt.example
@@ -0,0 +1 @@
+your-client-secret