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 ( +
+
+
Loading session...
+
+
+ ); + } + if (!data?.authenticated) { + return ; + } + return children; +} + +function HomeRoute() { + const walkup = useWalkupContext(); + + if (walkup.sessionQuery.isLoading) { + return ( +
+
+
Loading session...
+
+
+ ); + } + + if (!walkup.sessionQuery.data?.authenticated) { + return ; + } + + return ( + + ); +} + +function SignInRoute() { + const walkup = useWalkupContext(); + + if (walkup.sessionQuery.isLoading) { + return ( +
+
+
Loading session...
+
+
+ ); + } + + 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.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 ? ( +
+
+
+ Baseball audio ops + Walkup +
+ + +
+
+ ) : 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 ? ( +
+
+ {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} +
+ + +
+ + ) : ( +
No upcoming games were returned for this team.
+ )} +
+
+
+
+
+
+

Other games

+
+ {walkup.eventsQuery.isLoading ?
Loading games...
: null} + {walkup.games.slice(0, 8).map((game) => ( + + ))} + {!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)}. +

+
+
+
+
+
+
+
+ +
+ {selectedGame ? formatGameDate(selectedGame) : "Choose a game to attach clips."} +
+ {walkup.nextGame ?
Next game: {formatGameTitle(walkup.nextGame)}
: null} +
+
+
+
+
+
+
+

Attach a clip

+ {selectedGame ? ( + <> +
{formatGameDate(selectedGame)}
+ + + + {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"}
+
+ + {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"}
+
+ +
+
+ {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.
+
+
+ +
+
+ {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" ? ( +
+ +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+ + +
Upload a file and the backend will create a walk up clip from it.
+
+
+
+
+ + +
Import a URL and the backend will download and normalize it for the clip flow.
+
+
+
+
+ + {existingAssetId ? ( +
The clip will be created from the selected existing media file.
+ ) : ( +
Choose one of the existing media files in this walkup media library.
+ )} +
+
+
+ {sourceProgress ? : null} +
+ + +
+ {createSourceMutation.error instanceof Error ? ( +
{createSourceMutation.error.message}
+ ) : null} + + ) : 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()} + > +
+
+

Walkup clips

+

+ Manage uploaded media +

+
+ +
+
+
+ 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} +
+
+ + {menuOpen ? ( +
+ +
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 ( +
+
{introText}
+ + void onPreview(startMs, endMs)} + onStopPreview={onStopPreview} + /> + {previewTimeMs !== null ? ( +
+ Preview range: {formatClipRange(previewClip.start_ms, previewClip.end_ms)} + {" "} + Current time: {formatPlaybackPosition(previewTimeMs)} / {formatPlaybackPosition(previewClip.end_ms)} +
+ ) : null} +
+ + + {onChangeSource ? ( + + ) : null} + +
+ {saveMutation.error instanceof Error ?
{saveMutation.error.message}
: null} + + ); +} + +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)} +
+
+
+ +
+
+
+ Start + {formatPlaybackPosition(safeStartMs)} +
+ + +
+
+
+
+
+ End + {formatPlaybackPosition(safeEndMs)} +
+ + +
+
+
+
+
+ +
+
+ ); +} + +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 ( +
+
+
+ {isEditing ? "Editing media title" : asset.title || "Untitled media"} +
Uploaded as: {asset.original_filename || "Unknown file"}
+
+
+ {Math.round(asset.size_bytes / 1024)} KB + + +
+
+ {isEditing ? ( + + ) : null} +
+ {isEditing && trimmedTitle.length === 0 ?
Media title cannot be blank.
: null} + {updateMutation.error instanceof Error ?
{updateMutation.error.message}
: null} + {!isEditing ?
Open Edit title to rename this uploaded media item.
: null} +
+ {isEditing ? ( +
+ +
+ ) : null} +
+ ); +} 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} +
+
+ + +
+
+ ) : 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. +

+
+
+
+
+ +
+ {selectedGame ? formatGameDate(selectedGame) : "Choose a game to operate."} +
+ {walkup.nextGame ?
Next game: {formatGameTitle(walkup.nextGame)}
: null} +
+
+
+
+

Players

+ +
+
+ {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 ( +
+ + {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}` : ""} +
+
+ +
+ ); + }) + ) : ( +
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

+ +
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} +
+ +
+ ); + })} + + ); +} 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 ? ( + + ) : null} + +
+
+
+
+
+
+
+

Selected team

+ {walkup.isTeamSnap ? ( + <> + +
+ {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. +

+ +
+
TeamSnap sign-in is the primary access path.
+
+
+
+ {error ? ( +
+
+ {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